Skip to content

Commit c38ce2b

Browse files
authored
Merge branch 'main' into #179
2 parents 85eb50b + ba11e0c commit c38ce2b

File tree

8 files changed

+306
-3
lines changed

8 files changed

+306
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99

1010
### Added
1111

12+
- Advanced comparison (LIKE, IN, BETWEEN) operators to the Filter extension [#178](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/178)
13+
1214
### Changed
1315

1416
- Elasticsearch drivers from 7.17.9 to 8.11.0 [#169](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/169)

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
settings = ElasticsearchSettings()
2525
session = Session.create_from_settings(settings)
2626

27+
filter_extension = FilterExtension(client=EsAsyncBaseFiltersClient())
28+
filter_extension.conformance_classes.append(
29+
"http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators"
30+
)
31+
2732
extensions = [
2833
TransactionExtension(client=TransactionsClient(session=session), settings=settings),
2934
BulkTransactionExtension(client=BulkTransactionsClient(session=session)),
@@ -32,7 +37,7 @@
3237
SortExtension(),
3338
TokenPaginationExtension(),
3439
ContextExtension(),
35-
FilterExtension(client=EsAsyncBaseFiltersClient()),
40+
filter_extension,
3641
]
3742

3843
post_request_model = create_post_request_model(extensions)

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/extensions/filter.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
Basic CQL2 (AND, OR, NOT), comparison operators (=, <>, <, <=, >, >=), and IS NULL.
55
The comparison operators are allowed against string, numeric, boolean, date, and datetime types.
66
7+
Advanced comparison operators (http://www.opengis.net/spec/cql2/1.0/req/advanced-comparison-operators)
8+
defines the LIKE, IN, and BETWEEN operators.
9+
710
Basic Spatial Operators (http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators)
811
defines the intersects operator (S_INTERSECTS).
912
"""
1013
from __future__ import annotations
1114

1215
import datetime
16+
import re
1317
from enum import Enum
1418
from typing import List, Union
1519

@@ -78,6 +82,17 @@ def to_es(self):
7882
)
7983

8084

85+
class AdvancedComparisonOp(str, Enum):
86+
"""Advanced Comparison operator.
87+
88+
CQL2 advanced comparison operators like (~), between, and in.
89+
"""
90+
91+
like = "like"
92+
between = "between"
93+
_in = "in"
94+
95+
8196
class SpatialIntersectsOp(str, Enum):
8297
"""Spatial intersections operator s_intersects."""
8398

@@ -152,8 +167,8 @@ def validate(cls, v):
152167
class Clause(BaseModel):
153168
"""Filter extension clause."""
154169

155-
op: Union[LogicalOp, ComparisonOp, SpatialIntersectsOp]
156-
args: List[Arg]
170+
op: Union[LogicalOp, ComparisonOp, AdvancedComparisonOp, SpatialIntersectsOp]
171+
args: List[Union[Arg, List[Arg]]]
157172

158173
def to_es(self):
159174
"""Generate an Elasticsearch expression for this Clause."""
@@ -171,6 +186,30 @@ def to_es(self):
171186
"must_not": [{"term": {to_es(self.args[0]): to_es(self.args[1])}}]
172187
}
173188
}
189+
elif self.op == AdvancedComparisonOp.like:
190+
return {
191+
"wildcard": {
192+
to_es(self.args[0]): {
193+
"value": cql2_like_to_es(str(to_es(self.args[1]))),
194+
"case_insensitive": "false",
195+
}
196+
}
197+
}
198+
elif self.op == AdvancedComparisonOp.between:
199+
return {
200+
"range": {
201+
to_es(self.args[0]): {
202+
"gte": to_es(self.args[1]),
203+
"lte": to_es(self.args[2]),
204+
}
205+
}
206+
}
207+
elif self.op == AdvancedComparisonOp._in:
208+
if not isinstance(self.args[1], List):
209+
raise RuntimeError(f"Arg {self.args[1]} is not a list")
210+
return {
211+
"terms": {to_es(self.args[0]): [to_es(arg) for arg in self.args[1]]}
212+
}
174213
elif (
175214
self.op == ComparisonOp.lt
176215
or self.op == ComparisonOp.lte
@@ -210,3 +249,19 @@ def to_es(arg: Arg):
210249
return arg
211250
else:
212251
raise RuntimeError(f"unknown arg {repr(arg)}")
252+
253+
254+
def cql2_like_to_es(string):
255+
"""Convert wildcard characters in CQL2 ('_' and '%') to Elasticsearch wildcard characters ('?' and '*', respectively). Handle escape characters and pass through Elasticsearch wildcards."""
256+
percent_pattern = r"(?<!\\)%"
257+
underscore_pattern = r"(?<!\\)_"
258+
escape_pattern = r"\\(?=[_%])"
259+
260+
for pattern in [
261+
(percent_pattern, "*"),
262+
(underscore_pattern, "?"),
263+
(escape_pattern, ""),
264+
]:
265+
string = re.sub(pattern[0], pattern[1], string)
266+
267+
return string
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"op": "like",
3+
"args": [
4+
{
5+
"property": "scene_id"
6+
},
7+
"LC82030282019133%"
8+
]
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"op": "like",
3+
"args": [
4+
{
5+
"property": "scene_id"
6+
},
7+
"LC82030282019133LGN0_"
8+
]
9+
}
10+
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"op": "and",
3+
"args": [
4+
{
5+
"op": "between",
6+
"args": [
7+
{
8+
"property": "cloud_cover"
9+
},
10+
0.1,
11+
0.2
12+
]
13+
},
14+
{
15+
"op": "=",
16+
"args": [
17+
{
18+
"property": "landsat:wrs_row"
19+
},
20+
28
21+
]
22+
},
23+
{
24+
"op": "=",
25+
"args": [
26+
{
27+
"property": "landsat:wrs_path"
28+
},
29+
203
30+
]
31+
}
32+
]
33+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"op": "and",
3+
"args": [
4+
{
5+
"op": "in",
6+
"args": [
7+
{"property": "id"},
8+
["LC08_L1TP_060247_20180905_20180912_01_T1_L1TP"]
9+
]
10+
},
11+
{"op": "=", "args": [{"property": "collection"}, "landsat8_l1tp"]}
12+
]
13+
}

stac_fastapi/elasticsearch/tests/extensions/test_filter.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,179 @@ async def test_search_filter_extension_floats_post(app_client, ctx):
224224

225225
assert resp.status_code == 200
226226
assert len(resp.json()["features"]) == 1
227+
228+
229+
@pytest.mark.asyncio
230+
async def test_search_filter_extension_wildcard_cql2(app_client, ctx):
231+
single_char = ctx.item["id"][:-1] + "_"
232+
multi_char = ctx.item["id"][:-3] + "%"
233+
234+
params = {
235+
"filter": {
236+
"op": "and",
237+
"args": [
238+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
239+
{
240+
"op": "like",
241+
"args": [
242+
{"property": "id"},
243+
single_char,
244+
],
245+
},
246+
{
247+
"op": "like",
248+
"args": [
249+
{"property": "id"},
250+
multi_char,
251+
],
252+
},
253+
],
254+
}
255+
}
256+
257+
resp = await app_client.post("/search", json=params)
258+
259+
assert resp.status_code == 200
260+
assert len(resp.json()["features"]) == 1
261+
262+
263+
@pytest.mark.asyncio
264+
async def test_search_filter_extension_wildcard_es(app_client, ctx):
265+
single_char = ctx.item["id"][:-1] + "?"
266+
multi_char = ctx.item["id"][:-3] + "*"
267+
268+
params = {
269+
"filter": {
270+
"op": "and",
271+
"args": [
272+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
273+
{
274+
"op": "like",
275+
"args": [
276+
{"property": "id"},
277+
single_char,
278+
],
279+
},
280+
{
281+
"op": "like",
282+
"args": [
283+
{"property": "id"},
284+
multi_char,
285+
],
286+
},
287+
],
288+
}
289+
}
290+
291+
resp = await app_client.post("/search", json=params)
292+
293+
assert resp.status_code == 200
294+
assert len(resp.json()["features"]) == 1
295+
296+
297+
@pytest.mark.asyncio
298+
async def test_search_filter_extension_escape_chars(app_client, ctx):
299+
esc_chars = (
300+
ctx.item["properties"]["landsat:product_id"].replace("_", "\\_")[:-1] + "_"
301+
)
302+
303+
params = {
304+
"filter": {
305+
"op": "and",
306+
"args": [
307+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
308+
{
309+
"op": "like",
310+
"args": [
311+
{"property": "properties.landsat:product_id"},
312+
esc_chars,
313+
],
314+
},
315+
],
316+
}
317+
}
318+
319+
resp = await app_client.post("/search", json=params)
320+
321+
assert resp.status_code == 200
322+
assert len(resp.json()["features"]) == 1
323+
324+
325+
@pytest.mark.asyncio
326+
async def test_search_filter_extension_in(app_client, ctx):
327+
product_id = ctx.item["properties"]["landsat:product_id"]
328+
329+
params = {
330+
"filter": {
331+
"op": "and",
332+
"args": [
333+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
334+
{
335+
"op": "in",
336+
"args": [
337+
{"property": "properties.landsat:product_id"},
338+
[product_id],
339+
],
340+
},
341+
],
342+
}
343+
}
344+
345+
resp = await app_client.post("/search", json=params)
346+
347+
assert resp.status_code == 200
348+
assert len(resp.json()["features"]) == 1
349+
350+
351+
@pytest.mark.asyncio
352+
async def test_search_filter_extension_in_no_list(app_client, ctx):
353+
product_id = ctx.item["properties"]["landsat:product_id"]
354+
355+
params = {
356+
"filter": {
357+
"op": "and",
358+
"args": [
359+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
360+
{
361+
"op": "in",
362+
"args": [
363+
{"property": "properties.landsat:product_id"},
364+
product_id,
365+
],
366+
},
367+
],
368+
}
369+
}
370+
371+
resp = await app_client.post("/search", json=params)
372+
373+
assert resp.status_code == 400
374+
assert resp.json() == {
375+
"detail": f"Error with cql2_json filter: Arg {product_id} is not a list"
376+
}
377+
378+
379+
@pytest.mark.asyncio
380+
async def test_search_filter_extension_between(app_client, ctx):
381+
sun_elevation = ctx.item["properties"]["view:sun_elevation"]
382+
383+
params = {
384+
"filter": {
385+
"op": "and",
386+
"args": [
387+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
388+
{
389+
"op": "between",
390+
"args": [
391+
{"property": "properties.view:sun_elevation"},
392+
sun_elevation - 0.01,
393+
sun_elevation + 0.01,
394+
],
395+
},
396+
],
397+
}
398+
}
399+
resp = await app_client.post("/search", json=params)
400+
401+
assert resp.status_code == 200
402+
assert len(resp.json()["features"]) == 1

0 commit comments

Comments
 (0)