From 97b9aba2cfe3c18ede277f515caa72d315102b82 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Mon, 8 Jun 2026 15:43:50 +0200 Subject: [PATCH] feat(cql2-text): add array predicate support (A_EQUALS, A_CONTAINS, A_CONTAINEDBY, A_OVERLAPS) - Add 'a_containedBy' key to ARRAY_PREDICATES_MAP in cql2.py to match the camelCase used in CQL2-JSON (spec-compliant); the lowercase key is retained for the text parser - Add array_predicate rule to the CQL2-text grammar: A_EQUALS, A_CONTAINS, A_CONTAINEDBY, A_OVERLAPS with two array_arg operands - Inline empty_array and array as atom alternatives so LALR(1) can unambiguously parse '(1, 2, 3)' vs '(x)' by lookahead on the comma after the first element - Add binary_array_predicate, array, and empty_array transformer methods to CQLTransformer; import ARRAY_PREDICATES_MAP from pygeofilter.cql2 - Add four tests: A_EQUALS/A_CONTAINS/A_OVERLAPS with a literal array and A_EQUALS with an empty array --- pygeofilter/cql2.py | 1 + pygeofilter/parsers/cql2_text/grammar.lark | 11 +++++++++++ pygeofilter/parsers/cql2_text/parser.py | 11 ++++++++++- tests/parsers/cql2_text/test_parser.py | 20 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/pygeofilter/cql2.py b/pygeofilter/cql2.py index 53cf0ecf..92ce542d 100644 --- a/pygeofilter/cql2.py +++ b/pygeofilter/cql2.py @@ -56,6 +56,7 @@ "a_equals": ast.ArrayEquals, "a_contains": ast.ArrayContains, "a_containedby": ast.ArrayContainedBy, + "a_containedBy": ast.ArrayContainedBy, "a_overlaps": ast.ArrayOverlaps, } diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/parsers/cql2_text/grammar.lark index 703cb438..0b0a5719 100644 --- a/pygeofilter/parsers/cql2_text/grammar.lark +++ b/pygeofilter/parsers/cql2_text/grammar.lark @@ -61,6 +61,7 @@ | "EXCLUDE"i -> exclude | spatial_predicate | temporal_predicate + | array_predicate ?temporal_predicate: expression _binary_temporal_predicate_func expression -> binary_temporal_predicate @@ -95,6 +96,14 @@ | "S_EQUALS"i +?array_predicate: _array_predicate_func "(" expression "," expression ")" -> binary_array_predicate + +!_array_predicate_func: "A_EQUALS"i + | "A_CONTAINS"i + | "A_CONTAINEDBY"i + | "A_OVERLAPS"i + + ?expression: sum ?sum: product @@ -109,6 +118,8 @@ | attribute | literal | "-" atom -> neg + | "(" ")" -> empty_array + | "(" expression ("," expression)+ ")" -> array | "(" expression ")" func.2: attribute "(" expression ("," expression)* ")" -> function diff --git a/pygeofilter/parsers/cql2_text/parser.py b/pygeofilter/parsers/cql2_text/parser.py index cced2849..83ab00af 100644 --- a/pygeofilter/parsers/cql2_text/parser.py +++ b/pygeofilter/parsers/cql2_text/parser.py @@ -31,7 +31,7 @@ from lark import Lark, logger, v_args from ... import ast, values -from ...cql2 import SPATIAL_PREDICATES_MAP, TEMPORAL_PREDICATES_MAP +from ...cql2 import ARRAY_PREDICATES_MAP, SPATIAL_PREDICATES_MAP, TEMPORAL_PREDICATES_MAP from ..iso8601 import ISO8601Transformer from ..wkt import WKTTransformer @@ -124,6 +124,15 @@ def during_or_after(self, node, period): def after(self, node, dt): return ast.TimeAfter(node, dt) + def binary_array_predicate(self, func, lhs, rhs): + return ARRAY_PREDICATES_MAP[func.lower()](lhs, rhs) + + def array(self, *items): + return list(items) + + def empty_array(self): + return [] + def binary_spatial_predicate(self, op, lhs, rhs): op = op.lower() return SPATIAL_PREDICATES_MAP[op](lhs, rhs) diff --git a/tests/parsers/cql2_text/test_parser.py b/tests/parsers/cql2_text/test_parser.py index 7d4c38ea..386f3f03 100644 --- a/tests/parsers/cql2_text/test_parser.py +++ b/tests/parsers/cql2_text/test_parser.py @@ -477,3 +477,23 @@ def test_not_eq(): assert result == ast.Not( ast.Equal(ast.Attribute("attr"), 2) ) + + +def test_array_equals(): + result = parse("A_EQUALS(attr, (1, 2, 3))") + assert result == ast.ArrayEquals(ast.Attribute("attr"), [1, 2, 3]) + + +def test_array_contains(): + result = parse("A_CONTAINS(attr, (1, 2, 3))") + assert result == ast.ArrayContains(ast.Attribute("attr"), [1, 2, 3]) + + +def test_array_overlaps(): + result = parse("A_OVERLAPS(attr, (1, 2, 3))") + assert result == ast.ArrayOverlaps(ast.Attribute("attr"), [1, 2, 3]) + + +def test_array_empty(): + result = parse("A_EQUALS(attr, ())") + assert result == ast.ArrayEquals(ast.Attribute("attr"), [])