From 85b862da86d03c1db61e7ac06decb0127962ca82 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 19:38:32 +0200 Subject: [PATCH] feat(cql2): align temporal predicates with CQL2 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map CQL2 spec operator names to existing AST types without renaming them, keeping all other parsers and backends unaffected. - Use spec names in TEMPORAL_PREDICATES_MAP: t_starts → TimeBegins, t_startedBy → TimeBegunBy, t_finishes → TimeEnds, t_finishedBy → TimeEndedBy (camelCase keys per JSON schema) - Add TimeIntersects AST node — t_intersects was incorrectly aliased to TimeOverlaps - Add t_disjoint mapping (AST node TimeDisjoint already existed) - Change text syntax from infix (a T_BEFORE b) to function-call T_BEFORE(a, b) per spec BNF - Expand INTERVAL to accept DATE strings and open-ended '..' params --- pygeofilter/ast.py | 8 ++++ pygeofilter/cql2.py | 15 ++++--- pygeofilter/parsers/cql2_text/grammar.lark | 16 ++++--- pygeofilter/parsers/cql2_text/parser.py | 18 ++++++-- tests/parsers/cql2_json/fixtures.json | 2 +- tests/parsers/cql2_json/test_parser.py | 4 +- tests/parsers/cql2_text/test_parser.py | 51 +++++++++++++++++----- 7 files changed, 85 insertions(+), 29 deletions(-) diff --git a/pygeofilter/ast.py b/pygeofilter/ast.py index 5f65b79a..adaef3e9 100644 --- a/pygeofilter/ast.py +++ b/pygeofilter/ast.py @@ -308,6 +308,8 @@ def get_template(self) -> str: # interval T2,then the beginning of T1 is after the end of T2, or the end of # T1 is before the beginning of T2, i.e. the intervals do not overlap in any # way, but their ordering relationship is not known. +# TINTERSECTS: The union of all other temporal relationships; two temporal +# instances intersect if they are not disjoint. # https://github.com/geotools/geotools/blob/main/modules/library/cql/ECQL.md#temporal-predicate # BEFORE_OR_DURING <-----> @@ -329,6 +331,7 @@ class TemporalComparisonOp(Enum): METBY = "METBY" TOVERLAPS = "TOVERLAPS" OVERLAPPEDBY = "OVERLAPPEDBY" + TINTERSECTS = "TINTERSECTS" BEFORE_OR_DURING = "BEFORE OR DURING" DURING_OR_AFTER = "DURING OR AFTER" @@ -420,6 +423,11 @@ class TimeOverlappedBy(TemporalPredicate): @dataclass +@dataclass +class TimeIntersects(TemporalPredicate): + op: ClassVar[TemporalComparisonOp] = TemporalComparisonOp.TINTERSECTS + + class TimeBeforeOrDuring(TemporalPredicate): op: ClassVar[TemporalComparisonOp] = TemporalComparisonOp.BEFORE_OR_DURING diff --git a/pygeofilter/cql2.py b/pygeofilter/cql2.py index 53cf0ecf..8531b1f3 100644 --- a/pygeofilter/cql2.py +++ b/pygeofilter/cql2.py @@ -38,17 +38,18 @@ "t_before": ast.TimeBefore, "t_after": ast.TimeAfter, "t_meets": ast.TimeMeets, - "t_metby": ast.TimeMetBy, + "t_metBy": ast.TimeMetBy, "t_overlaps": ast.TimeOverlaps, - "t_overlappedby": ast.TimeOverlappedBy, - "t_begins": ast.TimeBegins, - "t_begunby": ast.TimeBegunBy, + "t_overlappedBy": ast.TimeOverlappedBy, + "t_starts": ast.TimeBegins, + "t_startedBy": ast.TimeBegunBy, "t_during": ast.TimeDuring, "t_contains": ast.TimeContains, - "t_ends": ast.TimeEnds, - "t_endedby": ast.TimeEndedBy, + "t_finishes": ast.TimeEnds, + "t_finishedBy": ast.TimeEndedBy, "t_equals": ast.TimeEquals, - "t_intersects": ast.TimeOverlaps, + "t_intersects": ast.TimeIntersects, + "t_disjoint": ast.TimeDisjoint, } diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/parsers/cql2_text/grammar.lark index 703cb438..ab77ec81 100644 --- a/pygeofilter/parsers/cql2_text/grammar.lark +++ b/pygeofilter/parsers/cql2_text/grammar.lark @@ -63,7 +63,7 @@ | temporal_predicate -?temporal_predicate: expression _binary_temporal_predicate_func expression -> binary_temporal_predicate +?temporal_predicate: _binary_temporal_predicate_func "(" expression "," expression ")" -> binary_temporal_predicate !_binary_temporal_predicate_func: "T_BEFORE"i | "T_AFTER"i @@ -71,14 +71,15 @@ | "T_METBY"i | "T_OVERLAPS"i | "T_OVERLAPPEDBY"i - | "T_BEGINS"i - | "T_BEGUNBY"i + | "T_STARTS"i + | "T_STARTEDBY"i | "T_DURING"i | "T_CONTAINS"i - | "T_ENDS"i - | "T_ENDEDBY"i + | "T_FINISHES"i + | "T_FINISHEDBY"i | "T_EQUALS"i | "T_INTERSECTS"i + | "T_DISJOINT"i ?spatial_predicate: _binary_spatial_predicate_func "(" expression "," expression ")" -> binary_spatial_predicate @@ -140,7 +141,10 @@ SINGLE_QUOTED: "'" /.*?/ "'" DATE: /[0-9]{4}-?[0-1][0-9]-?[0-3][0-9]/ 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](\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})?/ ?timestamp: "TIMESTAMP" "(" "'" DATETIME "'" ")" -?interval: "INTERVAL" "(" "'" DATETIME "'" "," "'" DATETIME "'" ")" +?interval: "INTERVAL" "(" instant_parameter "," instant_parameter ")" +instant_parameter: "'" DATETIME "'" -> instant_datetime + | "'" DATE "'" -> instant_date + | "'" ".." "'" -> instant_open ?date: "DATE" "(" "'" DATE "'" ")" attribute: /[a-zA-Z][a-zA-Z_:0-9.]+/ diff --git a/pygeofilter/parsers/cql2_text/parser.py b/pygeofilter/parsers/cql2_text/parser.py index cced2849..9736d9db 100644 --- a/pygeofilter/parsers/cql2_text/parser.py +++ b/pygeofilter/parsers/cql2_text/parser.py @@ -128,9 +128,12 @@ def binary_spatial_predicate(self, op, lhs, rhs): op = op.lower() return SPATIAL_PREDICATES_MAP[op](lhs, rhs) - def binary_temporal_predicate(self, lhs, op, rhs): - op = op.lower() - return TEMPORAL_PREDICATES_MAP[op](lhs, rhs) + def binary_temporal_predicate(self, op, lhs, rhs): + op_lower = op.lower() + for key, cls in TEMPORAL_PREDICATES_MAP.items(): + if key.lower() == op_lower: + return cls(lhs, rhs) + raise ValueError(f"Unknown temporal predicate: {op}") def relate_spatial_predicate(self, lhs, rhs, pattern): return ast.Relate(lhs, rhs, pattern) @@ -193,6 +196,15 @@ def geometry(self, value): def bbox(self, x1, y1, x2, y2): return values.Envelope(x1, x2, y1, y2) + def instant_datetime(self, value): + return value + + def instant_date(self, value): + return value + + def instant_open(self): + return None + def interval(self, start, end): return values.Interval(start, end) diff --git a/tests/parsers/cql2_json/fixtures.json b/tests/parsers/cql2_json/fixtures.json index 4e49be00..cbd64e35 100644 --- a/tests/parsers/cql2_json/fixtures.json +++ b/tests/parsers/cql2_json/fixtures.json @@ -20,7 +20,7 @@ "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"=\", \"args\": [{\"property\": \"prop1\"}, {\"property\": \"prop2\"}]}}" }, "Example 6": { - "text": "filter=datetime T_INTERSECTS INTERVAL('2020-11-11T00:00:00Z', '2020-11-12T00:00:00Z')", + "text": "filter=T_INTERSECTS(datetime, INTERVAL('2020-11-11T00:00:00Z', '2020-11-12T00:00:00Z'))", "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"t_intersects\", \"args\": [{\"property\": \"datetime\"}, {\"interval\": [\"2020-11-11T00:00:00Z\", \"2020-11-12T00:00:00Z\"]}]}}" }, "Example 7": { diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/parsers/cql2_json/test_parser.py index 01f2474a..925146ca 100644 --- a/tests/parsers/cql2_json/test_parser.py +++ b/tests/parsers/cql2_json/test_parser.py @@ -240,7 +240,7 @@ def test_meets_dt_dr(): def test_attribute_metby_dr_dt(): result = parse( { - "op": "t_metby", + "op": "t_metBy", "args": [ {"property": "attr"}, {"interval": ["PT4S", "2000-01-01T00:00:03Z"]}, @@ -278,7 +278,7 @@ def test_attribute_toverlaps_open_dt(): def test_attribute_overlappedby_dt_open(): result = parse( { - "op": "t_overlappedby", + "op": "t_overlappedBy", "args": [ {"property": "attr"}, {"interval": ["2000-01-01T00:00:03Z", ".."]}, diff --git a/tests/parsers/cql2_text/test_parser.py b/tests/parsers/cql2_text/test_parser.py index 7d4c38ea..aad441e1 100644 --- a/tests/parsers/cql2_text/test_parser.py +++ b/tests/parsers/cql2_text/test_parser.py @@ -207,8 +207,7 @@ def test_attribute_is_null(): def test_attribute_before(): - # Using TIMESTAMP function to properly wrap the timestamp - result = parse("attr T_BEFORE TIMESTAMP('2000-01-01T00:00:01Z')") + result = parse("T_BEFORE(attr, TIMESTAMP('2000-01-01T00:00:01Z'))") assert result == ast.TimeBefore( ast.Attribute("attr"), datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), @@ -222,11 +221,10 @@ def test_attribute_lt_date(): ) def test_attribute_t_intersects(): - # Using INTERVAL function with properly quoted timestamps result = parse( - "attr T_INTERSECTS INTERVAL('2000-01-01T00:00:00Z', '2000-01-01T00:00:01Z')" + "T_INTERSECTS(attr, INTERVAL('2000-01-01T00:00:00Z', '2000-01-01T00:00:01Z'))" ) - assert result == ast.TimeOverlaps( + assert result == ast.TimeIntersects( ast.Attribute("attr"), values.Interval( datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), @@ -235,15 +233,48 @@ def test_attribute_t_intersects(): ) -def test_attribute_tintersects_dt_dr(): +def test_t_disjoint(): result = parse( - "attr T_INTERSECTS INTERVAL('2000-01-01T00:00:03Z', '2000-01-01T00:00:04Z')" + "T_DISJOINT(attr, TIMESTAMP('2000-01-01T00:00:00Z'))" ) - assert result == ast.TimeOverlaps( + assert result == ast.TimeDisjoint( + ast.Attribute("attr"), + datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + + +def test_t_starts(): + result = parse( + "T_STARTS(attr, INTERVAL('2000-01-01T00:00:00Z', '2000-01-01T00:00:01Z'))" + ) + assert result == ast.TimeBegins( + ast.Attribute("attr"), + values.Interval( + datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + ), + ) + + +def test_interval_with_date(): + result = parse( + "T_DURING(attr, INTERVAL('2000-01-01', '2001-01-01'))" + ) + assert result == ast.TimeDuring( + ast.Attribute("attr"), + values.Interval(date(2000, 1, 1), date(2001, 1, 1)), + ) + + +def test_interval_open_ended(): + result = parse( + "T_DURING(attr, INTERVAL('..', '2001-01-01T00:00:00Z'))" + ) + assert result == ast.TimeDuring( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 4, tzinfo=StaticTzInfo("Z", timedelta(0))), + None, + datetime(2001, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), ), )