diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bec4de..03160fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Advanced comparison (LIKE, IN, BETWEEN) operators to the Filter extension [#178](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/178) + ### Changed - Elasticsearch drivers from 7.17.9 to 8.11.0 [#169](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/169) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index e3c4bc64..8adcece4 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -24,6 +24,11 @@ settings = ElasticsearchSettings() session = Session.create_from_settings(settings) +filter_extension = FilterExtension(client=EsAsyncBaseFiltersClient()) +filter_extension.conformance_classes.append( + "http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators" +) + extensions = [ TransactionExtension(client=TransactionsClient(session=session), settings=settings), BulkTransactionExtension(client=BulkTransactionsClient(session=session)), @@ -32,7 +37,7 @@ SortExtension(), TokenPaginationExtension(), ContextExtension(), - FilterExtension(client=EsAsyncBaseFiltersClient()), + filter_extension, ] post_request_model = create_post_request_model(extensions) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/extensions/filter.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/extensions/filter.py index 6cc4ac28..fe691ddf 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/extensions/filter.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/extensions/filter.py @@ -4,12 +4,16 @@ Basic CQL2 (AND, OR, NOT), comparison operators (=, <>, <, <=, >, >=), and IS NULL. The comparison operators are allowed against string, numeric, boolean, date, and datetime types. +Advanced comparison operators (http://www.opengis.net/spec/cql2/1.0/req/advanced-comparison-operators) +defines the LIKE, IN, and BETWEEN operators. + Basic Spatial Operators (http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators) defines the intersects operator (S_INTERSECTS). """ from __future__ import annotations import datetime +import re from enum import Enum from typing import List, Union @@ -78,6 +82,17 @@ def to_es(self): ) +class AdvancedComparisonOp(str, Enum): + """Advanced Comparison operator. + + CQL2 advanced comparison operators like (~), between, and in. + """ + + like = "like" + between = "between" + _in = "in" + + class SpatialIntersectsOp(str, Enum): """Spatial intersections operator s_intersects.""" @@ -152,8 +167,8 @@ def validate(cls, v): class Clause(BaseModel): """Filter extension clause.""" - op: Union[LogicalOp, ComparisonOp, SpatialIntersectsOp] - args: List[Arg] + op: Union[LogicalOp, ComparisonOp, AdvancedComparisonOp, SpatialIntersectsOp] + args: List[Union[Arg, List[Arg]]] def to_es(self): """Generate an Elasticsearch expression for this Clause.""" @@ -171,6 +186,30 @@ def to_es(self): "must_not": [{"term": {to_es(self.args[0]): to_es(self.args[1])}}] } } + elif self.op == AdvancedComparisonOp.like: + return { + "wildcard": { + to_es(self.args[0]): { + "value": cql2_like_to_es(str(to_es(self.args[1]))), + "case_insensitive": "false", + } + } + } + elif self.op == AdvancedComparisonOp.between: + return { + "range": { + to_es(self.args[0]): { + "gte": to_es(self.args[1]), + "lte": to_es(self.args[2]), + } + } + } + elif self.op == AdvancedComparisonOp._in: + if not isinstance(self.args[1], List): + raise RuntimeError(f"Arg {self.args[1]} is not a list") + return { + "terms": {to_es(self.args[0]): [to_es(arg) for arg in self.args[1]]} + } elif ( self.op == ComparisonOp.lt or self.op == ComparisonOp.lte @@ -210,3 +249,19 @@ def to_es(arg: Arg): return arg else: raise RuntimeError(f"unknown arg {repr(arg)}") + + +def cql2_like_to_es(string): + """Convert wildcard characters in CQL2 ('_' and '%') to Elasticsearch wildcard characters ('?' and '*', respectively). Handle escape characters and pass through Elasticsearch wildcards.""" + percent_pattern = r"(?