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
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: