From c384717be313ba1a40fac39899d4b4bfdf2f2fd8 Mon Sep 17 00:00:00 2001 From: Nortaq-PlayNexus <311333767+Nortaq-PlayNexus@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:12:30 +1000 Subject: [PATCH] Add CQL2 array predicates, cql-json ne, and parser validation - cql2_text: support array predicates (A_EQUALS, A_CONTAINS, A_CONTAINEDBY, A_OVERLAPS) with array literals or attributes (fixes #160) - cql2_text: allow single-character attribute names (fixes #144) - cql-json: add ne comparison operator (fixes #19) - cql-json: validate multiple predicate keys and and/or arity (fixes #47) - update CHANGELOG and add tests --- CHANGELOG.md | 23 ++++++++++ pygeofilter/parsers/cql2_text/grammar.lark | 14 +++++- pygeofilter/parsers/cql2_text/parser.py | 13 +++++- pygeofilter/parsers/cql_json/parser.py | 29 ++++++++++++ tests/parsers/cql2_text/test_parser.py | 35 +++++++++++++++ tests/parsers/cql_json/test_parser.py | 52 ++++++++++++++++++++++ 6 files changed, 164 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f83c3..df6e4c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [Unreleased] + +### Added + +- **CQL2 text array predicates** (fixes #160): the cql2_text parser now + accepts `A_EQUALS`, `A_CONTAINS`, `A_CONTAINEDBY`, and `A_OVERLAPS` + with an array literal (`('a', 'b', 'c')`) or an attribute on the + right-hand side, matching the CQL2 spec. Previously + `A_CONTAINS('values', ('a', 'b', 'c'))` failed with a LALR + `KeyError: 'COMMA'`. +- **cql-json `ne` comparison** (fixes #19): `{"ne": [...]}` now parses + to `ast.NotEqual`, completing the CQL2 comparison operator set. +- **cql-json malformed-expression validation** (fixes #47): nodes with + more than one predicate key, and `and`/`or` with fewer than 2 + operands, now raise a descriptive `ValueError` instead of silently + producing a wrong AST. + +### Fixed + +- **cql2_text single-character attribute names** (fixes #144): the + attribute terminal regex now allows names of length 1 + (`[a-zA-Z][a-zA-Z_:0-9.]*` instead of `+`), so `a = 1` parses. + ## [0.4.0](https://github.com/geopython/pygeofilter/compare/v0.3.3...v0.4.0) (2026-06-08) ## What's Changed diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/parsers/cql2_text/grammar.lark index 703cb43..97952ca 100644 --- a/pygeofilter/parsers/cql2_text/grammar.lark +++ b/pygeofilter/parsers/cql2_text/grammar.lark @@ -61,6 +61,18 @@ | "EXCLUDE"i -> exclude | spatial_predicate | temporal_predicate + | array_predicate + + +?array_predicate: _binary_array_predicate_func "(" expression "," array_literal ")" -> binary_array_predicate + | _binary_array_predicate_func "(" expression "," expression ")" -> binary_array_predicate + +!_binary_array_predicate_func: "A_EQUALS"i + | "A_CONTAINS"i + | "A_CONTAINEDBY"i + | "A_OVERLAPS"i + +array_literal: "(" expression "," expression ( "," expression )* ")" -> array_literal ?temporal_predicate: expression _binary_temporal_predicate_func expression -> binary_temporal_predicate @@ -143,7 +155,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/cql2_text/parser.py b/pygeofilter/parsers/cql2_text/parser.py index cced284..77e6d57 100644 --- a/pygeofilter/parsers/cql2_text/parser.py +++ b/pygeofilter/parsers/cql2_text/parser.py @@ -31,7 +31,11 @@ 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 @@ -128,6 +132,13 @@ def binary_spatial_predicate(self, op, lhs, rhs): op = op.lower() return SPATIAL_PREDICATES_MAP[op](lhs, rhs) + def binary_array_predicate(self, op, lhs, rhs): + op = op.lower() + return ARRAY_PREDICATES_MAP[op](lhs, rhs) + + def array_literal(self, *exprs): + return list(exprs) + def binary_temporal_predicate(self, lhs, op, rhs): op = op.lower() return TEMPORAL_PREDICATES_MAP[op](lhs, rhs) diff --git a/pygeofilter/parsers/cql_json/parser.py b/pygeofilter/parsers/cql_json/parser.py index b0d969e..7149fea 100644 --- a/pygeofilter/parsers/cql_json/parser.py +++ b/pygeofilter/parsers/cql_json/parser.py @@ -38,6 +38,7 @@ COMPARISON_MAP = { "eq": ast.Equal, + "ne": ast.NotEqual, "lt": ast.LessThan, "lte": ast.LessEqual, "gt": ast.GreaterThan, @@ -87,6 +88,19 @@ "/": ast.Div, } +# Keys that identify a predicate/operator in a CQL2 JSON node. Every +# node must contain exactly one of these; extra keys are either +# options for that operator (e.g. ``like``'s ``singleChar``/``nocase``) +# or a malformed second predicate. +PREDICATE_KEYS = ( + set(COMPARISON_MAP) + | set(SPATIAL_PREDICATES_MAP) + | set(TEMPORAL_PREDICATES_MAP) + | set(ARRAY_PREDICATES_MAP) + | set(ARITHMETIC_MAP) + | {"and", "or", "not", "between", "like", "in", "isNull", "property", "function"} +) + def walk_cql_json(node: dict, is_temporal: bool = False) -> ast.AstType: # noqa: C901 if is_temporal and isinstance(node, str): @@ -128,9 +142,24 @@ def walk_cql_json(node: dict, is_temporal: bool = False) -> ast.AstType: # noqa return Envelope(*node["bbox"]) # decode all other nodes + # A node may only contain a single predicate key. Extra keys are a + # malformed expression (e.g. two top-level predicates). Silently + # dropping the extra key would produce a misleading AST, so fail. + predicate_keys = [key for key in node if key in PREDICATE_KEYS] + if len(predicate_keys) > 1: + raise ValueError( + f"Unable to parse expression node {node!r}: multiple predicates " + f"{predicate_keys!r}; a CQL2 JSON node must contain exactly one predicate" + ) + for name, value in node.items(): if name in ("and", "or"): sub_items = cast(list, walk_cql_json(value)) + if len(sub_items) < 2: + raise ValueError( + f"Unable to parse '{name}' expression: a CQL2 logical " + f"predicate requires at least 2 operands, got {len(sub_items)}" + ) return (ast.And if name == "and" else ast.Or).from_items(*sub_items) elif name == "not": diff --git a/tests/parsers/cql2_text/test_parser.py b/tests/parsers/cql2_text/test_parser.py index 5bceec7..190e1fe 100644 --- a/tests/parsers/cql2_text/test_parser.py +++ b/tests/parsers/cql2_text/test_parser.py @@ -489,3 +489,38 @@ def test_not_lt(): def test_not_eq(): result = parse("NOT(attr = 2)") assert result == ast.Not(ast.Equal(ast.Attribute("attr"), 2)) + + +def test_single_character_attribute_name(): + result = parse("a = 1") + assert result == ast.Equal(ast.Attribute("a"), 1) + + +def test_single_character_attribute_in_list(): + result = parse("x IN (1, 2, 3)") + assert result == ast.In(ast.Attribute("x"), [1, 2, 3], False) + + +def test_aequals_array_literal(): + result = parse("A_EQUALS(attr, (1, 2, 3))") + assert result == ast.ArrayEquals(ast.Attribute("attr"), [1, 2, 3]) + + +def test_acontains_array_literal(): + result = parse("A_CONTAINS(attr, ('a', 'b', 'c'))") + assert result == ast.ArrayContains(ast.Attribute("attr"), ["a", "b", "c"]) + + +def test_acontainedby_array_literal(): + result = parse("A_CONTAINEDBY(attr, (1, 2))") + assert result == ast.ArrayContainedBy(ast.Attribute("attr"), [1, 2]) + + +def test_aoverlaps_array_literal(): + result = parse("A_OVERLAPS(attr, (1, 2, 3))") + assert result == ast.ArrayOverlaps(ast.Attribute("attr"), [1, 2, 3]) + + +def test_acontains_attribute_rhs(): + result = parse("A_CONTAINS(attr, other_attr)") + assert result == ast.ArrayContains(ast.Attribute("attr"), ast.Attribute("other_attr")) diff --git a/tests/parsers/cql_json/test_parser.py b/tests/parsers/cql_json/test_parser.py index 5d45142..f581764 100644 --- a/tests/parsers/cql_json/test_parser.py +++ b/tests/parsers/cql_json/test_parser.py @@ -28,6 +28,7 @@ import json from datetime import datetime, timedelta +import pytest from dateparser.timezone_parser import StaticTzInfo from pygeoif import geometry @@ -835,3 +836,54 @@ def test_function_attr_string_arg(): ], ), ) + + +def test_attribute_ne_literal(): + """The ``ne`` comparison (``<>``) is part of the CQL2 JSON comparison set.""" + result = parse({"ne": [{"property": "attr"}, 5]}) + assert result == ast.NotEqual( + ast.Attribute("attr"), + 5, + ) + + +def test_malformed_multiple_predicates_raises(): + """A node with two predicates is invalid and must not silently drop one.""" + with pytest.raises(ValueError, match="multiple predicates"): + parse( + { + "intersects": [ + {"property": "geometry"}, + {"type": "Point", "coordinates": [10.4064, 55.3951]}, + ], + "and": [{"eq": [{"property": "direction"}, "east"]}], + } + ) + + +def test_single_operand_and_raises(): + """A logical ``and`` with fewer than 2 operands is invalid CQL2 JSON.""" + with pytest.raises(ValueError, match="at least 2 operands"): + parse({"and": [{"eq": [{"property": "direction"}, "east"]}]}) + + +def test_single_operand_or_raises(): + """A logical ``or`` with fewer than 2 operands is invalid CQL2 JSON.""" + with pytest.raises(ValueError, match="at least 2 operands"): + parse({"or": [{"eq": [{"property": "a"}, 1]}]}) + + +def test_two_operand_and_parses(): + """A well-formed two-operand ``and`` still parses to a combined AST.""" + result = parse( + { + "and": [ + {"eq": [{"property": "a"}, 1]}, + {"eq": [{"property": "b"}, 2]}, + ] + } + ) + assert result == ast.And( + ast.Equal(ast.Attribute("a"), 1), + ast.Equal(ast.Attribute("b"), 2), + )