Skip to content

Commit 85b862d

Browse files
committed
feat(cql2): align temporal predicates with CQL2 spec
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
1 parent f799198 commit 85b862d

7 files changed

Lines changed: 85 additions & 29 deletions

File tree

pygeofilter/ast.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ def get_template(self) -> str:
308308
# interval T2,then the beginning of T1 is after the end of T2, or the end of
309309
# T1 is before the beginning of T2, i.e. the intervals do not overlap in any
310310
# way, but their ordering relationship is not known.
311+
# TINTERSECTS: The union of all other temporal relationships; two temporal
312+
# instances intersect if they are not disjoint.
311313

312314
# https://github.com/geotools/geotools/blob/main/modules/library/cql/ECQL.md#temporal-predicate
313315
# BEFORE_OR_DURING <----->
@@ -329,6 +331,7 @@ class TemporalComparisonOp(Enum):
329331
METBY = "METBY"
330332
TOVERLAPS = "TOVERLAPS"
331333
OVERLAPPEDBY = "OVERLAPPEDBY"
334+
TINTERSECTS = "TINTERSECTS"
332335

333336
BEFORE_OR_DURING = "BEFORE OR DURING"
334337
DURING_OR_AFTER = "DURING OR AFTER"
@@ -420,6 +423,11 @@ class TimeOverlappedBy(TemporalPredicate):
420423

421424

422425
@dataclass
426+
@dataclass
427+
class TimeIntersects(TemporalPredicate):
428+
op: ClassVar[TemporalComparisonOp] = TemporalComparisonOp.TINTERSECTS
429+
430+
423431
class TimeBeforeOrDuring(TemporalPredicate):
424432
op: ClassVar[TemporalComparisonOp] = TemporalComparisonOp.BEFORE_OR_DURING
425433

pygeofilter/cql2.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,18 @@
3838
"t_before": ast.TimeBefore,
3939
"t_after": ast.TimeAfter,
4040
"t_meets": ast.TimeMeets,
41-
"t_metby": ast.TimeMetBy,
41+
"t_metBy": ast.TimeMetBy,
4242
"t_overlaps": ast.TimeOverlaps,
43-
"t_overlappedby": ast.TimeOverlappedBy,
44-
"t_begins": ast.TimeBegins,
45-
"t_begunby": ast.TimeBegunBy,
43+
"t_overlappedBy": ast.TimeOverlappedBy,
44+
"t_starts": ast.TimeBegins,
45+
"t_startedBy": ast.TimeBegunBy,
4646
"t_during": ast.TimeDuring,
4747
"t_contains": ast.TimeContains,
48-
"t_ends": ast.TimeEnds,
49-
"t_endedby": ast.TimeEndedBy,
48+
"t_finishes": ast.TimeEnds,
49+
"t_finishedBy": ast.TimeEndedBy,
5050
"t_equals": ast.TimeEquals,
51-
"t_intersects": ast.TimeOverlaps,
51+
"t_intersects": ast.TimeIntersects,
52+
"t_disjoint": ast.TimeDisjoint,
5253
}
5354

5455

pygeofilter/parsers/cql2_text/grammar.lark

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,23 @@
6363
| temporal_predicate
6464

6565

66-
?temporal_predicate: expression _binary_temporal_predicate_func expression -> binary_temporal_predicate
66+
?temporal_predicate: _binary_temporal_predicate_func "(" expression "," expression ")" -> binary_temporal_predicate
6767

6868
!_binary_temporal_predicate_func: "T_BEFORE"i
6969
| "T_AFTER"i
7070
| "T_MEETS"i
7171
| "T_METBY"i
7272
| "T_OVERLAPS"i
7373
| "T_OVERLAPPEDBY"i
74-
| "T_BEGINS"i
75-
| "T_BEGUNBY"i
74+
| "T_STARTS"i
75+
| "T_STARTEDBY"i
7676
| "T_DURING"i
7777
| "T_CONTAINS"i
78-
| "T_ENDS"i
79-
| "T_ENDEDBY"i
78+
| "T_FINISHES"i
79+
| "T_FINISHEDBY"i
8080
| "T_EQUALS"i
8181
| "T_INTERSECTS"i
82+
| "T_DISJOINT"i
8283

8384

8485
?spatial_predicate: _binary_spatial_predicate_func "(" expression "," expression ")" -> binary_spatial_predicate
@@ -140,7 +141,10 @@ SINGLE_QUOTED: "'" /.*?/ "'"
140141
DATE: /[0-9]{4}-?[0-1][0-9]-?[0-3][0-9]/
141142
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})?/
142143
?timestamp: "TIMESTAMP" "(" "'" DATETIME "'" ")"
143-
?interval: "INTERVAL" "(" "'" DATETIME "'" "," "'" DATETIME "'" ")"
144+
?interval: "INTERVAL" "(" instant_parameter "," instant_parameter ")"
145+
instant_parameter: "'" DATETIME "'" -> instant_datetime
146+
| "'" DATE "'" -> instant_date
147+
| "'" ".." "'" -> instant_open
144148
?date: "DATE" "(" "'" DATE "'" ")"
145149

146150
attribute: /[a-zA-Z][a-zA-Z_:0-9.]+/

pygeofilter/parsers/cql2_text/parser.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,12 @@ def binary_spatial_predicate(self, op, lhs, rhs):
128128
op = op.lower()
129129
return SPATIAL_PREDICATES_MAP[op](lhs, rhs)
130130

