Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 13 additions & 1 deletion pygeofilter/parsers/cql2_text/grammar.lark
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
13 changes: 12 additions & 1 deletion pygeofilter/parsers/cql2_text/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions pygeofilter/parsers/cql_json/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

COMPARISON_MAP = {
"eq": ast.Equal,
"ne": ast.NotEqual,
"lt": ast.LessThan,
"lte": ast.LessEqual,
"gt": ast.GreaterThan,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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":
Expand Down
35 changes: 35 additions & 0 deletions tests/parsers/cql2_text/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
52 changes: 52 additions & 0 deletions tests/parsers/cql_json/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import json
from datetime import datetime, timedelta

import pytest
from dateparser.timezone_parser import StaticTzInfo
from pygeoif import geometry

Expand Down Expand Up @@ -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),
)