diff --git a/docs/developer-guide/LayerFilter.md b/docs/developer-guide/LayerFilter.md index a31dfacbad..00ad710cf7 100644 --- a/docs/developer-guide/LayerFilter.md +++ b/docs/developer-guide/LayerFilter.md @@ -91,7 +91,7 @@ The `cql` format is a JSON object that has this shape: ``` !!! Note: - MapStore actually supports only a subset of CQL, that is the one used by GeoServer. In particular can not parse WKT geometries yet. + MapStore actually supports only a subset of CQL, that is the one used by GeoServer. ### `mapstore-query-panel` format diff --git a/web/client/utils/ogc/Filter/CQL/__tests__/parser-test.js b/web/client/utils/ogc/Filter/CQL/__tests__/parser-test.js index 5adf180c40..500ab7c8b3 100644 --- a/web/client/utils/ogc/Filter/CQL/__tests__/parser-test.js +++ b/web/client/utils/ogc/Filter/CQL/__tests__/parser-test.js @@ -1,72 +1,64 @@ import expect from 'expect'; -import {get} from 'lodash'; -import parser from '../parser'; +import { get } from 'lodash'; +import {read, functionOperator} from '../parser'; const COMPARISON_TESTS = [ // numeric { cql: "PROP = 1", expected: { - property: "PROP", - type: "=", - value: 1 + args: [{type: "property", name: "PROP"}, {type: "literal", value: 1}], + type: "=" } }, { cql: "PROP <> 1", expected: { - property: "PROP", - type: "<>", - value: 1 + args: [{type: "property", name: "PROP"}, {type: "literal", value: 1}], + type: "<>" } }, { cql: "PROP < 1", expected: { - property: "PROP", - type: "<", - value: 1 + args: [{type: "property", name: "PROP"}, {type: "literal", value: 1}], + type: "<" } }, { cql: "PROP <= 1", expected: { - property: "PROP", - type: "<=", - value: 1 + args: [{type: "property", name: "PROP"}, {type: "literal", value: 1}], + type: "<=" } }, { cql: "PROP > 1", expected: { - property: "PROP", - type: ">", - value: 1 + args: [{type: "property", name: "PROP"}, {type: "literal", value: 1}], + type: ">" } }, { cql: "PROP >= 1", expected: { - property: "PROP", - type: ">=", - value: 1 + args: [{type: "property", name: "PROP"}, {type: "literal", value: 1}], + type: ">=" } }, // string { cql: "PROP = 'a'", expected: { - property: "PROP", - type: "=", - value: 'a' + args: [{type: "property", name: "PROP"}, {type: "literal", value: 'a'}], + type: "=" } }, { cql: "PROP like 'a'", expected: { - property: "PROP", - type: "like", - value: 'a' + args: [{type: "property", name: "PROP"}, {type: "literal", value: 'a'}], + type: "like" } }, { @@ -76,12 +68,12 @@ const COMPARISON_TESTS = [ } } ]; + const VARIANTS = [{ cql: "\"PROP\" = 'a'", expected: { - property: "PROP", - type: "=", - value: 'a' + args: [{type: "property", name: "PROP"}, {type: "literal", value: 'a'}], + type: "=" } }]; @@ -91,12 +83,12 @@ const LOGICAL = [ cql: "PROP1 like 'a' and PROP2 < 1", expected: { "type": "and", - "filters[0].property": "PROP1", + "filters[0].args[0].name": "PROP1", "filters[0].type": "like", - "filters[0].value": "a", - "filters[1].property": "PROP2", + "filters[0].args[1].value": "a", + "filters[1].args[0].name": "PROP2", "filters[1].type": "<", - "filters[1].value": 1 + "filters[1].args[1].value": 1 } }, // or @@ -104,12 +96,12 @@ const LOGICAL = [ cql: "PROP1 like 'a' or PROP2 < 1", expected: { "type": "or", - "filters[0].property": "PROP1", + "filters[0].args[0].name": "PROP1", "filters[0].type": "like", - "filters[0].value": "a", - "filters[1].property": "PROP2", + "filters[0].args[1].value": "a", + "filters[1].args[0].name": "PROP2", "filters[1].type": "<", - "filters[1].value": 1 + "filters[1].args[1].value": 1 } }, // not @@ -117,9 +109,9 @@ const LOGICAL = [ cql: "NOT X < 12", expected: { "type": "not", - "filters[0].property": "X", + "filters[0].args[0].name": "X", "filters[0].type": "<", - "filters[0].value": 12 + "filters[0].args[1].value": 12 } }, // complex filter @@ -129,21 +121,299 @@ const LOGICAL = [ "type": "and", "filters[0].type": "and", "filters[0].filters[0].type": "like", - "filters[0].filters[0].property": "PROP1", - "filters[0].filters[0].value": "a", + "filters[0].filters[0].args[0].name": "PROP1", + "filters[0].filters[0].args[1].value": "a", "filters[0].filters[1].type": "<", - "filters[0].filters[1].property": "PROP2", - "filters[0].filters[1].value": 1, + "filters[0].filters[1].args[0].name": "PROP2", + "filters[0].filters[1].args[1].value": 1, "filters[1].type": "or", "filters[1].filters[0].type": "<>", - "filters[1].filters[0].property": "PROP1", - "filters[1].filters[0].value": "a", + "filters[1].filters[0].args[0].name": "PROP1", + "filters[1].filters[0].args[1].value": "a", "filters[1].filters[1].type": "not", - "filters[1].filters[1].filters[0].property": "PROP2" + "filters[1].filters[1].filters[0].args[0].name": "PROP2" } } ]; +const WKT_TESTS = [ + // POINT + { + cql: "INTERSECTS(PROP1, POINT(1 2))", + expected: { + type: "INTERSECTS", + args: [{ + type: "property", + name: "PROP1" + }, { + type: "Point", + coordinates: [1, 2] + }] + } + }, + // MULTIPOINT + { + cql: "INTERSECTS(PROP1, MULTIPOINT(1 2, 3 4))", + expected: { + type: "INTERSECTS", + args: [{type: "property", name: "PROP1"}, + { + type: "MultiPoint", + coordinates: [[1, 2], [3, 4]] + }] + } + }, + // LINESTRING + { + cql: "INTERSECTS(PROP1, LINESTRING(1 2, 3 4))", + expected: { + type: "INTERSECTS", + args: [{ + type: "property", + name: "PROP1" + }, { + type: "LineString", + coordinates: [[1, 2], [3, 4]] + }] + } + }, + // MULTILINESTRING + { + cql: "INTERSECTS(PROP1, MULTILINESTRING((1 2, 3 4), (5 6, 7 8)))", + expected: { + type: "INTERSECTS", + args: [ {type: "property", name: "PROP1"}, { + type: "MultiLineString", + coordinates: [[[1, 2], [3, 4]], [[5, 6], [7, 8]]] + }] + } + }, + // POLYGON + { + cql: "INTERSECTS(PROP1, POLYGON((1 2, 3 4, 5 6, 1 2)))", + expected: { + type: "INTERSECTS", + args: [ + {type: "property", name: "PROP1"}, { + type: "Polygon", + coordinates: [[[1, 2], [3, 4], [5, 6], [1, 2]]] + }] + } + }, + // MULTIPOLYGON + { + cql: "INTERSECTS(PROP1, MULTIPOLYGON(((1 2, 3 4, 5 6, 1 2)), ((7 8, 9 10, 11 12, 7 8))))", + expected: { + type: "INTERSECTS", + args: [ + {type: "property", name: "PROP1"}, + { + type: "MultiPolygon", + coordinates: [ + [[[1, 2], [3, 4], [5, 6], [1, 2]]], + [[[7, 8], [9, 10], [11, 12], [7, 8]]] + ] + }] + } + } +]; +const FUNCTION_TESTS = [ + { + cql: "func('text')", + expected: { + type: functionOperator, + name: "func", + args: [{type: 'literal', value: 'text'}] + } + }, + // multiple strings + { + cql: "func('Hello', ' ', 'World')", + expected: { + type: functionOperator, + name: "func", + args: [ + {type: 'literal', value: 'Hello'}, + {type: 'literal', value: ' '}, + {type: 'literal', value: 'World'} + ] + } + }, + // mixed args + { + cql: "func('abcdef', 2, 4)", + expected: { + type: functionOperator, + name: "func", + args: [ + {type: 'literal', value: 'abcdef'}, + {type: 'literal', value: 2}, + {type: 'literal', value: 4} + ] + } + }, + // Property Names + { + cql: "func( propertyName )", + expected: { + type: functionOperator, + name: "func", + args: [{type: 'property', name: 'propertyName'}] + } + }, + // with double quotes + { + cql: 'func("firstName" , lastName )', + expected: { + type: functionOperator, + name: "func", + args: [ + {type: 'property', name: 'firstName'}, + {type: 'property', name: 'lastName'} + ] + } + }, + // mixing numbers, property names and strings + { + cql: 'substring("address", 1, \'test\')', + expected: { + type: functionOperator, + name: "substring", + args: [ + {type: 'property', name: 'address'}, + {type: 'literal', value: 1}, + {type: 'literal', value: 'test'}] + } + }, + { + cql: 'func(propertyName, \'oldValue\', \'newValue\')', + expected: { + type: functionOperator, + name: "func", + args: [ + {type: 'property', name: 'propertyName'}, + {type: 'literal', value: 'oldValue'}, + {type: 'literal', value: 'newValue'} + ] + } + }, + + // Numbers + { + cql: "func(-5)", + expected: { + type: functionOperator, + name: "func", + args: [{type: 'literal', value: -5}] + } + }, + { + cql: "func(3.14159)", + expected: { + type: functionOperator, + name: "func", + args: [{type: 'literal', value: 3.14159}] + } + }, + // nested functions + { + cql: "func(func2('text'))", + expected: { + type: functionOperator, + name: "func", + args: [{ + type: functionOperator, + name: "func2", + args: [{type: 'literal', value: 'text'}] + }] + } + }, + { + cql: "func(func2('text1'), func3('text2'))", + expected: { + type: functionOperator, + name: "func", + args: [ + { + type: functionOperator, + name: "func2", + args: [{type: 'literal', value: 'text1'}] + }, + { + type: functionOperator, + name: "func3", + args: [{type: 'literal', value: 'text2'}] + } + ] + } + }, + // nested functions with property names + { + cql: "func(func2(propertyName))", + expected: { + type: functionOperator, + name: "func", + args: [{ + type: functionOperator, + name: "func2", + args: [{type: 'property', name: 'propertyName'}] + }] + } + }, + // nested functions with multiple arguments + { + cql: "func(func2(propertyName, 'text1'), func3('text2', 2))", + expected: { + type: functionOperator, + name: "func", + args: [ + { + type: functionOperator, + name: "func2", + args: [ + {type: 'property', name: 'propertyName'}, + {type: 'literal', value: 'text1'} + ] + }, { + type: functionOperator, + name: "func3", + args: [ + {type: 'literal', value: 'text2'}, + {type: 'literal', value: 2} + ] + } + ] + } + }, + // Existing functions + + // jsonArrayContains + { + cql: "jsonArrayContains(\"properties\", 'key', 'value')", + expected: { + "type": functionOperator, + "name": "jsonArrayContains", + "args": [ + {type: 'property', name: "properties"}, + {type: 'literal', value: "key"}, + {type: 'literal', value: "value"} + ] + + } + }, { + cql: "jsonPointer(\"properties\", 'key')", + expected: { + "type": functionOperator, + "name": "jsonPointer", + "args": [ + {type: 'property', name: "properties"}, + {type: 'literal', value: "key"} + ] + } + } + +]; + const REAL_WORLD = [ // real world example { @@ -152,31 +422,246 @@ const REAL_WORLD = [ "type": "and", "filters[0].type": "and", "filters[0].filters[0].filters[0].type": "<=", - "filters[1].property": "TPINCID" + "filters[1].args[0].type": "property", + "filters[1].args[0].name": "TPINCID", + "filters[1].type": "=", + "filters[1].args[1].value": "1" + + } + }, { + cql: 'func1() = false', + expected: { + "type": "=", + "args": [{ + "type": functionOperator, + "name": "func1", + "args": [] + }, { + "type": "literal", + "value": false + }] + } + }, + { + cql: "jsonArrayContains(\"property1\", 'key', 'value') = true", + expected: { + "type": "=", + "args": [{ + "type": functionOperator, + "name": "jsonArrayContains", + "args": [ + {type: 'property', name: "property1"}, + {type: 'literal', value: "key"}, + {type: 'literal', value: "value"} + ] + }, { + "type": "literal", + "value": true + }] + } + }, + + { + cql: "INTERSECTS(PROP1, POINT(1 2)) AND PROP2 < 1", + expected: { + "type": "and", + "filters": [{ + "type": "INTERSECTS", + "args": [ + {type: "property", name: "PROP1"}, + { + "type": "Point", + "coordinates": [1, 2] + }] + }, { + "type": "<", + "args": [{type: "property", name: "PROP2"}, {type: "literal", value: 1}] + }] + + } + }, + { + cql: "a = 1 AND b = 2", + expected: { + "type": "and", + "filters": [{ + "type": "=", + "args": [{type: 'property', name: "a"}, {type: 'literal', value: 1}] + }, { + "type": "=", + "args": [{type: 'property', name: "b"}, {type: 'literal', value: 2}] + }] + } + }, { + cql: "f1(a) AND f2(b)", + expected: { + "type": "and", + "filters": [{ + "type": functionOperator, + "name": "f1", + "args": [{type: 'property', name: "a"}] + }, { + "type": functionOperator, + "name": "f2", + "args": [{type: 'property', name: "b"}] + }] + } + }, + { + cql: "jsonArrayContains(\"property1\", 'key', 'value') = false AND jsonPointer(\"property2\", 'key') = 'value')", + expected: { + "type": "and", + filters: [{ + "type": "=", + "args": [{ + "type": functionOperator, + "name": "jsonArrayContains", + "args": [ + {type: 'property', name: "property1"}, + {type: 'literal', value: "key"}, + {type: 'literal', value: "value"} + ] + }, { + "type": "literal", + "value": false + }] + }, { + "type": "=", + "args": [{ + "type": functionOperator, + "name": "jsonPointer", + "args": [ + {type: 'property', name: "property2"}, + {type: 'literal', value: "key"} + ] + }, { + "type": "literal", + "value": "value" + }] + }] + } + }, + { + cql: "(jsonArrayContains(\"property1\", 'key', 'value') = false) AND (jsonPointer(\"property2\", 'key') = 'value'))", + expected: { + "type": "and", + filters: [{ + "type": "=", + "args": [{ + "type": functionOperator, + "name": "jsonArrayContains", + "args": [ + {type: 'property', name: "property1"}, + {type: 'literal', value: "key"}, + {type: 'literal', value: "value"} + ] + }, { + "type": "literal", + "value": false + }] + }, { + "type": "=", + "args": [{ + "type": functionOperator, + "name": "jsonPointer", + "args": [ + {type: 'property', name: "property2"}, + {type: 'literal', value: "key"} + ] + }, { + "type": "literal", + "value": "value" + }] + }] + } + }, + { + // mixed functions and operators + cql: `jsonArrayContains(\"property1\", 'key', 'value') = false AND jsonPointer(\"property2\", 'key') = 'value' AND INTERSECTS(geom, POINT(1 2)) AND property3 = 'value'`, + expected: { + type: "and", + filters: [{ + "type": "and", + filters: [{ + type: "and", + filters: [{ + "type": "=", + "args": [{ + "type": functionOperator, + "name": "jsonArrayContains", + "args": [ + {type: 'property', name: "property1"}, + {type: 'literal', value: "key"}, + {type: 'literal', value: "value"} + ] + }, { + "type": "literal", + "value": false + }] + }, { + "type": "=", + "args": [{ + "type": functionOperator, + "name": "jsonPointer", + "args": [ + {type: 'property', name: "property2"}, + {type: 'literal', value: "key"} + ] + }, { + "type": "literal", + "value": "value" + }] + }] + }, { + "type": "INTERSECTS", + "args": [ + {type: "property", name: "geom"}, + { + "type": "Point", + "coordinates": [1, 2] + }] + }] + }, { + "type": "=", + "args": [{type: 'property', name: "property3"}, {type: 'literal', value: "value"}] + }] } } ]; const testRules = rules => rules.map(({ cql, expected }) => { - const res = parser.read(cql); - Object.keys(expected).map(k => - expect(get(res, k)).toBe(expected[k]) - ); + it(`testing ${cql}`, () => { + try { + const res = read(cql); + Object.keys(expected).map(k => { + expect(get(res, k)).toEqual(expected[k]); + }); + } catch (e) { + throw e; + } + }); }); describe('cql parser', () => { - it('test simple comparison', () => { + describe('test simple comparison', () => { testRules(COMPARISON_TESTS); }); - it('test logical operators', () => { + describe('test logical operators', () => { testRules(LOGICAL); }); - it('test variants of operators', () => { + describe('test variants of operators', () => { testRules(VARIANTS); }); - it('test more real world examples', () => { + describe('test wkt parsing', () => { + testRules(WKT_TESTS); + }); + describe('test function parsing', () => { + testRules(FUNCTION_TESTS); + }); + describe('test more real world examples', () => { testRules(REAL_WORLD); }); + }); diff --git a/web/client/utils/ogc/Filter/CQL/parser.js b/web/client/utils/ogc/Filter/CQL/parser.js index a6d6b37aa1..aeb392d7c4 100644 --- a/web/client/utils/ogc/Filter/CQL/parser.js +++ b/web/client/utils/ogc/Filter/CQL/parser.js @@ -5,29 +5,31 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ -const fromWKT = () => { - throw new Error("WKT parsing for CQL filter not supported yet"); -}; // TODO: use wkt-parser -const spatialOperators = { +import {toGeoJSON} from '../../WKT'; + +export const spatialOperators = { INTERSECTS: "INTERSECTS", BBOX: "BBOX", CONTAINS: "CONTAINS", DWITHIN: "DWITHIN", WITHIN: "WITHIN" }; -const patterns = { + +export const functionOperator = "func"; +export const patterns = { INCLUDE: /^INCLUDE$/, PROPERTY: /^"?[_a-zA-Z"]\w*"?/, COMPARISON: /^(=|<>|<=|<|>=|>|LIKE)/i, IS_NULL: /^IS NULL/i, COMMA: /^,/, LOGICAL: /^(AND|OR)/i, - VALUE: /^('([^']|'')*'|-?\d+(\.\d*)?|\.\d+)/, + VALUE: /^('([^']|'')*'|-?\d+(\.\d*)?|\.\d+|true|false)/i, LPAREN: /^\(/, RPAREN: /^\)/, SPATIAL: /^(BBOX|INTERSECTS|DWITHIN|WITHIN|CONTAINS)/i, NOT: /^NOT/i, BETWEEN: /^BETWEEN/i, + FUNCTION: /^[_a-zA-Z][_a-zA-Z1-9]*(?=\()/, GEOMETRY: (text) => { let type = /^(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)/.exec(text); if (type) { @@ -57,18 +59,19 @@ const patterns = { }; const follows = { INCLUDE: ['END'], - LPAREN: ['GEOMETRY', 'SPATIAL', 'PROPERTY', 'VALUE', 'LPAREN'], - RPAREN: ['NOT', 'LOGICAL', 'END', 'RPAREN'], - PROPERTY: ['COMPARISON', 'BETWEEN', 'COMMA', 'IS_NULL'], + LPAREN: ['GEOMETRY', 'SPATIAL', 'FUNCTION', 'PROPERTY', 'VALUE', 'LPAREN', 'RPAREN', 'NOT'], + RPAREN: ['NOT', 'LOGICAL', 'END', 'RPAREN', 'COMMA', 'COMPARISON', 'BETWEEN', 'IS_NULL'], + PROPERTY: ['COMPARISON', 'BETWEEN', 'COMMA', 'IS_NULL', 'RPAREN'], BETWEEN: ['VALUE'], IS_NULL: ['END'], - COMPARISON: ['VALUE'], - COMMA: ['GEOMETRY', 'VALUE', 'PROPERTY'], + COMPARISON: ['VALUE', 'FUNCTION'], + COMMA: ['GEOMETRY', 'FUNCTION', 'VALUE', 'PROPERTY'], VALUE: ['LOGICAL', 'COMMA', 'RPAREN', 'END'], SPATIAL: ['LPAREN'], - LOGICAL: ['NOT', 'VALUE', 'SPATIAL', 'PROPERTY', 'LPAREN'], + LOGICAL: ['NOT', 'VALUE', 'SPATIAL', 'FUNCTION', 'PROPERTY', 'LPAREN'], NOT: ['PROPERTY', 'LPAREN'], - GEOMETRY: ['COMMA', 'RPAREN'] + GEOMETRY: ['COMMA', 'RPAREN'], + FUNCTION: ['LPAREN', 'FUNCTION', 'VALUE', 'PROPERTY'] }; @@ -141,7 +144,7 @@ const nextToken = (text, tokens) => { const tokenize = (text) => { let results = []; let token; - const expect = ["INCLUDE", "NOT", "GEOMETRY", "SPATIAL", "PROPERTY", "LPAREN"]; + const expect = ["INCLUDE", "NOT", "GEOMETRY", "SPATIAL", "FUNCTION", "PROPERTY", "LPAREN"]; let text2 = text; let expect2 = expect; do { @@ -161,13 +164,16 @@ const tokenize = (text) => { const buildAst = (tokens) => { let operatorStack = []; let postfix = []; - while (tokens.length) { let tok = tokens.shift(); switch (tok.type) { case "PROPERTY": case "GEOMETRY": case "VALUE": + if (operatorStack.length > 0 && + operatorStack?.[operatorStack.length - 2]?.type === "FUNCTION") { + operatorStack[operatorStack.length - 2].count++; + } postfix.push(tok); break; case "COMPARISON": @@ -182,12 +188,26 @@ const buildAst = (tokens) => { ) { postfix.push(operatorStack.pop()); } - + if (operatorStack.length > 0 && + operatorStack?.[operatorStack.length - 2]?.type === "FUNCTION") { + operatorStack[operatorStack.length - 2].count++; + } operatorStack.push(tok); break; case "SPATIAL": + case "FUNCTION": case "NOT": + if (operatorStack.length > 0 && + operatorStack?.[operatorStack.length - 2]?.type === "FUNCTION") { + operatorStack[operatorStack.length - 2].count++; + } + operatorStack.push(tok); + break; case "LPAREN": + if (operatorStack.length > 0 && + operatorStack?.[operatorStack.length - 1]?.type === "FUNCTION") { + operatorStack[operatorStack.length - 1].count = 0; + } operatorStack.push(tok); break; case "RPAREN": @@ -202,6 +222,12 @@ const buildAst = (tokens) => { operatorStack[operatorStack.length - 1].type === "SPATIAL") { postfix.push(operatorStack.pop()); } + + if (operatorStack.length > 0 && + operatorStack[operatorStack.length - 1].type === "FUNCTION") { + let funcTok = operatorStack.pop(); + postfix.push(funcTok); + } break; case "COMMA": case "END": @@ -237,39 +263,68 @@ const buildAst = (tokens) => { let min = buildTree(); const property = buildTree(); return ({ - property, - lowerBoundary: min, - upperBoundary: max, + args: [property, min, max], type: operators.BETWEEN }); } case "COMPARISON": { - let value = buildTree(); - const property = buildTree(); + const arg2 = buildTree(); + const arg1 = buildTree(); return ({ - property, - value: value, + args: [arg1, arg2], type: operators[tok.text.toUpperCase()] }); } case "IS_NULL": { const property = buildTree(); return ({ - property, + args: [property], type: operators[tok.text.toUpperCase()] }); } case "VALUE": let match = tok.text.match(/^'(.*)'$/); if (match) { - return match[1].replace(/''/g, "'"); + return { + type: 'literal', + value: match[1].replace(/''/g, "'") + }; } - return Number(tok.text); + if (tok.text.toLowerCase() === "true" || tok.text.toLowerCase() === "false") { + return { + type: 'literal', + value: tok.text.toLowerCase() === "true" + }; + } + return { + type: 'literal', + value: Number(tok.text) + }; + case "PROPERTY": + return ({ + type: "property", + name: tok.text + }); + case "INCLUDE": { return ({ type: cql.INCLUDE }); } + case "FUNCTION": { + let name = tok.text.replace(/\($/, ""); + let args = []; + for ( let i = 0; i < tok.count; i++) { + args.push(buildTree()); + } + + return ({ + type: functionOperator, + name, + args: args.reverse() + }); + } + case "SPATIAL": switch (tok.text.toUpperCase()) { case "BBOX": { @@ -290,8 +345,7 @@ const buildAst = (tokens) => { const property = buildTree(); return ({ type: spatialOperators.INTERSECTS, - property, - value + args: [property, value] }); } case "WITHIN": { @@ -299,8 +353,7 @@ const buildAst = (tokens) => { let property = buildTree(); return ({ type: spatialOperators.WITHIN, - property, - value + args: [property, value] }); } case "CONTAINS": { @@ -308,8 +361,7 @@ const buildAst = (tokens) => { const property = buildTree(); return ({ type: spatialOperators.CONTAINS, - property, - value + args: [property, value] }); } case "DWITHIN": { @@ -327,7 +379,8 @@ const buildAst = (tokens) => { return null; } case "GEOMETRY": - return fromWKT(tok.text); + // WKT to convert in GeoJSON. + return toGeoJSON(tok.text); default: return tok.text; } @@ -344,33 +397,42 @@ const buildAst = (tokens) => { return result; }; -module.exports = { - /** - * Parse a CQL filter. returns an object representation of the filter. - * For the moment this parser doesn't support WKT parsing. - * Example: - * ``` - * const cqlFilter = "property1 = 'value1' AND property2 = 'value2'"; - * const obj = read(cqlFilter); - * console.log(obj); - * // obj looks like this - * { - * type: "and", - * filters: [{ - * type: "=", - * property: "property1", - * value: "value1" - * },{ - * type: "=", - * property: "property1", - * value: "value2" - * }] - * } - * ``` - * @memberof utils.ogc.Filter.CQL.parser - * @name read - * @param cqlFilter the cql_filter o parse - * @return a javascript representation of the filter. - */ - read: (text) => buildAst(tokenize(text)) -}; + + +/** + * Parse a CQL filter. returns an AST object representation of the filter. + * Example: + * ``` + * const cqlFilter = "property1 = 'value1' AND property2 = 'value2'"; + * const obj = read(cqlFilter); + * console.log(obj); + * // obj looks like this + * { + * type: "and", + * filters: [{ + * type: "=", + * args[{type: "property", name: "property1"}, {type: "literal", value: "value1"}] + * },{ + * type: "=", + * property: "property1", + * args[{type: "property", name: "property1"}, {type: "literal", value: "value1"}] + * }, { + * type: "func", + * name: "func1", + * args: [{ + * type: "property", + * name: "property5" + * }, { + * type: "literal", + * value: "value5" + * }] + * }]] + * } + * ``` + * @memberof utils.ogc.Filter.CQL.parser + * @name read + * @param cqlFilter the cql_filter o parse + * @return a javascript representation of the filter. + */ +export const read = (text) => buildAst(tokenize(text)); + diff --git a/web/client/utils/ogc/Filter/FilterBuilder.js b/web/client/utils/ogc/Filter/FilterBuilder.js index e49ca146bb..fd94b803e6 100644 --- a/web/client/utils/ogc/Filter/FilterBuilder.js +++ b/web/client/utils/ogc/Filter/FilterBuilder.js @@ -5,7 +5,7 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ -const {logical, spatial, comparison, literal, propertyName, valueReference, distance, lower, upper} = require('./operators'); +const {logical, spatial, comparison, literal, propertyName, valueReference, distance, lower, upper, func} = require('./operators'); const {filter, fidFilter} = require('./filter'); const {processOGCGeometry} = require("../GML"); // const isValidXML = (value, {filterNS, gmlNS}) => value.indexOf(`<${filterNS}:` === 0) || value.indexOf(`<${gmlNS}:`) === 0; @@ -21,7 +21,7 @@ const {processOGCGeometry} = require("../GML"); * filter( * and( * property("P1").equals("v1"), - * proprety("the_geom").intersects(geoJSONGeometry) + * property("the_geom").intersects(geoJSONGeometry) * ) * ), * {srsName="EPSG:4326"} // 3rd for query is optional @@ -92,11 +92,27 @@ module.exports = function({filterNS = "ogc", gmlVersion, wfsVersion = "1.1.0"} = }; const propName = wfsVersion.indexOf("2.") === 0 ? valueReference : propertyName; return { + + operations: Object.entries({ + ...spatial, + ...comparison, + ...logical, + // override between to avoid to explicit lower and upper boundaries as arguments + between: (ns, prop, ...args) => comparison.between(ns, prop, lower(ns, args[0]), upper(ns, args[1])), + func + }).reduce((acc, [key, fun]) => { + acc[key] = fun.bind(null, filterNS); + return acc; + }, {}), + geometry: getGeom, filter: filter.bind(null, filterNS), fidFilter: fidFilter.bind(null, filterNS), and: logical.and.bind(null, filterNS), or: logical.or.bind(null, filterNS), not: logical.not.bind(null, filterNS), + func: func.bind(null, filterNS), + literal: getValue, + propertyName: propName.bind(null, filterNS), property: function(name) { return { equalTo: (value) => comparison.equal(filterNS, propName(filterNS, name), getValue(value)), diff --git a/web/client/utils/ogc/Filter/__tests__/fromObject-test.js b/web/client/utils/ogc/Filter/__tests__/fromObject-test.js index 0835b7e785..908119cbf2 100644 --- a/web/client/utils/ogc/Filter/__tests__/fromObject-test.js +++ b/web/client/utils/ogc/Filter/__tests__/fromObject-test.js @@ -106,6 +106,70 @@ const CQL_SPECIFIC = [{ expected: "" }]; +const FUNCTIONS = [ + // empty + { + cql: "func1() = false", + expected: '' + + '' + + '' + + 'false' + + '' + }, + // simple + { + cql: "func1('arg1') = false", + expected: '' + + '' + + 'arg1' + + '' + + 'false' + + '' + }, + // multiple args + { + cql: "func1('arg1', 'arg2') = false", + expected: '' + + '' + + 'arg1' + + 'arg2' + + '' + + 'false' + + '' + }, + // property + { + cql: "func1(PROP) = false", + expected: '' + + '' + + 'PROP' + + '' + + 'false' + + '' + }, + + // nested + { + cql: "jsonArrayContains(\"property1\", 'key', 'value') = false", + expected: '' + + '' + + 'property1' + + 'key' + + 'value' + + '' + + 'false' + + '' + }, { + cql: "jsonPointer(\"property2\", 'key') = 'value')", + expected: '' + + '' + + 'property2' + + 'key' + + '' + + 'value' + + '' + }]; + const REAL_WORLD = [ // real world example { @@ -133,6 +197,41 @@ const REAL_WORLD = [ + '1' + '' + '' + }, { + cql: "jsonArrayContains(\"property1\", 'key', 'value') = false AND jsonPointer(\"property2\", 'key') = 'value')", + expected: + '' + + '' + + '' + + 'property1' + + 'key' + + 'value' + + '' + + 'false' + + '' + + '' + + '' + + 'property2' + + 'key' + + '' + + 'value' + + '' + + '' + + }, + // geometry example + { + cql: "INTERSECTS(GEOMETRY, POLYGON((0 0, 0 10, 10 10, 10 0, 0 0)))", + expected: '' + + 'GEOMETRY' + + '' + + '' + + '' + + '0 0 0 10 10 10 10 0 0 0' + + '' + + '' + + '' + + '' } ]; const testRules = (rules, toOGCFilter) => rules.map(({ cql, expected }) => { @@ -156,6 +255,10 @@ describe('Convert CQL filter to OGC Filter', () => { testRules(CQL_SPECIFIC, toOGCFilter); }); + it('functions', () => { + const toOGCFilter = fromObject(filterBuilder({ gmlVersion: "3.1.1" })); + testRules(FUNCTIONS, toOGCFilter); + }); it('more real world examples', () => { const toOGCFilter = fromObject(filterBuilder({ gmlVersion: "3.1.1" })); testRules(REAL_WORLD, toOGCFilter); diff --git a/web/client/utils/ogc/Filter/fromObject.js b/web/client/utils/ogc/Filter/fromObject.js index ceb03fc888..44be8a2bea 100644 --- a/web/client/utils/ogc/Filter/fromObject.js +++ b/web/client/utils/ogc/Filter/fromObject.js @@ -1,22 +1,24 @@ -import {includes, isNil} from 'lodash'; +import {includes} from 'lodash'; const logical = ["and", "or", "not"]; const cql = ["include"]; const operators = { - '=': "equalTo", - "<>": "notEqualTo", + '=': "equal", + "<>": "notEqual", "><": "between", - '<': "lessThen", - '<=': "lessThenOrEqualTo", - '>': "greaterThen", - '>=': "greaterThenOrEqualTo", + '<': "less", + '<=': "lessOrEqual", + '>': "greater", + '>=': "greaterOrEqual", 'like': "like", 'ilike': "ilike" // TODO: support unary operators like isNull // TODO: support geometry operations }; +const spatial = ["intersects", "within", "bbox", "dwithin", "contains"]; +const geometryTypes = ["Point", "LineString", "Polygon", "MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection"]; /** * Returns a function that convert objects coming from CQL/parser.js --> read function - * into ogc filter + * into XML OGC filter * @param {object} filterBuilder The FilterBuilder instance to use for this conversion. * @example * const cqlFilter = "property = 'value"; @@ -26,16 +28,41 @@ const operators = { * const ogcFilter = toOgcFiler(filterObject); * // ogcFilter --> "propertyvalue" */ -const fromObject = (filterBuilder = {}) => ({type, filters = [], value, property, lowerBoundary, upperBoundary }) => { +const fromObject = (filterBuilder = {}) => ({type, filters = [], args = [], name, value, ...rest }) => { + if (type === "literal") { + return filterBuilder.literal(value); + } + if (type === "property") { + return filterBuilder.propertyName(name); + } if (includes(logical, type)) { return filterBuilder[type]( ...filters.map(fromObject(filterBuilder)) ); } if (includes(cql, type)) { - return ""; + return ""; // TODO: implement in filterBuilder as empty filter + } + if (includes(Object.keys(operators), type)) { + return filterBuilder.operations[operators[type]](...args.map(fromObject(filterBuilder))); + } + if (includes(filterBuilder.operators, type)) { + return filterBuilder.operations[type](...args.map(fromObject(filterBuilder))); + } + if (includes(geometryTypes, type)) { + return filterBuilder.geometry({ + type, + ...rest + }); + } + + if (includes(spatial, type.toLowerCase())) { + return filterBuilder.operations[type.toLowerCase()](name, ...args.map(fromObject(filterBuilder))); + } + if (typeof filterBuilder[type] === "function") { + return filterBuilder[type](name, ...args.map(fromObject(filterBuilder))); } - return filterBuilder.property(property)[operators[type]](isNil(value) ? lowerBoundary : value, upperBoundary); + throw new Error(`Filter type ${type} not supported`); }; export default fromObject; diff --git a/web/client/utils/ogc/Filter/operators.js b/web/client/utils/ogc/Filter/operators.js index 810ab8161d..14e8341f26 100644 --- a/web/client/utils/ogc/Filter/operators.js +++ b/web/client/utils/ogc/Filter/operators.js @@ -36,6 +36,14 @@ const valueReference = (ns, name) => `<${ns}:ValueReference>${name} `<${ns}:Literal>${value}`; const lower = (ns, value) => `<${ns}:LowerBoundary>${value}`; const upper = (ns, value) => `<${ns}:UpperBoundary>${value}`; + +/** + * This function is used to apply multiple operations to the same content. + * @param {string} ns namespace + * @param {function} op operation function + * @param {string|Array} content content + * @returns the operation result + */ const multiop = (ns, op, content) => op(ns, Array.isArray(content) ? content.join("") : content); const logical = { and: (ns, content, ...other) => other && other.length > 0 ? multiop(ns, ogcLogicalOperators.AND, [content, ...other]) : multiop(ns, ogcLogicalOperators.AND, content), @@ -44,6 +52,7 @@ const logical = { nor: (ns, content, ...other) => other && other.length > 0 ? multiop(ns, ogcLogicalOperators.NOR, [content, ...other]) : multiop(ns, ogcLogicalOperators.NOR, content) }; +const ogcFunc = (ns, name, content = []) => `<${ns}:Function name="${name}">${Array.isArray(content) ? content.join("") : content}`; const spatial = { intersects: (ns, ...args) => multiop(ns, ogcSpatialOperators.INTERSECTS, args), @@ -65,6 +74,7 @@ const comparison = { ilike: (ns, ...args) => multiop(ns, ogcComparisonOperators.ilike, args), isNull: (ns, ...args) => multiop(ns, ogcComparisonOperators.isNull, args) }; +const func = (ns, name, ...args) => ogcFunc(ns, name, args); module.exports = { ogcComparisonOperators, @@ -78,5 +88,6 @@ module.exports = { spatial, comparison, lower, - upper + upper, + func }; diff --git a/web/client/utils/ogc/WFS/RequestBuilder.js b/web/client/utils/ogc/WFS/RequestBuilder.js index cfc185e46d..aac43be6a4 100644 --- a/web/client/utils/ogc/WFS/RequestBuilder.js +++ b/web/client/utils/ogc/WFS/RequestBuilder.js @@ -49,7 +49,7 @@ const getStaticAttributesWFS2 = (ver) => 'service="WFS" version="' + ver + '" ' * filter( * and( * property("P1").equals("v1"), - * proprety("the_geom").intersects(geoJSONGeometry) + * property("the_geom").intersects(geoJSONGeometry) * ) * ), * {srsName="EPSG:4326"} // 3rd for query is optional diff --git a/web/client/utils/ogc/WKT/__tests__/toGeoJSON-test.js b/web/client/utils/ogc/WKT/__tests__/toGeoJSON-test.js new file mode 100644 index 0000000000..bdbe0d53a6 --- /dev/null +++ b/web/client/utils/ogc/WKT/__tests__/toGeoJSON-test.js @@ -0,0 +1,82 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import expect from 'expect'; +import toGeoJSON from '../toGeoJSON'; +const WKT_TESTS = [{ + wkt: 'POINT(30 10)', + geojson: { + type: 'Point', + coordinates: [30, 10] + } +}, { + wkt: 'LINESTRING(30 10, 10 30, 40 40)', + geojson: { + type: 'LineString', + coordinates: [[30, 10], [10, 30], [40, 40]] + } +}, { + wkt: 'POLYGON((30 10, 40 40, 20 40, 10 20, 30 10))', + geojson: { + type: 'Polygon', + coordinates: [[[30, 10], [40, 40], [20, 40], [10, 20], [30, 10]]] + } +}, +// MULTIPOINT HAS 2 EQUIVALENT FORMS +{ + wkt: 'MULTIPOINT((10 40), (40 30), (20 20), (30 10))', + geojson: { + type: 'MultiPoint', + coordinates: [[10, 40], [40, 30], [20, 20], [30, 10]] + } +}, +{ + wkt: 'MULTIPOINT(10 40, 40 30, 20 20, 30 10)', + geojson: { + type: 'MultiPoint', + coordinates: [[10, 40], [40, 30], [20, 20], [30, 10]] + } +}, +{ + + wkt: 'MULTILINESTRING ((10 10, 20 20, 10 40), (40 40, 30 30, 40 20, 30 10))', + geojson: { + type: 'MultiLineString', + coordinates: [[[10, 10], [20, 20], [10, 40]], [[40, 40], [30, 30], [40, 20], [30, 10]]] + } +}, +{ + wkt: 'MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)), ((15 5, 40 10, 10 20, 5 10, 15 5)))', + geojson: { + type: 'MultiPolygon', + coordinates: [ + [[[30, 20], [45, 40], [10, 40], [30, 20]]], + [[[15, 5], [40, 10], [10, 20], [5, 10], [15, 5] + ]] + ] + } +}, { + wkt: 'GEOMETRYCOLLECTION(POINT(4 6),LINESTRING(4 6,7 10))', + geojson: { + type: 'GeometryCollection', + geometries: [{ + type: 'Point', + coordinates: [4, 6] + }, { + type: 'LineString', + coordinates: [[4, 6], [7, 10]] + }] + } +}]; +describe('WKT convertion to geoJSON (toGeoJSON)', function() { + WKT_TESTS.forEach((test) => { + it(test.wkt, () => { + expect(toGeoJSON(test.wkt)).toEqual(test.geojson); + }); + }); +}); diff --git a/web/client/utils/ogc/WKT/index.js b/web/client/utils/ogc/WKT/index.js new file mode 100644 index 0000000000..075e85df7f --- /dev/null +++ b/web/client/utils/ogc/WKT/index.js @@ -0,0 +1 @@ +export {default as toGeoJSON} from './toGeoJSON'; diff --git a/web/client/utils/ogc/WKT/toGeoJSON.js b/web/client/utils/ogc/WKT/toGeoJSON.js new file mode 100644 index 0000000000..8095854b4f --- /dev/null +++ b/web/client/utils/ogc/WKT/toGeoJSON.js @@ -0,0 +1,195 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Convert a WKT sub-string of geometries to a geoJSON Point + * @private + * @param {string} coordinates coordinates string + * @returns {object} geoJSON point + */ +const parsePoint = (coordinates) => { + const [x, y] = coordinates.split(' ').map(parseFloat); + return { + type: 'Point', + coordinates: [x, y] + }; +}; + +/** + * Convert a WKT sub-string of geometries to a geoJSON LineString + * @private + * @param {string} coordinates coordinates string + * @returns {object} geoJSON LineString + */ +const parseLineString = (coordinates) => { + const points = coordinates.split(',').map(point => { + const [x, y] = point.trim().split(' ').map(parseFloat); + return [x, y]; + }); + return { + type: 'LineString', + coordinates: points + }; +}; + +/** + * Convert a WKT sub-string of geometries to a geoJSON Polygon + * @private + * @param {string} coordinates coordinates string + * @returns {object} geoJSON Polygon + */ +const parsePolygon = (coordinates) => { + const rings = coordinates.split('),').map(ring => { + const points = ring.replace('(', '').trim().split(',').map(point => { + const [x, y] = point.trim().split(' ').map(parseFloat); + return [x, y]; + }); + return points; + }); + return { + type: 'Polygon', + coordinates: rings + }; +}; + +/** + * Convert a WKT sub-string of geometries to a geoJSON MultiPoint + * @private + * @param {string} coordinates coordinates string + * @returns {object} geoJSON MultiPoint + */ +const parseMultiPoint = (coordinates) => { + const points = coordinates.split(',').map(point => { + const [x, y] = point + /* + MultiPoint can have these forms. Remove the parenthesis to support both, + given that we are parsing one single point. + MULTIPOINT ((10 40), (40 30), (20 20), (30 10)) + MULTIPOINT (10 40, 40 30, 20 20, 30 10) + */ + .replace('(', '').replace(')', '') + .trim().split(' ').map(parseFloat); + return [x, y]; + }); + return { + type: 'MultiPoint', + coordinates: points + }; +}; + +/** + * Convert a WKT sub-string of geometries to a geoJSON MultiLineString + * @private + * @param {string} coordinates coordinates string + * @returns {object} geoJSON MultiLineString + */ +const parseMultiLineString = (coordinates) => { + const lines = coordinates.split('),').map(line => { + const points = line.replace('(', '').trim().split(',').map(point => { + const [x, y] = point.trim().split(' ').map(parseFloat); + return [x, y]; + }); + return points; + }); + return { + type: 'MultiLineString', + coordinates: lines + }; +}; + +/** + * Convert a WKT sub-string of geometries to a geoJSON MultiPolygon + * @private + * @param {string} coordinates coordinates string + * @returns {object} geoJSON MultiPolygon + */ +const parseMultiPolygon = (coordinates) => { + const polygons = coordinates.split(')),').map(polygon => { + const rings = polygon.replace('(', '').trim().split('),').map(ring => { + const points = ring.replace('(', '').trim().split(',').map(point => { + const [x, y] = point.trim().split(' ').map(parseFloat); + return [x, y]; + }); + return points; + }); + return rings; + }); + return { + type: 'MultiPolygon', + coordinates: polygons + }; +}; +let toGeoJSON; + +/** + * Convert a WKT sub-string of geometries to a geoJSON GeometryCollection + * @private + * @param {string} coordinates coordinates string + * @returns {object} geoJSON GeometryCollection + */ +const parseGeometryCollection = (coordinates) => { + const geometries = coordinates.split('),').map(geometry => { + const type = geometry.substring(0, geometry.indexOf('(')).trim().toUpperCase(); + const coords = geometry.substring(geometry.indexOf('(') + 1).trim(); + return toGeoJSON(`${type}(${coords})`); + }); + return { + type: 'GeometryCollection', + geometries: geometries + }; +}; + +toGeoJSON = (rawWkt) => { + // Remove any leading or trailing white spaces from the WKT + let wkt = rawWkt.trim(); + + // Determine the geometry type based on the initial keyword + const type = wkt.substring(0, wkt.indexOf('(')).trim().toUpperCase(); + + // Extract the coordinates from the inner part of the WKT + const coordinates = wkt.substring(wkt.indexOf('(') + 1, wkt.lastIndexOf(')')).trim(); + + // Parse the coordinates based on the geometry type + let result; + switch (type) { + case 'POINT': + result = parsePoint(coordinates); + break; + case 'LINESTRING': + result = parseLineString(coordinates); + break; + case 'POLYGON': + result = parsePolygon(coordinates); + break; + // Add support for additional geometry types here + case 'MULTIPOINT': + result = parseMultiPoint(coordinates); + break; + case 'MULTILINESTRING': + result = parseMultiLineString(coordinates); + break; + case 'MULTIPOLYGON': + result = parseMultiPolygon(coordinates); + break; + case 'GEOMETRYCOLLECTION': + result = parseGeometryCollection(coordinates); + break; + default: + throw new Error(`Not supported geometry: ${type}`); + } + + return result; +}; +/** + * Convert a WKT sub-string of geometries to a geoJSON geometry + * @name toGeoJSON + * @memberof utils.ogc.Filter.WKT + * @param {string} wkt the wkt string + * @return {object} the geoJSON geometry + */ +export default toGeoJSON;