From e4c1065e4412ccc478ffbf2baa127c88f8264b75 Mon Sep 17 00:00:00 2001 From: Mike Mahoney Date: Fri, 29 May 2026 12:45:10 -0400 Subject: [PATCH 1/2] Add support for prefixed temporal predicates --- pygeofilter/backends/sqlalchemy/filters.py | 10 +- pygeofilter/cql2.py | 1 + pygeofilter/parsers/cql2_text/grammar.lark | 2 + pygeofilter/parsers/cql2_text/parser.py | 4 + tests/backends/sqlalchemy/test_evaluate.py | 8 ++ tests/parsers/cql2_text/test_parser.py | 104 +++++++++++++++++++++ 6 files changed, 127 insertions(+), 2 deletions(-) diff --git a/pygeofilter/backends/sqlalchemy/filters.py b/pygeofilter/backends/sqlalchemy/filters.py index c99c68df..9b519040 100644 --- a/pygeofilter/backends/sqlalchemy/filters.py +++ b/pygeofilter/backends/sqlalchemy/filters.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from functools import reduce from inspect import signature from typing import Callable, Dict, Optional @@ -196,6 +196,12 @@ def temporal(lhs, time_or_period, op): low = time_or_period elif op == "TEQUALS": equal = time_or_period + elif op == "DISJOINT": + if isinstance(time_or_period, datetime): + low = time_or_period + high = time_or_period + else: + high, low = time_or_period else: low, high = time_or_period @@ -205,7 +211,7 @@ def temporal(lhs, time_or_period, op): high = low + high if low is not None or high is not None: if low is not None and high is not None: - return between(lhs, low, high) + return between(lhs, low, high, negate = (op == "DISJOINT")) elif low is not None: return runop(lhs, low, ">=") else: diff --git a/pygeofilter/cql2.py b/pygeofilter/cql2.py index 53cf0ecf..3039f182 100644 --- a/pygeofilter/cql2.py +++ b/pygeofilter/cql2.py @@ -49,6 +49,7 @@ "t_endedby": ast.TimeEndedBy, "t_equals": ast.TimeEquals, "t_intersects": ast.TimeOverlaps, + "t_disjoint": ast.TimeDisjoint } diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/parsers/cql2_text/grammar.lark index 1f964636..208832c3 100644 --- a/pygeofilter/parsers/cql2_text/grammar.lark +++ b/pygeofilter/parsers/cql2_text/grammar.lark @@ -64,6 +64,7 @@ ?temporal_predicate: expression _binary_temporal_predicate_func expression -> binary_temporal_predicate + | _binary_temporal_predicate_func "(" expression "," expression ")" -> prefixed_temporal_predicate !_binary_temporal_predicate_func: "T_BEFORE"i | "T_AFTER"i @@ -79,6 +80,7 @@ | "T_ENDEDBY"i | "T_EQUALS"i | "T_INTERSECTS"i + | "T_DISJOINT"i ?spatial_predicate: _binary_spatial_predicate_func "(" expression "," expression ")" -> binary_spatial_predicate diff --git a/pygeofilter/parsers/cql2_text/parser.py b/pygeofilter/parsers/cql2_text/parser.py index ff1cd348..e2e1e7dc 100644 --- a/pygeofilter/parsers/cql2_text/parser.py +++ b/pygeofilter/parsers/cql2_text/parser.py @@ -131,6 +131,10 @@ def binary_spatial_predicate(self, op, lhs, rhs): def binary_temporal_predicate(self, lhs, op, rhs): op = op.lower() return TEMPORAL_PREDICATES_MAP[op](lhs, rhs) + + def prefixed_temporal_predicate(self, op, lhs, rhs): + op = op.lower() + return TEMPORAL_PREDICATES_MAP[op](lhs, rhs) def relate_spatial_predicate(self, lhs, rhs, pattern): return ast.Relate(lhs, rhs, pattern) diff --git a/tests/backends/sqlalchemy/test_evaluate.py b/tests/backends/sqlalchemy/test_evaluate.py index 17801489..8bb196d3 100644 --- a/tests/backends/sqlalchemy/test_evaluate.py +++ b/tests/backends/sqlalchemy/test_evaluate.py @@ -302,6 +302,14 @@ def test_casei(db_session): # temporal predicates +def test_tbefore(db_session): + evaluate(db_session, "T_BEFORE(datetimeAttribute, TIMESTAMP('2000-01-01T00:00:01Z'))", ("A",), None, parse_cql_text) + +def test_tdisjoint(db_session): + evaluate(db_session, "T_DISJOINT(datetimeAttribute, TIMESTAMP('2000-01-01T00:00:10Z'))", ("A",), None, parse_cql_text) + +def test_tintersects(db_session): + evaluate(db_session, "T_INTERSECTS(datetimeAttribute, INTERVAL('2000-01-01T00:00:09Z', '2000-01-01T00:00:11Z'))", ("B",), None, parse_cql_text) def test_before(db_session): evaluate(db_session, "datetimeAttribute BEFORE 2000-01-01T00:00:01Z", ("A",)) diff --git a/tests/parsers/cql2_text/test_parser.py b/tests/parsers/cql2_text/test_parser.py index 813832f4..9dec65d2 100644 --- a/tests/parsers/cql2_text/test_parser.py +++ b/tests/parsers/cql2_text/test_parser.py @@ -228,6 +228,110 @@ def test_attribute_tintersects_dt_dr(): ), ) +def test_prefix_tafter_timestamp(): + result = parse( + "T_AFTER(attr, TIMESTAMP('2022-04-24T07:59:57Z'))" + ) + assert result == ast.TimeAfter( + ast.Attribute("attr"), + datetime(2022, 4, 24, 7, 59, 57, tzinfo=StaticTzInfo("Z", timedelta(0))) + ) + +def test_prefix_tafter_interval(): + result = parse( + "T_AFTER(attr, INTERVAL('2021-01-01T00:00:00Z','2021-12-31T23:59:59Z'))" + ) + assert result == ast.TimeAfter( + ast.Attribute("attr"), + values.Interval( + datetime(2021, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime(2021, 12, 31, 23, 59, 59, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + ) + +def test_prefix_tbefore_timestamp(): + result = parse( + "T_BEFORE(attr, TIMESTAMP('2022-04-24T07:59:57Z'))" + ) + assert result == ast.TimeBefore( + ast.Attribute("attr"), + datetime(2022, 4, 24, 7, 59, 57, tzinfo=StaticTzInfo("Z", timedelta(0))) + ) + +def test_prefix_tbefore_interval(): + result = parse( + "T_BEFORE(attr, INTERVAL('2021-01-01T00:00:00Z','2021-12-31T23:59:59Z'))" + ) + assert result == ast.TimeBefore( + ast.Attribute("attr"), + values.Interval( + datetime(2021, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime(2021, 12, 31, 23, 59, 59, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + ) + +def test_prefix_tdisjoint_timestamp(): + result = parse( + "T_DISJOINT(attr, TIMESTAMP('2022-04-24T07:59:57Z'))" + ) + assert result == ast.TimeDisjoint( + ast.Attribute("attr"), + datetime(2022, 4, 24, 7, 59, 57, tzinfo=StaticTzInfo("Z", timedelta(0))) + ) + +def test_prefix_tdisjoint_interval(): + result = parse( + "T_DISJOINT(attr, INTERVAL('2021-01-01T00:00:00Z','2021-12-31T23:59:59Z'))" + ) + assert result == ast.TimeDisjoint( + ast.Attribute("attr"), + values.Interval( + datetime(2021, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime(2021, 12, 31, 23, 59, 59, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + ) + +def test_prefix_tequals_timestamp(): + result = parse( + "T_EQUALS(attr, TIMESTAMP('2022-04-24T07:59:57Z'))" + ) + assert result == ast.TimeEquals( + ast.Attribute("attr"), + datetime(2022, 4, 24, 7, 59, 57, tzinfo=StaticTzInfo("Z", timedelta(0))) + ) + +def test_prefix_tequals_interval(): + result = parse( + "T_EQUALS(attr, INTERVAL('2021-01-01T00:00:00Z','2021-12-31T23:59:59Z'))" + ) + assert result == ast.TimeEquals( + ast.Attribute("attr"), + values.Interval( + datetime(2021, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime(2021, 12, 31, 23, 59, 59, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + ) + +def test_prefix_tintersects_timestamp(): + result = parse( + "T_INTERSECTS(attr, TIMESTAMP('2022-04-24T07:59:57Z'))" + ) + assert result == ast.TimeOverlaps( + ast.Attribute("attr"), + datetime(2022, 4, 24, 7, 59, 57, tzinfo=StaticTzInfo("Z", timedelta(0))) + ) + +def test_prefix_tintersects_interval(): + result = parse( + "T_INTERSECTS(attr, INTERVAL('2021-01-01T00:00:00Z','2021-12-31T23:59:59Z'))" + ) + assert result == ast.TimeOverlaps( + ast.Attribute("attr"), + values.Interval( + datetime(2021, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime(2021, 12, 31, 23, 59, 59, tzinfo=StaticTzInfo("Z", timedelta(0))), + ) + ) def test_intersects_geometry(): result = parse( From 693c30721191ed0c7e814d64fd8c55a0345b6bbe Mon Sep 17 00:00:00 2001 From: Mike Mahoney Date: Mon, 8 Jun 2026 09:38:43 -0400 Subject: [PATCH 2/2] Apply suggestion from @mikemahoney218-usgs --- tests/backends/sqlalchemy/test_evaluate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/backends/sqlalchemy/test_evaluate.py b/tests/backends/sqlalchemy/test_evaluate.py index bd3ac93f..381ea270 100644 --- a/tests/backends/sqlalchemy/test_evaluate.py +++ b/tests/backends/sqlalchemy/test_evaluate.py @@ -327,6 +327,7 @@ def test_tdisjoint(db_session): def test_tintersects(db_session): evaluate(db_session, "T_INTERSECTS(datetimeAttribute, INTERVAL('2000-01-01T00:00:09Z', '2000-01-01T00:00:11Z'))", ("B",), None, parse_cql_text) + def test_date_gte(db_session): evaluate(db_session, "datetimeAttribute >= DATE('2000-01-01')", ("A", "B",), None, parse_cql_text)