Skip to content

Commit 73f6dc4

Browse files
IN and BETWEEN operators
1 parent 8ed4d35 commit 73f6dc4

File tree

5 files changed

+179
-6
lines changed

5 files changed

+179
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99

1010
### Added
1111

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

1414
### Changed
1515

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
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 CQL2 LIKE comparison operator (http://www.opengis.net/spec/cql2/1.0/req/advanced-comparison-operators).
8-
The LIKE comparison operator is allowed against string types.
7+
Advanced comparison operators (http://www.opengis.net/spec/cql2/1.0/req/advanced-comparison-operators)
8+
defines the LIKE, IN, and BETWEEN operators.
99
1010
Basic Spatial Operators (http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators)
1111
defines the intersects operator (S_INTERSECTS).
@@ -84,10 +84,12 @@ def to_es(self):
8484
class AdvancedComparisonOp(str, Enum):
8585
"""Advanced Comparison operator.
8686
87-
CQL2 advanced comparison operator like (~).
87+
CQL2 advanced comparison operators like (~), between, and in.
8888
"""
8989

9090
like = "like"
91+
between = "between"
92+
_in = "in"
9193

9294

9395
class SpatialIntersectsOp(str, Enum):
@@ -165,7 +167,7 @@ class Clause(BaseModel):
165167
"""Filter extension clause."""
166168

167169
op: Union[LogicalOp, ComparisonOp, AdvancedComparisonOp, SpatialIntersectsOp]
168-
args: List[Arg]
170+
args: List[Union[Arg, List[Arg]]]
169171

170172
def to_es(self):
171173
"""Generate an Elasticsearch expression for this Clause."""
@@ -188,11 +190,27 @@ def to_es(self):
188190
"wildcard": {
189191
to_es(self.args[0]): {
190192
"value": cql2_like_to_es(str(to_es(self.args[1]))),
191-
"boost": 1.0,
192193
"case_insensitive": "true",
193194
}
194195
}
195196
}
197+
elif self.op == AdvancedComparisonOp.between:
198+
if not isinstance(self.args[1], List):
199+
raise RuntimeError(f"Arg {self.args[1]} is not a list")
200+
return {
201+
"range": {
202+
to_es(self.args[0]): {
203+
"gte": to_es(self.args[1][0]),
204+
"lte": to_es(self.args[1][1]),
205+
}
206+
}
207+
}
208+
elif self.op == AdvancedComparisonOp._in:
209+
if not isinstance(self.args[1], List):
210+
raise RuntimeError(f"Arg {self.args[1]} is not a list")
211+
return {
212+
"terms": {to_es(self.args[0]): [to_es(arg) for arg in self.args[1]]}
213+
}
196214
elif (
197215
self.op == ComparisonOp.lt
198216
or self.op == ComparisonOp.lte
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"op": "and",
3+
"args": [
4+
{
5+
"op": "between",
6+
"args": [
7+
{
8+
"property": "cloud_cover"
9+
},
10+
[
11+
0.1,
12+
0.2
13+
]
14+
]
15+
},
16+
{
17+
"op": "=",
18+
"args": [
19+
{
20+
"property": "landsat:wrs_row"
21+
},
22+
28
23+
]
24+
},
25+
{
26+
"op": "=",
27+
"args": [
28+
{
29+
"property": "landsat:wrs_path"
30+
},
31+
203
32+
]
33+
}
34+
]
35+
}
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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,110 @@ async def test_search_filter_extension_escape_chars(app_client, ctx):
309309

310310
assert resp.status_code == 200
311311
assert len(resp.json()["features"]) == 1
312+
313+
314+
@pytest.mark.asyncio
315+
async def test_search_filter_extension_in(app_client, ctx):
316+
product_id = ctx.item["properties"]["landsat:product_id"]
317+
318+
params = {
319+
"filter": {
320+
"op": "and",
321+
"args": [
322+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
323+
{
324+
"op": "in",
325+
"args": [
326+
{"property": "properties.landsat:product_id"},
327+
[product_id],
328+
],
329+
},
330+
],
331+
}
332+
}
333+
334+
resp = await app_client.post("/search", json=params)
335+
336+
assert resp.status_code == 200
337+
assert len(resp.json()["features"]) == 1
338+
339+
340+
@pytest.mark.asyncio
341+
async def test_search_filter_extension_in_no_list(app_client, ctx):
342+
product_id = ctx.item["properties"]["landsat:product_id"]
343+
344+
params = {
345+
"filter": {
346+
"op": "and",
347+
"args": [
348+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
349+
{
350+
"op": "in",
351+
"args": [
352+
{"property": "properties.landsat:product_id"},
353+
product_id,
354+
],
355+
},
356+
],
357+
}
358+
}
359+
360+
resp = await app_client.post("/search", json=params)
361+
362+
assert resp.status_code == 400
363+
assert resp.json() == {
364+
"detail": f"Error with cql2_json filter: Arg {product_id} is not a list"
365+
}
366+
367+
368+
@pytest.mark.asyncio
369+
async def test_search_filter_extension_between(app_client, ctx):
370+
sun_elevation = ctx.item["properties"]["view:sun_elevation"]
371+
372+
params = {
373+
"filter": {
374+
"op": "and",
375+
"args": [
376+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
377+
{
378+
"op": "between",
379+
"args": [
380+
{"property": "properties.view:sun_elevation"},
381+
[sun_elevation - 0.01, sun_elevation + 0.01],
382+
],
383+
},
384+
],
385+
}
386+
}
387+
resp = await app_client.post("/search", json=params)
388+
389+
assert resp.status_code == 200
390+
assert len(resp.json()["features"]) == 1
391+
392+
393+
@pytest.mark.asyncio
394+
async def test_search_filter_extension_between_no_list(app_client, ctx):
395+
sun_elevation = ctx.item["properties"]["view:sun_elevation"]
396+
397+
params = {
398+
"filter": {
399+
"op": "and",
400+
"args": [
401+
{"op": "=", "args": [{"property": "id"}, ctx.item["id"]]},
402+
{
403+
"op": "between",
404+
"args": [
405+
{"property": "properties.view:sun_elevation"},
406+
sun_elevation - 0.01,
407+
sun_elevation + 0.01,
408+
],
409+
},
410+
],
411+
}
412+
}
413+
resp = await app_client.post("/search", json=params)
414+
415+
assert resp.status_code == 400
416+
assert resp.json() == {
417+
"detail": f"Error with cql2_json filter: Arg {sun_elevation - 0.01} is not a list"
418+
}

0 commit comments

Comments
 (0)