From fd34ea2356958d556980d7aa6950318a0befe5fd Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Sat, 3 Oct 2026 10:11:24 +0800 Subject: [PATCH 1/3] fix(cql2_text): allow single-letter property names in attribute token The unquoted attribute regex /[a-zA-Z][a-zA-Z_:0-9.]+/ required at least two characters, so expressions like 'a = 1' or 'S_INTERSECTS(g, BBOX(...))' failed to parse. Change the trailing .+ to .* so a single-letter identifier is accepted. Fixes #165 --- pygeofilter/parsers/cql2_text/grammar.lark | 2 +- tests/parsers/cql2_text/test_parser.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/parsers/cql2_text/grammar.lark index 703cb43..47429a9 100644 --- a/pygeofilter/parsers/cql2_text/grammar.lark +++ b/pygeofilter/parsers/cql2_text/grammar.lark @@ -143,7 +143,7 @@ DATETIME: /[0-9]{4}-?[0-1][0-9]-?[0-3][0-9][T ][0-2][0-9]:?[0-5][0-9]:?[0-5][0-9 ?interval: "INTERVAL" "(" "'" DATETIME "'" "," "'" DATETIME "'" ")" ?date: "DATE" "(" "'" DATE "'" ")" -attribute: /[a-zA-Z][a-zA-Z_:0-9.]+/ +attribute: /[a-zA-Z][a-zA-Z_:0-9.]*/ | DOUBLE_QUOTED diff --git a/tests/parsers/cql2_text/test_parser.py b/tests/parsers/cql2_text/test_parser.py index 5bceec7..b85b343 100644 --- a/tests/parsers/cql2_text/test_parser.py +++ b/tests/parsers/cql2_text/test_parser.py @@ -489,3 +489,21 @@ def test_not_lt(): def test_not_eq(): result = parse("NOT(attr = 2)") assert result == ast.Not(ast.Equal(ast.Attribute("attr"), 2)) + + +def test_single_letter_attribute(): + # https://github.com/geopython/pygeofilter/issues/165 + # single-letter property names must parse + result = parse("a = 1") + assert result == ast.Equal(ast.Attribute("a"), 1) + + result = parse("x > 5") + assert result == ast.GreaterThan(ast.Attribute("x"), 5) + + +def test_single_letter_attribute_in_function(): + # https://github.com/geopython/pygeofilter/issues/165 + result = parse("S_INTERSECTS(g, BBOX(-180, -90, 180, 90))") + assert result == ast.GeometryIntersects( + ast.Attribute("g"), values.Envelope(-180, 180, -90, 90) + ) From 59ea0ec07f55c6efcc019bd95791c403d4525e48 Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Sat, 3 Oct 2026 10:28:46 +0800 Subject: [PATCH 2/3] fix(cql2_json): correct Envelope axis order for bbox literals A GeoJSON bbox literal is [minx, miny, maxx, maxy], but parsers/cql2_json/parser.py unpacked it directly into values.Envelope(*bbox) whose field order is (x1, x2, y1, y2) == (minx, maxx, miny, maxy). The two conventions were conflated, so longitudes and latitudes were silently swapped. Reindex explicitly: Envelope(b[0], b[2], b[1], b[3]). Fixes #163 --- pygeofilter/parsers/cql2_json/parser.py | 5 ++++- tests/parsers/cql2_json/test_parser.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pygeofilter/parsers/cql2_json/parser.py b/pygeofilter/parsers/cql2_json/parser.py index 5686947..22d7993 100644 --- a/pygeofilter/parsers/cql2_json/parser.py +++ b/pygeofilter/parsers/cql2_json/parser.py @@ -76,7 +76,10 @@ def walk_cql_json(node: JsonType): # noqa: C901 return values.Geometry(node) elif "bbox" in node: - return values.Envelope(*node["bbox"]) + # GeoJSON bbox order is [minx, miny, maxx, maxy] while Envelope is + # (x1, x2, y1, y2) == (minx, maxx, miny, maxy). + b = node["bbox"] + return values.Envelope(b[0], b[2], b[1], b[3]) elif "date" in node: return parse_date(node["date"]) diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/parsers/cql2_json/test_parser.py index dd55fc9..8c0f595 100644 --- a/tests/parsers/cql2_json/test_parser.py +++ b/tests/parsers/cql2_json/test_parser.py @@ -745,3 +745,18 @@ def test_function_attr_string_arg(): ], ), ) + + +def test_bbox_envelope_axis_order(): + # https://github.com/geopython/pygeofilter/issues/163 + # GeoJSON bbox is [minx, miny, maxx, maxy]; Envelope is + # (x1, x2, y1, y2) == (minx, maxx, miny, maxy). The two orderings must + # not be conflated, otherwise longitudes and latitudes are scrambled. + result = parse({"bbox": [-74.1, 40.5, -73.9, 41.0]}) + assert result == values.Envelope(-74.1, -73.9, 40.5, 41.0) + + +def test_bbox_envelope_global(): + # The global bbox used by cql2-text and the SQLAlchemy backend. + result = parse({"bbox": [-180.0, -90.0, 180.0, 90.0]}) + assert result == values.Envelope(-180.0, 180.0, -90.0, 90.0) From e404eae3ed77830c95d562ebecaacbc9bddcc81b Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Sat, 3 Oct 2026 10:38:58 +0800 Subject: [PATCH 3/3] fix(cql2_json): accept BETWEEN with flat args per final spec OGC 21-065r2 defines BETWEEN with a flat 3-element args array [operand, low, high]. The parser only accepted the draft nested shape [operand, [low, high]], so a spec-conformant client raised TypeError. Accept both: nested when args[1] is a list, flat when it is not. Fixes #162 --- pygeofilter/parsers/cql2_json/parser.py | 13 +++++++++++-- tests/parsers/cql2_json/test_parser.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pygeofilter/parsers/cql2_json/parser.py b/pygeofilter/parsers/cql2_json/parser.py index 22d7993..9b77f62 100644 --- a/pygeofilter/parsers/cql2_json/parser.py +++ b/pygeofilter/parsers/cql2_json/parser.py @@ -140,10 +140,19 @@ def walk_cql_json(node: JsonType): # noqa: C901 return ast.IsNull(cast(ast.Node, walk_cql_json(args)), not_=False) elif op == "between": + # OGC 21-065r2 defines BETWEEN with a flat 3-element args array + # [operand, low, high]. The earlier draft nested the bounds as + # [operand, [low, high]]; accept both for compatibility. + if isinstance(args[1], list): + low = cast(ast.ScalarAstType, walk_cql_json(args[1][0])) + high = cast(ast.ScalarAstType, walk_cql_json(args[1][1])) + else: + low = cast(ast.ScalarAstType, walk_cql_json(args[1])) + high = cast(ast.ScalarAstType, walk_cql_json(args[2])) 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])), + low, + high, not_=False, ) diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/parsers/cql2_json/test_parser.py index 8c0f595..7b3910e 100644 --- a/tests/parsers/cql2_json/test_parser.py +++ b/tests/parsers/cql2_json/test_parser.py @@ -760,3 +760,16 @@ def test_bbox_envelope_global(): # The global bbox used by cql2-text and the SQLAlchemy backend. result = parse({"bbox": [-180.0, -90.0, 180.0, 90.0]}) assert result == values.Envelope(-180.0, 180.0, -90.0, 90.0) + + +def test_between_flat_args_final_spec(): + # https://github.com/geopython/pygeofilter/issues/162 + # OGC 21-065r2 defines BETWEEN with a flat 3-element args array. + result = parse({"op": "between", "args": [{"property": "height"}, 2, 5]}) + assert result == ast.Between(ast.Attribute("height"), 2, 5, not_=False) + + +def test_between_nested_args_draft_compat(): + # The earlier draft nested the bounds as [operand, [low, high]]. + result = parse({"op": "between", "args": [{"property": "height"}, [2, 5]]}) + assert result == ast.Between(ast.Attribute("height"), 2, 5, not_=False)