Skip to content

Commit 9e3da46

Browse files
Yuri ZmytrakovYuri Zmytrakov
authored andcommitted
feat: Rewrite CQL2 implementation for SFEOS
1 parent 4e30406 commit 9e3da46

File tree

7 files changed

+549
-7
lines changed

7 files changed

+549
-7
lines changed

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,12 @@ async def post_search(
875875
query_fields = get_properties_from_cql2_filter(cql2_filter)
876876
await self.queryables_cache.validate(query_fields)
877877
search = await self.database.apply_cql2_filter(search, cql2_filter)
878+
date_str = getattr(search, "_cql2_date_str", None)
879+
if date_str is not None:
880+
datetime_parsed = format_datetime_range(date_str=date_str)
881+
search, datetime_search = self.database.apply_datetime_filter(
882+
search=search, datetime=datetime_parsed
883+
)
878884
except HTTPException:
879885
raise
880886
except Exception as e:

stac_fastapi/core/stac_fastapi/core/extensions/filter.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
# defines spatial operators (S_INTERSECTS, S_CONTAINS, S_WITHIN, S_DISJOINT).
1414
# """
1515

16+
from dataclasses import dataclass
1617
from enum import Enum
17-
from typing import Any, Dict
18+
from typing import Any, Dict, List, Optional
1819

1920
DEFAULT_QUERYABLES: Dict[str, Dict[str, Any]] = {
2021
"id": {
@@ -90,3 +91,62 @@ class SpatialOp(str, Enum):
9091
S_CONTAINS = "s_contains"
9192
S_WITHIN = "s_within"
9293
S_DISJOINT = "s_disjoint"
94+
95+
96+
@dataclass
97+
class CqlNode:
98+
"""Base class."""
99+
100+
pass
101+
102+
103+
@dataclass
104+
class LogicalNode(CqlNode):
105+
"""Logical operators (AND, OR, NOT)."""
106+
107+
op: LogicalOp
108+
children: List["CqlNode"]
109+
110+
111+
@dataclass
112+
class ComparisonNode(CqlNode):
113+
"""Comparison operators (=, <>, <, <=, >, >=, is null)."""
114+
115+
op: ComparisonOp
116+
field: str
117+
value: Any
118+
119+
120+
@dataclass
121+
class AdvancedComparisonNode(CqlNode):
122+
"""Advanced comparison operators (like, between, in)."""
123+
124+
op: AdvancedComparisonOp
125+
field: str
126+
value: Any
127+
128+
129+
@dataclass
130+
class SpatialNode(CqlNode):
131+
"""Spatial operators."""
132+
133+
op: SpatialOp
134+
field: str
135+
geometry: Dict[str, Any]
136+
137+
138+
@dataclass
139+
class DateTimeRangeNode(CqlNode):
140+
"""Datetime range queries."""
141+
142+
field: str = "properties.datetime"
143+
start: Optional[str] = None
144+
end: Optional[str] = None
145+
146+
147+
@dataclass
148+
class DateTimeExactNode(CqlNode):
149+
"""Exact datetime queries."""
150+
151+
field: str = "properties.datetime"
152+
value: Optional[str] = None

stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
merge_to_operations,
5353
operations_to_script,
5454
)
55+
from stac_fastapi.sfeos_helpers.filter import (
56+
Cql2AstParser,
57+
DatetimeOptimizer,
58+
to_es_via_ast,
59+
)
60+
from stac_fastapi.sfeos_helpers.filter.datetime_optimizer import extract_from_ast
5561
from stac_fastapi.sfeos_helpers.mappings import (
5662
AGGREGATION_MAPPING,
5763
COLLECTIONS_INDEX,
@@ -683,11 +689,11 @@ async def apply_cql2_filter(
683689
self, search: Search, _filter: Optional[Dict[str, Any]]
684690
):
685691
"""
686-
Apply a CQL2 filter to an Opensearch Search object.
692+
Apply a CQL2 filter to an OpenSearch Search object.
687693
688-
This method transforms a dictionary representing a CQL2 filter into an Opensearch query
689-
and applies it to the provided Search object. If the filter is None, the original Search
690-
object is returned unmodified.
694+
This method transforms a CQL2 filter dictionary into an OpenSearch query using
695+
an AST tree-based approach. If the filter is None, the original Search object is returned
696+
unmodified.
691697
692698
Args:
693699
search (Search): The Opensearch Search object to which the filter will be applied.
@@ -701,8 +707,26 @@ async def apply_cql2_filter(
701707
otherwise the original Search object.
702708
"""
703709
if _filter is not None:
704-
es_query = filter_module.to_es(await self.get_queryables_mapping(), _filter)
705-
search = search.filter(es_query)
710+
queryables_mapping = await self.get_queryables_mapping()
711+
712+
try:
713+
parser = Cql2AstParser(queryables_mapping)
714+
ast = parser.parse(_filter)
715+
716+
optimizer = DatetimeOptimizer()
717+
optimized_ast = optimizer.optimize_query_structure(ast)
718+
719+
date_str = extract_from_ast(optimized_ast, "datetime")
720+
es_query = to_es_via_ast(queryables_mapping, optimized_ast)
721+
722+
search = search.filter(es_query)
723+
search._cql2_date_str = date_str
724+
725+
except Exception:
726+
# Fallback to dictionary-based approach
727+
es_query = filter_module.to_es(queryables_mapping, _filter)
728+
search = search.filter(es_query)
729+
return search
706730

707731
return search
708732

stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/filter/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
- cql2.py: CQL2 pattern conversion helpers
1212
- transform.py: Query transformation functions
1313
- client.py: Filter client implementation
14+
- ast_parser.py: AST parser for CQL2 queries
15+
- datetime_optimizer.py: Datetime optimization for query structure
1416
1517
When adding new functionality to this package, consider:
1618
1. Will this code be used by both Elasticsearch and OpenSearch implementations?
@@ -22,6 +24,14 @@
2224
- Parameter names should be consistent across similar functions
2325
"""
2426

27+
from stac_fastapi.core.extensions.filter import (
28+
AdvancedComparisonOp,
29+
ComparisonOp,
30+
LogicalOp,
31+
)
32+
33+
from .ast_parser import Cql2AstParser
34+
from .ast_transform import to_es_via_ast
2535
from .client import EsAsyncBaseFiltersClient
2636

2737
# Re-export the main functions and classes for backward compatibility
@@ -31,6 +41,7 @@
3141
cql2_like_to_es,
3242
valid_like_substitutions,
3343
)
44+
from .datetime_optimizer import DatetimeOptimizer
3445
from .transform import to_es, to_es_field
3546

3647
__all__ = [
@@ -40,5 +51,12 @@
4051
"_replace_like_patterns",
4152
"to_es_field",
4253
"to_es",
54+
"to_es_via_ast",
4355
"EsAsyncBaseFiltersClient",
56+
"Cql2AstParser",
57+
"AdvancedComparisonOp",
58+
"ComparisonOp",
59+
"LogicalOp",
60+
"DatetimeOptimizer",
61+
"extract_from_ast",
4462
]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""AST parser for CQL2 queries."""
2+
3+
import json
4+
from typing import Any, Dict, Union
5+
6+
from stac_fastapi.core.extensions.filter import (
7+
AdvancedComparisonNode,
8+
AdvancedComparisonOp,
9+
ComparisonNode,
10+
ComparisonOp,
11+
CqlNode,
12+
LogicalNode,
13+
LogicalOp,
14+
SpatialNode,
15+
SpatialOp,
16+
)
17+
18+
19+
class Cql2AstParser:
20+
"""Parse CQL2 into AST tree."""
21+
22+
def __init__(self, queryables_mapping: Dict[str, Any]):
23+
"""Initialize the CQL2 AST parser."""
24+
self.queryables_mapping = queryables_mapping
25+
26+
def parse(self, cql: Union[str, Dict[str, Any]]) -> CqlNode:
27+
"""Parse CQL2 into AST tree.
28+
29+
Args:
30+
cql: CQL2 expression as string/dictionary
31+
32+
Returns:
33+
Node of AST tree
34+
"""
35+
if isinstance(cql, str):
36+
data: Dict[str, Any] = json.loads(cql)
37+
return self._parse_node(data)
38+
39+
return self._parse_node(cql)
40+
41+
def _parse_node(self, node: Dict[str, Any]) -> CqlNode:
42+
"""Parse a single CQL2 node into AST."""
43+
if "op" in node and node["op"] in ["and", "or", "not"]:
44+
op = LogicalOp(node["op"])
45+
args = node.get("args", [])
46+
47+
if op == LogicalOp.NOT:
48+
children = [self._parse_node(args[0])] if args else []
49+
else:
50+
children = [self._parse_node(arg) for arg in args]
51+
52+
return LogicalNode(op=op, children=children)
53+
54+
elif "op" in node and node["op"] in ["=", "<>", "<", "<=", ">", ">=", "isNull"]:
55+
op = ComparisonOp(node["op"])
56+
args = node.get("args", [])
57+
58+
if isinstance(args[0], dict) and "property" in args[0]:
59+
field = args[0]["property"]
60+
else:
61+
field = str(args[0])
62+
63+
value = args[1] if len(args) > 1 else None
64+
65+
return ComparisonNode(op=op, field=field, value=value)
66+
67+
elif "op" in node and node["op"] in ["like", "between", "in"]:
68+
op = AdvancedComparisonOp(node["op"])
69+
args = node.get("args", [])
70+
71+
if isinstance(args[0], dict) and "property" in args[0]:
72+
field = args[0]["property"]
73+
else:
74+
field = str(args[0])
75+
76+
if op == AdvancedComparisonOp.BETWEEN:
77+
if len(args) != 3:
78+
raise ValueError(
79+
f"BETWEEN operator requires (property, lower, upper), got {args}"
80+
)
81+
value = (args[1], args[2])
82+
83+
elif op == AdvancedComparisonOp.IN:
84+
if not isinstance(args[1], list):
85+
raise ValueError(f"IN operator expects list, got {type(args[1])}")
86+
value = args[1]
87+
88+
elif op == AdvancedComparisonOp.LIKE:
89+
if len(args) != 2:
90+
raise ValueError(
91+
f"LIKE operator requires (property, pattern), got {args}"
92+
)
93+
value = args[1]
94+
95+
return AdvancedComparisonNode(op=op, field=field, value=value)
96+
97+
elif "op" in node and node["op"] in [
98+
"s_intersects",
99+
"s_contains",
100+
"s_within",
101+
"s_disjoint",
102+
]:
103+
op = SpatialOp(node["op"])
104+
args = node.get("args", [])
105+
106+
if isinstance(args[0], dict) and "property" in args[0]:
107+
field = args[0]["property"]
108+
else:
109+
field = str(args[0])
110+
111+
geometry = args[1]
112+
113+
return SpatialNode(op=op, field=field, geometry=geometry)

0 commit comments

Comments
 (0)