Skip to content

Commit c1d9ca8

Browse files
Add support for S_CONTAINS, S_WITHIN, S_DISJOINT spatial operations (#372)
**Related Issue(s):** - #371 **Description:** Add support for `S_CONTAINS`, `S_WITHIN`, `S_DISJOINT` spatial operations. **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog --------- Co-authored-by: Jonathan Healy <[email protected]>
1 parent f3ac7da commit c1d9ca8

File tree

4 files changed

+171
-5
lines changed

4 files changed

+171
-5
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Added
1111

1212
- Added configurable landing page ID `STAC_FASTAPI_LANDING_PAGE_ID` [#352](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/352)
13+
- Added support for `S_CONTAINS`, `S_WITHIN`, `S_DISJOINT` spatial filter operations [#371](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/371)
1314
- Introduced the `DATABASE_REFRESH` environment variable to control whether database operations refresh the index immediately after changes. If set to `true`, changes will be immediately searchable. If set to `false`, changes may not be immediately visible but can improve performance for bulk operations. If set to `wait_for`, changes will wait for the next refresh cycle to become visible. [#370](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/370)
1415

1516
### Changed

stac_fastapi/core/pytest.ini

+4
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

+22-5
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

+144
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,147 @@ 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": [
532+
[
533+
[148.5776607193635, -35.257132625788756],
534+
[153.15052873427666, -35.257132625788756],
535+
[153.15052873427666, -31.080816742218623],
536+
[148.5776607193635, -31.080816742218623],
537+
[148.5776607193635, -35.257132625788756],
538+
]
539+
],
540+
"type": "Polygon",
541+
}
542+
params = {
543+
"filter": {
544+
"op": "s_within",
545+
"args": [
546+
{"property": "geometry"},
547+
within_geom,
548+
],
549+
},
550+
}
551+
resp = await app_client.post("/search", json=params)
552+
assert resp.status_code == 200
553+
resp_json = resp.json()
554+
assert len(resp_json["features"]) == 1
555+
556+
557+
@pytest.mark.asyncio
558+
async def test_search_filter_extension_s_disjoint_property(app_client, ctx):
559+
intersecting_geom = {
560+
"coordinates": [0, 0],
561+
"type": "Point",
562+
}
563+
params = {
564+
"filter": {
565+
"op": "s_disjoint",
566+
"args": [
567+
{"property": "geometry"},
568+
intersecting_geom,
569+
],
570+
},
571+
}
572+
resp = await app_client.post("/search", json=params)
573+
assert resp.status_code == 200
574+
resp_json = resp.json()
575+
assert len(resp_json["features"]) == 1
576+
577+
578+
@pytest.mark.asyncio
579+
async def test_search_filter_extension_cql2text_s_intersects_property(app_client, ctx):
580+
filter = 'S_INTERSECTS("geometry",POINT(150.04 -33.14))'
581+
params = {
582+
"filter": filter,
583+
"filter_lang": "cql2-text",
584+
}
585+
resp = await app_client.get("/search", params=params)
586+
assert resp.status_code == 200
587+
resp_json = resp.json()
588+
assert len(resp_json["features"]) == 1
589+
590+
591+
@pytest.mark.asyncio
592+
async def test_search_filter_extension_cql2text_s_contains_property(app_client, ctx):
593+
filter = 'S_CONTAINS("geometry",POINT(150.04 -33.14))'
594+
params = {
595+
"filter": filter,
596+
"filter_lang": "cql2-text",
597+
}
598+
resp = await app_client.get("/search", params=params)
599+
assert resp.status_code == 200
600+
resp_json = resp.json()
601+
assert len(resp_json["features"]) == 1
602+
603+
604+
@pytest.mark.asyncio
605+
async def test_search_filter_extension_cql2text_s_within_property(app_client, ctx):
606+
filter = 'S_WITHIN("geometry",POLYGON((148.5776607193635 -35.257132625788756, 153.15052873427666 -35.257132625788756, 153.15052873427666 -31.080816742218623, 148.5776607193635 -31.080816742218623, 148.5776607193635 -35.257132625788756)))'
607+
params = {
608+
"filter": filter,
609+
"filter_lang": "cql2-text",
610+
}
611+
resp = await app_client.get("/search", params=params)
612+
assert resp.status_code == 200
613+
resp_json = resp.json()
614+
assert len(resp_json["features"]) == 1
615+
616+
617+
@pytest.mark.asyncio
618+
async def test_search_filter_extension_cql2text_s_disjoint_property(app_client, ctx):
619+
filter = 'S_DISJOINT("geometry",POINT(0 0))'
620+
params = {
621+
"filter": filter,
622+
"filter_lang": "cql2-text",
623+
}
624+
resp = await app_client.get("/search", params=params)
625+
assert resp.status_code == 200
626+
resp_json = resp.json()
627+
assert len(resp_json["features"]) == 1

0 commit comments

Comments
 (0)