131-
def binary_temporal_predicate(self, lhs, op, rhs):
132-
op = op.lower()
133-
return TEMPORAL_PREDICATES_MAP[op](lhs, rhs)
131+
def binary_temporal_predicate(self, op, lhs, rhs):
132+
op_lower = op.lower()
133+
for key, cls in TEMPORAL_PREDICATES_MAP.items():
134+
if key.lower() == op_lower:
135+
return cls(lhs, rhs)
136+
raise ValueError(f"Unknown temporal predicate: {op}")
134137

135138
def relate_spatial_predicate(self, lhs, rhs, pattern):
136139
return ast.Relate(lhs, rhs, pattern)
@@ -193,6 +196,15 @@ def geometry(self, value):
193196
def bbox(self, x1, y1, x2, y2):
194197
return values.Envelope(x1, x2, y1, y2)
195198

199+
def instant_datetime(self, value):
200+
return value
201+
202+
def instant_date(self, value):
203+
return value
204+
205+
def instant_open(self):
206+
return None
207+
196208
def interval(self, start, end):
197209
return values.Interval(start, end)
198210

tests/parsers/cql2_json/fixtures.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"=\", \"args\": [{\"property\": \"prop1\"}, {\"property\": \"prop2\"}]}}"
2121
},
2222
"Example 6": {
23-
"text": "filter=datetime T_INTERSECTS INTERVAL('2020-11-11T00:00:00Z', '2020-11-12T00:00:00Z')",
23+
"text": "filter=T_INTERSECTS(datetime, INTERVAL('2020-11-11T00:00:00Z', '2020-11-12T00:00:00Z'))",
2424
"json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"t_intersects\", \"args\": [{\"property\": \"datetime\"}, {\"interval\": [\"2020-11-11T00:00:00Z\", \"2020-11-12T00:00:00Z\"]}]}}"
2525
},
2626
"Example 7": {

tests/parsers/cql2_json/test_parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def test_meets_dt_dr():
240240
def test_attribute_metby_dr_dt():
241241
result = parse(
242242
{
243-
"op": "t_metby",
243+
"op": "t_metBy",
244244
"args": [
245245
{"property": "attr"},
246246
{"interval": ["PT4S", "2000-01-01T00:00:03Z"]},
@@ -278,7 +278,7 @@ def test_attribute_toverlaps_open_dt():
278278
def test_attribute_overlappedby_dt_open():
279279
result = parse(
280280
{
281-
"op": "t_overlappedby",
281+
"op": "t_overlappedBy",
282282
"args": [
283283
{"property": "attr"},
284284
{"interval": ["2000-01-01T00:00:03Z", ".."]},

tests/parsers/cql2_text/test_parser.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,7 @@ def test_attribute_is_null():
207207

208208

209209
def test_attribute_before():
210-
# Using TIMESTAMP function to properly wrap the timestamp
211-
result = parse("attr T_BEFORE TIMESTAMP('2000-01-01T00:00:01Z')")
210+
result = parse("T_BEFORE(attr, TIMESTAMP('2000-01-01T00:00:01Z'))")
212211
assert result == ast.TimeBefore(
213212
ast.Attribute("attr"),
214213
datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))),
@@ -222,11 +221,10 @@ def test_attribute_lt_date():
222221
)
223222

224223
def test_attribute_t_intersects():
225-
# Using INTERVAL function with properly quoted timestamps
226224
result = parse(
227-
"attr T_INTERSECTS INTERVAL('2000-01-01T00:00:00Z', '2000-01-01T00:00:01Z')"
225+
"T_INTERSECTS(attr, INTERVAL('2000-01-01T00:00:00Z', '2000-01-01T00:00:01Z'))"
228226
)
229-
assert result == ast.TimeOverlaps(
227+
assert result == ast.TimeIntersects(
230228
ast.Attribute("attr"),
231229
values.Interval(
232230
datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))),
@@ -235,15 +233,48 @@ def test_attribute_t_intersects():
235233
)
236234

237235

238-
def test_attribute_tintersects_dt_dr():
236+
def test_t_disjoint():
239237
result = parse(
240-
"attr T_INTERSECTS INTERVAL('2000-01-01T00:00:03Z', '2000-01-01T00:00:04Z')"
238+
"T_DISJOINT(attr, TIMESTAMP('2000-01-01T00:00:00Z'))"
241239
)
242-
assert result == ast.TimeOverlaps(
240+
assert result == ast.TimeDisjoint(
241+
ast.Attribute("attr"),
242+
datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))),
243+
)
244+
245+
246+
def test_t_starts():
247+
result = parse(
248+
"T_STARTS(attr, INTERVAL('2000-01-01T00:00:00Z', '2000-01-01T00:00:01Z'))"
249+
)
250+
assert result == ast.TimeBegins(
251+
ast.Attribute("attr"),
252+
values.Interval(
253+
datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))),
254+
datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))),
255+
),
256+
)
257+
258+
259+
def test_interval_with_date():
260+
result = parse(
261+
"T_DURING(attr, INTERVAL('2000-01-01', '2001-01-01'))"
262+
)
263+
assert result == ast.TimeDuring(
264+
ast.Attribute("attr"),
265+
values.Interval(date(2000, 1, 1), date(2001, 1, 1)),
266+
)
267+
268+
269+
def test_interval_open_ended():
270+
result = parse(
271+
"T_DURING(attr, INTERVAL('..', '2001-01-01T00:00:00Z'))"
272+
)
273+
assert result == ast.TimeDuring(
243274
ast.Attribute("attr"),
244275
values.Interval(
245-
datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))),
246-
datetime(2000, 1, 1, 0, 0, 4, tzinfo=StaticTzInfo("Z", timedelta(0))),
276+
None,
277+
datetime(2001, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))),
247278
),
248279
)
249280

0 commit comments

Comments
 (0)