From f1af397f4a7d9ac6366cd613826cb4dd5398170b Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Mon, 8 Jun 2026 15:37:36 +0200 Subject: [PATCH] fix(cql2): CQL2 Core conformance fixes - Allow single-character property names in CQL2 Text grammar by changing the attribute regex from /[a-zA-Z][a-zA-Z_:0-9.]+/ to /[a-zA-Z][a-zA-Z_:0-9.]*/ (+ to *). The spec requires identifiers to be at least one character, not two. - Fix CQL2 JSON encoder date/datetime serialization. The previous dead-code @handle(datetime) with wrong node annotation was overshadowed by the literal handler. Reorder handlers so date and datetime handlers come AFTER the generic literal handler so the metaclass registry correctly maps them. Date values are now serialized as {"date": "YYYY-MM-DD"} and datetime values as {"timestamp": "...Z"} with Z suffix instead of +00:00. - Remove debug print() calls from wkt.py (geometry_with_srid and multipoint_2 handlers). - Add regression tests: single-char attribute parsing, date/datetime round-trip through JSON parser and encoder. --- pygeofilter/backends/cql2_json/evaluate.py | 16 ++++++++++++---- pygeofilter/parsers/cql2_text/grammar.lark | 2 +- pygeofilter/parsers/wkt.py | 2 -- tests/parsers/cql2_json/test_parser.py | 16 ++++++++++++++++ tests/parsers/cql2_text/test_parser.py | 1 + 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/pygeofilter/backends/cql2_json/evaluate.py b/pygeofilter/backends/cql2_json/evaluate.py index 20b0751a..9b484743 100644 --- a/pygeofilter/backends/cql2_json/evaluate.py +++ b/pygeofilter/backends/cql2_json/evaluate.py @@ -103,14 +103,22 @@ def attribute(self, node: ast.Attribute): def interval(self, node: values.Interval, start, end): return {"interval": [start, end]} - @handle(datetime) - def datetime(self, node: ast.Attribute): - return {"timestamp": node.name} - @handle(*values.LITERALS) def literal(self, node): return node + @handle(date) + def date_(self, node: date): + return {"date": node.isoformat()} + + @handle(datetime) + def datetime_(self, node: datetime): + if node.microsecond: + ts = node.strftime("%Y-%m-%dT%H:%M:%S.") + f"{node.microsecond:06d}Z" + else: + ts = node.strftime("%Y-%m-%dT%H:%M:%SZ") + return {"timestamp": ts} + @handle(values.Geometry) def geometry(self, node: values.Geometry): return node.__geo_interface__ diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/parsers/cql2_text/grammar.lark index 703cb438..47429a9e 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/pygeofilter/parsers/wkt.py b/pygeofilter/parsers/wkt.py index bf9c5304..da08b455 100644 --- a/pygeofilter/parsers/wkt.py +++ b/pygeofilter/parsers/wkt.py @@ -31,7 +31,6 @@ @v_args(meta=False, inline=True) class WKTTransformer(Transformer): def wkt__geometry_with_srid(self, srid, geometry): - print(srid, geometry) geometry["crs"] = { "type": "name", "properties": {"name": f"urn:ogc:def:crs:EPSG::{srid}"}, @@ -66,7 +65,6 @@ def wkt__multipoint(self, coordinates): } def wkt__multipoint_2(self, *coordinates): - print(coordinates) return { "type": "MultiPoint", "coordinates": coordinates, diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/parsers/cql2_json/test_parser.py index 01f2474a..12ab953c 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 @@ -717,3 +718,18 @@ def test_function_attr_string_arg(): ], ), ) + + +def test_encode_date(): + node = ast.Equal(ast.Attribute("attr"), date(2000, 1, 1)) + result = json.loads(to_cql2(node)) + assert result["args"][1] == {"date": "2000-01-01"} + + +def test_encode_timestamp(): + node = ast.Equal( + ast.Attribute("attr"), + datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + result = json.loads(to_cql2(node)) + assert result["args"][1] == {"timestamp": "2000-01-01T00:00:00Z"} diff --git a/tests/parsers/cql2_text/test_parser.py b/tests/parsers/cql2_text/test_parser.py index 7d4c38ea..250992e6 100644 --- a/tests/parsers/cql2_text/test_parser.py +++ b/tests/parsers/cql2_text/test_parser.py @@ -477,3 +477,4 @@ def test_not_eq(): assert result == ast.Not( ast.Equal(ast.Attribute("attr"), 2) ) +