Skip to content

Commit 8f15381

Browse files
committed
add support for more spatial ops
1 parent b8d6c38 commit 8f15381

File tree

4 files changed

+165
-5
lines changed

4 files changed

+165
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1212
- Added logging to bulk insertion methods to provide detailed feedback on errors encountered during operations. [#364](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/364)
1313
- Introduced the `RAISE_ON_BULK_ERROR` environment variable to control whether bulk insertion methods raise exceptions on errors (`true`) or log warnings and continue processing (`false`). [#364](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/364)
1414
- Added code coverage reporting to the test suite using pytest-cov. [#87](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/87)
15+
- Added support for `S_CONTAINS`, `S_WITHIN`, `S_DISJOINT` spatial filter operations [#371](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/371)
1516

1617
### Changed
1718

stac_fastapi/core/pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[pytest]
2+
testpaths = tests
3+
addopts = -sv
4+
asyncio_mode = auto

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# defines the LIKE, IN, and BETWEEN operators.
1111

1212
# Basic Spatial Operators (http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators)
13-
# defines the intersects operator (S_INTERSECTS).
13+
# defines spatial operators (S_INTERSECTS, S_CONTAINS, S_WITHIN, S_DISJOINT).
1414
# """
1515

1616
import re
@@ -82,10 +82,13 @@ class AdvancedComparisonOp(str, Enum):
8282
IN = "in"
8383

8484

85-
class SpatialIntersectsOp(str, Enum):
86-
"""Enumeration for spatial intersection operator as per CQL2 standards."""
85+
class SpatialOp(str, Enum):
86+
"""Enumeration for spatial operators as per CQL2 standards."""
8787

8888
S_INTERSECTS = "s_intersects"
89+
S_CONTAINS = "s_contains"
90+
S_WITHIN = "s_within"
91+
S_DISJOINT = "s_disjoint"
8992

9093

9194
queryables_mapping = {
@@ -194,9 +197,23 @@ def to_es(query: Dict[str, Any]) -> Dict[str, Any]:
194197
pattern = cql2_like_to_es(query["args"][1])
195198
return {"wildcard": {field: {"value": pattern, "case_insensitive": True}}}
196199

197-
elif query["op"] == SpatialIntersectsOp.S_INTERSECTS:
200+
elif query["op"] in [
201+
SpatialOp.S_INTERSECTS,
202+
SpatialOp.S_CONTAINS,
203+
SpatialOp.S_WITHIN,
204+
SpatialOp.S_DISJOINT,
205+
]:
198206
field = to_es_field(query["args"][0]["property"])
199207
geometry = query["args"][1]
200-
return {"geo_shape": {field: {"shape": geometry, "relation": "intersects"}}}
208+
209+
relation_mapping = {
210+
SpatialOp.S_INTERSECTS: "intersects",
211+
SpatialOp.S_CONTAINS: "contains",
212+
SpatialOp.S_WITHIN: "within",
213+
SpatialOp.S_DISJOINT: "disjoint",
214+
}
215+
216+
relation = relation_mapping[query["op"]]
217+
return {"geo_shape": {field: {"shape": geometry, "relation": relation}}}
201218

202219
return {}

stac_fastapi/tests/extensions/test_filter.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,141 @@ async def test_search_filter_extension_isnull_get(app_client, ctx):
481481

482482
assert resp.status_code == 200
483483
assert len(resp.json()["features"]) == 1
484+
485+
486+
@pytest.mark.asyncio
487+
async def test_search_filter_extension_s_intersects_property(app_client, ctx):
488+
intersecting_geom = {
489+
"coordinates": [150.04, -33.14],
490+
"type": "Point",
491+
}
492+
params = {
493+
"filter": {
494+
"op": "s_intersects",
495+
"args": [
496+
{"property": "geometry"},
497+
intersecting_geom,
498+
],
499+
},
500+
}
501+
resp = await app_client.post("/search", json=params)
502+
assert resp.status_code == 200
503+
resp_json = resp.json()
504+
assert len(resp_json["features"]) == 1
505+
506+
507+
@pytest.mark.asyncio
508+
async def test_search_filter_extension_s_contains_property(app_client, ctx):
509+
contains_geom = {
510+
"coordinates": [150.04, -33.14],
511+
"type": "Point",
512+
}
513+
params = {
514+
"filter": {
515+
"op": "s_contains",
516+
"args": [
517+
{"property": "geometry"},
518+
contains_geom,
519+
],
520+
},
521+
}
522+
resp = await app_client.post("/search", json=params)
523+
assert resp.status_code == 200
524+
resp_json = resp.json()
525+
assert len(resp_json["features"]) == 1
526+
527+
528+
@pytest.mark.asyncio
529+
async def test_search_filter_extension_s_within_property(app_client, ctx):
530+
within_geom = {
531+
"coordinates": [[[152.15052873427666, -33.82243006904891], [150.1000346138806, -34.257132625788756], [149.5776607193635, -32.514709769700254], [151.6262528041627, -32.08081674221862], [152.15052873427666, -33.82243006904891]]],
532+
"type": "Polygon",
533+
}
534+
params = {
535+
"filter": {
536+
"op": "s_within",
537+
"args": [
538+
{"property": "geometry"},
539+
within_geom,
540+
],
541+
},
542+
}
543+
resp = await app_client.post("/search", json=params)
544+
assert resp.status_code == 200
545+
resp_json = resp.json()
546+
assert len(resp_json["features"]) == 1
547+
548+
549+
@pytest.mark.asyncio
550+
async def test_search_filter_extension_s_disjoint_property(app_client, ctx):
551+
intersecting_geom = {
552+
"coordinates": [0, 0],
553+
"type": "Point",
554+
}
555+
params = {
556+
"filter": {
557+
"op": "s_disjoint",
558+
"args": [
559+
{"property": "geometry"},
560+
intersecting_geom,
561+
],
562+
},
563+
}
564+
resp = await app_client.post("/search", json=params)
565+
assert resp.status_code == 200
566+
resp_json = resp.json()
567+
assert len(resp_json["features"]) == 1
568+
569+
570+
@pytest.mark.asyncio
571+
async def test_search_filter_extension_cql2text_s_intersects_property(app_client, ctx):
572+
filter = 'S_INTERSECTS("geometry",POINT(150.04 -33.14))'
573+
params = {
574+
"filter": filter,
575+
"filter_lang": "cql2-text",
576+
}
577+
resp = await app_client.get("/search", params=params)
578+
assert resp.status_code == 200
579+
resp_json = resp.json()
580+
assert len(resp_json["features"]) == 1
581+
582+
583+
@pytest.mark.asyncio
584+
async def test_search_filter_extension_cql2text_s_contains_property(app_client, ctx):
585+
filter = (
586+
'S_CONTAINS("geometry",POINT(150.04 -33.14))'
587+
)
588+
params = {
589+
"filter": filter,
590+
"filter_lang": "cql2-text",
591+
}
592+
resp = await app_client.get("/search", params=params)
593+
assert resp.status_code == 200
594+
resp_json = resp.json()
595+
assert len(resp_json["features"]) == 1
596+
597+
598+
@pytest.mark.asyncio
599+
async def test_search_filter_extension_cql2text_s_within_property(app_client, ctx):
600+
filter = 'S_WITHIN("geometry",POLYGON((152.15052873427666 -33.82243006904891, 150.1000346138806 -34.257132625788756, 149.5776607193635 -32.514709769700254, 151.6262528041627 -32.08081674221862, 152.15052873427666 -33.82243006904891)))'
601+
params = {
602+
"filter": filter,
603+
"filter_lang": "cql2-text",
604+
}
605+
resp = await app_client.get("/search", params=params)
606+
assert resp.status_code == 200
607+
resp_json = resp.json()
608+
assert len(resp_json["features"]) == 1
609+
610+
611+
@pytest.mark.asyncio
612+
async def test_search_filter_extension_cql2text_s_disjoint_property(app_client, ctx):
613+
filter = 'S_DISJOINT("geometry",POINT(0 0))'
614+
params = {
615+
"filter": filter,
616+
"filter_lang": "cql2-text",
617+
}
618+
resp = await app_client.get("/search", params=params)
619+
assert resp.status_code == 200
620+
resp_json = resp.json()
621+
assert len(resp_json["features"]) == 1

0 commit comments

Comments
 (0)