From ea618099e1523e3c5721f70c0ad4b2cc7f872c45 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Mon, 8 Jun 2026 15:39:06 +0200 Subject: [PATCH] fix(cql2_json): CQL2 advanced comparison conformance fixes - BETWEEN: switch to flat 3-arg format [lhs, low, high] in both parser and encoder, matching OGC CQL2 spec (previously used nested [lhs, [low, high]]) - NOT flags: wrap like/between/in_ results in {"op": "not", "args": [...]} when node.not_ is True, matching behavior of isNull handler - CASEI/ACCENTI: parse 'casei'/'lower' op to ast.Function('lower', ...), 'accenti' op to ast.Function('accenti', ...); encode lower->casei and accenti->accenti using {"op": ...} format instead of non-standard {"lower": ...}/{"upper": ...} objects - Fix function fallback encoder to use {"op": name, "args": [...]} format - Update test fixtures and add conformance regression tests for round-trips --- pygeofilter/backends/cql2_json/evaluate.py | 25 +++++--- pygeofilter/parsers/cql2_json/parser.py | 15 ++++- tests/parsers/cql2_json/fixtures.json | 4 +- tests/parsers/cql2_json/test_parser.py | 68 +++++++++++++++++++++- 4 files changed, 96 insertions(+), 16 deletions(-) diff --git a/pygeofilter/backends/cql2_json/evaluate.py b/pygeofilter/backends/cql2_json/evaluate.py index 20b0751a..f5da129f 100644 --- a/pygeofilter/backends/cql2_json/evaluate.py +++ b/pygeofilter/backends/cql2_json/evaluate.py @@ -67,11 +67,17 @@ def comparison(self, node, *args): @handle(ast.Between) def between(self, node, lhs, low, high): - return {"op": "between", "args": [lhs, [low, high]]} + ret = {"op": "between", "args": [lhs, low, high]} + if node.not_: + ret = {"op": "not", "args": [ret]} + return ret @handle(ast.Like) def like(self, node, *subargs): - return {"op": "like", "args": [subargs[0], node.pattern]} + ret = {"op": "like", "args": [subargs[0], node.pattern]} + if node.not_: + ret = {"op": "not", "args": [ret]} + return ret @handle(ast.IsNull) def isnull(self, node, arg): @@ -84,16 +90,17 @@ def isnull(self, node, arg): def function(self, node, *args): name = node.name.lower() if name == "lower": - ret = {"lower": args[0]} - elif name == "upper": - ret = {"upper": args[0]} - else: - ret = {"function": name, "args": [*args]} - return ret + return {"op": "casei", "args": [args[0]]} + elif name == "accenti": + return {"op": "accenti", "args": [args[0]]} + return {"op": node.name, "args": [*args]} @handle(ast.In) def in_(self, node, lhs, *options): - return {"op": "in", "args": [lhs, options]} + ret = {"op": "in", "args": [lhs, list(options)]} + if node.not_: + ret = {"op": "not", "args": [ret]} + return ret @handle(ast.Attribute) def attribute(self, node: ast.Attribute): diff --git a/pygeofilter/parsers/cql2_json/parser.py b/pygeofilter/parsers/cql2_json/parser.py index e3c2e9a7..d5034a55 100644 --- a/pygeofilter/parsers/cql2_json/parser.py +++ b/pygeofilter/parsers/cql2_json/parser.py @@ -135,8 +135,8 @@ def walk_cql_json(node: JsonType): # noqa: C901 elif op == "between": return ast.Between( cast(ast.Node, walk_cql_json(args[0])), - cast(ast.ScalarAstType, walk_cql_json(args[1][0])), - cast(ast.ScalarAstType, walk_cql_json(args[1][1])), + cast(ast.ScalarAstType, walk_cql_json(args[1])), + cast(ast.ScalarAstType, walk_cql_json(args[2])), not_=False, ) @@ -158,13 +158,22 @@ def walk_cql_json(node: JsonType): # noqa: C901 not_=False, ) - elif op == "casei": + elif op in ("casei", "lower"): return ast.Function("lower", [cast(ast.Node, walk_cql_json(args[0]))]) + elif op == "accenti": + return ast.Function("accenti", [cast(ast.Node, walk_cql_json(args[0]))]) + elif op in BINARY_OP_PREDICATES_MAP: args = [cast(ast.Node, walk_cql_json(arg)) for arg in args] return BINARY_OP_PREDICATES_MAP[op](*args) + else: + return ast.Function( + op, + [walk_cql_json(arg) for arg in args], + ) + raise ValueError(f"Unable to parse expression node {node!r}") diff --git a/tests/parsers/cql2_json/fixtures.json b/tests/parsers/cql2_json/fixtures.json index 4e49be00..c32ca68b 100644 --- a/tests/parsers/cql2_json/fixtures.json +++ b/tests/parsers/cql2_json/fixtures.json @@ -37,7 +37,7 @@ }, "Example 10": { "text": "filter=eo:cloud_cover BETWEEN 0 AND 50", - "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"between\", \"args\": [{\"property\": \"eo:cloud_cover\"}, [0, 50]]}}" + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"between\", \"args\": [{\"property\": \"eo:cloud_cover\"}, 0, 50]}}" }, "Example 11": { "text": "filter=mission LIKE 'sentinel%'", @@ -47,4 +47,4 @@ "text": "filter=CASEI(provider) = 'coolsat'", "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"=\", \"args\": [{\"lower\": {\"property\": \"provider\"}}, \"coolsat\"]}}" } -} +} \ No newline at end of file diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/parsers/cql2_json/test_parser.py index 01f2474a..068c30a9 100644 --- a/tests/parsers/cql2_json/test_parser.py +++ b/tests/parsers/cql2_json/test_parser.py @@ -32,6 +32,7 @@ from pygeoif import geometry from pygeofilter import ast, values +from pygeofilter.backends.cql2_json.evaluate import to_cql2 from pygeofilter.parsers.cql2_json import parse @@ -82,7 +83,7 @@ def test_attribute_gte_literal(): def test_attribute_between(): - result = parse({"op": "between", "args": [{"property": "attr"}, [2, 5]]}) + result = parse({"op": "between", "args": [{"property": "attr"}, 2, 5]}) assert result == ast.Between( ast.Attribute("attr"), 2, @@ -92,7 +93,7 @@ def test_attribute_between(): def test_attribute_between_negative_positive(): - result = parse({"op": "between", "args": [{"property": "attr"}, [-1, 1]]}) + result = parse({"op": "between", "args": [{"property": "attr"}, -1, 1]}) assert result == ast.Between( ast.Attribute("attr"), -1, @@ -717,3 +718,66 @@ def test_function_attr_string_arg(): ], ), ) + + +# --- CQL2 Advanced Comparison conformance tests --- + +def test_between_flat_args_parse(): + result = parse({"op": "between", "args": [{"property": "attr"}, 2, 5]}) + assert result == ast.Between(ast.Attribute("attr"), 2, 5, False) + + +def test_between_encode_flat_args(): + + node = ast.Between(ast.Attribute("attr"), 2, 5, False) + decoded = json.loads(to_cql2(node)) + assert decoded == {"op": "between", "args": [{"property": "attr"}, 2, 5]} + + +def test_not_between_encodes_with_not_wrapper(): + + node = ast.Between(ast.Attribute("attr"), 2, 5, not_=True) + decoded = json.loads(to_cql2(node)) + assert decoded["op"] == "not" + assert decoded["args"][0]["op"] == "between" + + +def test_not_like_encodes_with_not_wrapper(): + + node = ast.Like(ast.Attribute("attr"), "val%", nocase=False, not_=True, + wildcard="%", singlechar=".", escapechar="\\") + decoded = json.loads(to_cql2(node)) + assert decoded["op"] == "not" + assert decoded["args"][0]["op"] == "like" + + +def test_not_in_encodes_with_not_wrapper(): + + node = ast.In(ast.Attribute("attr"), [1, 2, 3], not_=True) + decoded = json.loads(to_cql2(node)) + assert decoded["op"] == "not" + assert decoded["args"][0]["op"] == "in" + + +def test_casei_json_parse(): + result = parse({"op": "casei", "args": [{"property": "name"}]}) + assert result == ast.Function("lower", [ast.Attribute("name")]) + + +def test_casei_json_encode(): + + node = ast.Function("lower", [ast.Attribute("name")]) + decoded = json.loads(to_cql2(node)) + assert decoded == {"op": "casei", "args": [{"property": "name"}]} + + +def test_accenti_json_parse(): + result = parse({"op": "accenti", "args": [{"property": "name"}]}) + assert result == ast.Function("accenti", [ast.Attribute("name")]) + + +def test_accenti_json_encode(): + + node = ast.Function("accenti", [ast.Attribute("name")]) + decoded = json.loads(to_cql2(node)) + assert decoded == {"op": "accenti", "args": [{"property": "name"}]}