diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e149b4..3c521dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Added configurable landing page ID `STAC_FASTAPI_LANDING_PAGE_ID` [#352](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/352) +- Added support for `S_CONTAINS`, `S_WITHIN`, `S_DISJOINT` spatial filter operations [#371](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/371) - 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) ### Changed diff --git a/stac_fastapi/core/pytest.ini b/stac_fastapi/core/pytest.ini new file mode 100644 index 00000000..db0353ef --- /dev/null +++ b/stac_fastapi/core/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +addopts = -sv +asyncio_mode = auto \ No newline at end of file diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/filter.py b/stac_fastapi/core/stac_fastapi/core/extensions/filter.py index 251614e1..a74eff99 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/filter.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/filter.py @@ -10,7 +10,7 @@ # 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). +# defines spatial operators (S_INTERSECTS, S_CONTAINS, S_WITHIN, S_DISJOINT). # """ import re @@ -82,10 +82,13 @@ class AdvancedComparisonOp(str, Enum): IN = "in" -class SpatialIntersectsOp(str, Enum): - """Enumeration for spatial intersection operator as per CQL2 standards.""" +class SpatialOp(str, Enum): + """Enumeration for spatial operators as per CQL2 standards.""" S_INTERSECTS = "s_intersects" + S_CONTAINS = "s_contains" + S_WITHIN = "s_within" + S_DISJOINT = "s_disjoint" queryables_mapping = { @@ -194,9 +197,23 @@ def to_es(query: Dict[str, Any]) -> Dict[str, Any]: pattern = cql2_like_to_es(query["args"][1]) return {"wildcard": {field: {"value": pattern, "case_insensitive": True}}} - elif query["op"] == SpatialIntersectsOp.S_INTERSECTS: + elif query["op"] in [ + SpatialOp.S_INTERSECTS, + SpatialOp.S_CONTAINS, + SpatialOp.S_WITHIN, + SpatialOp.S_DISJOINT, + ]: field = to_es_field(query["args"][0]["property"]) geometry = query["args"][1] - return {"geo_shape": {field: {"shape": geometry, "relation": "intersects"}}} + + relation_mapping = { + SpatialOp.S_INTERSECTS: "intersects", + SpatialOp.S_CONTAINS: "contains", + SpatialOp.S_WITHIN: "within", + SpatialOp.S_DISJOINT: "disjoint", + } + + relation = relation_mapping[query["op"]] + return {"geo_shape": {field: {"shape": geometry, "relation": relation}}} return {} diff --git a/stac_fastapi/tests/extensions/test_filter.py b/stac_fastapi/tests/extensions/test_filter.py index 3102da34..ae355c3a 100644 --- a/stac_fastapi/tests/extensions/test_filter.py +++ b/stac_fastapi/tests/extensions/test_filter.py @@ -481,3 +481,147 @@ async def test_search_filter_extension_isnull_get(app_client, ctx): assert resp.status_code == 200 assert len(resp.json()["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_s_intersects_property(app_client, ctx): + intersecting_geom = { + "coordinates": [150.04, -33.14], + "type": "Point", + } + params = { + "filter": { + "op": "s_intersects", + "args": [ + {"property": "geometry"}, + intersecting_geom, + ], + }, + } + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_s_contains_property(app_client, ctx): + contains_geom = { + "coordinates": [150.04, -33.14], + "type": "Point", + } + params = { + "filter": { + "op": "s_contains", + "args": [ + {"property": "geometry"}, + contains_geom, + ], + }, + } + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_s_within_property(app_client, ctx): + within_geom = { + "coordinates": [ + [ + [148.5776607193635, -35.257132625788756], + [153.15052873427666, -35.257132625788756], + [153.15052873427666, -31.080816742218623], + [148.5776607193635, -31.080816742218623], + [148.5776607193635, -35.257132625788756], + ] + ], + "type": "Polygon", + } + params = { + "filter": { + "op": "s_within", + "args": [ + {"property": "geometry"}, + within_geom, + ], + }, + } + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_s_disjoint_property(app_client, ctx): + intersecting_geom = { + "coordinates": [0, 0], + "type": "Point", + } + params = { + "filter": { + "op": "s_disjoint", + "args": [ + {"property": "geometry"}, + intersecting_geom, + ], + }, + } + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_cql2text_s_intersects_property(app_client, ctx): + filter = 'S_INTERSECTS("geometry",POINT(150.04 -33.14))' + params = { + "filter": filter, + "filter_lang": "cql2-text", + } + resp = await app_client.get("/search", params=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_cql2text_s_contains_property(app_client, ctx): + filter = 'S_CONTAINS("geometry",POINT(150.04 -33.14))' + params = { + "filter": filter, + "filter_lang": "cql2-text", + } + resp = await app_client.get("/search", params=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_cql2text_s_within_property(app_client, ctx): + 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)))' + params = { + "filter": filter, + "filter_lang": "cql2-text", + } + resp = await app_client.get("/search", params=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +async def test_search_filter_extension_cql2text_s_disjoint_property(app_client, ctx): + filter = 'S_DISJOINT("geometry",POINT(0 0))' + params = { + "filter": filter, + "filter_lang": "cql2-text", + } + resp = await app_client.get("/search", params=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 1