From 8f1538131480ccf70342be7a76d5c095503e212e Mon Sep 17 00:00:00 2001 From: Travis Harrison Date: Thu, 8 May 2025 15:10:26 -0500 Subject: [PATCH 1/4] add support for more spatial ops --- CHANGELOG.md | 1 + stac_fastapi/core/pytest.ini | 4 + .../stac_fastapi/core/extensions/filter.py | 27 +++- stac_fastapi/tests/extensions/test_filter.py | 138 ++++++++++++++++++ 4 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 stac_fastapi/core/pytest.ini diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc77391..dc910b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - 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) - 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) - Added code coverage reporting to the test suite using pytest-cov. [#87](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/87) +- Added support for `S_CONTAINS`, `S_WITHIN`, `S_DISJOINT` spatial filter operations [#371](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/371) ### 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..2a56b953 100644 --- a/stac_fastapi/tests/extensions/test_filter.py +++ b/stac_fastapi/tests/extensions/test_filter.py @@ -481,3 +481,141 @@ 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": [[[152.15052873427666, -33.82243006904891], [150.1000346138806, -34.257132625788756], [149.5776607193635, -32.514709769700254], [151.6262528041627, -32.08081674221862], [152.15052873427666, -33.82243006904891]]], + "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((152.15052873427666 -33.82243006904891, 150.1000346138806 -34.257132625788756, 149.5776607193635 -32.514709769700254, 151.6262528041627 -32.08081674221862, 152.15052873427666 -33.82243006904891)))' + 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 From 49e093e3f946f507ca4f17cb6becdbfb38f6378a Mon Sep 17 00:00:00 2001 From: Travis Harrison Date: Thu, 8 May 2025 21:22:12 -0500 Subject: [PATCH 2/4] fix formatting --- stac_fastapi/tests/extensions/test_filter.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/stac_fastapi/tests/extensions/test_filter.py b/stac_fastapi/tests/extensions/test_filter.py index 2a56b953..7ef9a164 100644 --- a/stac_fastapi/tests/extensions/test_filter.py +++ b/stac_fastapi/tests/extensions/test_filter.py @@ -528,7 +528,15 @@ async def test_search_filter_extension_s_contains_property(app_client, ctx): @pytest.mark.asyncio async def test_search_filter_extension_s_within_property(app_client, ctx): within_geom = { - "coordinates": [[[152.15052873427666, -33.82243006904891], [150.1000346138806, -34.257132625788756], [149.5776607193635, -32.514709769700254], [151.6262528041627, -32.08081674221862], [152.15052873427666, -33.82243006904891]]], + "coordinates": [ + [ + [152.15052873427666, -33.82243006904891], + [150.1000346138806, -34.257132625788756], + [149.5776607193635, -32.514709769700254], + [151.6262528041627, -32.08081674221862], + [152.15052873427666, -33.82243006904891], + ] + ], "type": "Polygon", } params = { @@ -582,9 +590,7 @@ async def test_search_filter_extension_cql2text_s_intersects_property(app_client @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))' - ) + filter = 'S_CONTAINS("geometry",POINT(150.04 -33.14))' params = { "filter": filter, "filter_lang": "cql2-text", From 8bbb5b08f06fdc9382717dadcf6739ed3f2b7714 Mon Sep 17 00:00:00 2001 From: Travis Harrison Date: Fri, 9 May 2025 07:17:05 -0500 Subject: [PATCH 3/4] fix within test --- stac_fastapi/tests/extensions/test_filter.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/stac_fastapi/tests/extensions/test_filter.py b/stac_fastapi/tests/extensions/test_filter.py index 7ef9a164..ae355c3a 100644 --- a/stac_fastapi/tests/extensions/test_filter.py +++ b/stac_fastapi/tests/extensions/test_filter.py @@ -530,11 +530,11 @@ async def test_search_filter_extension_s_within_property(app_client, ctx): within_geom = { "coordinates": [ [ - [152.15052873427666, -33.82243006904891], - [150.1000346138806, -34.257132625788756], - [149.5776607193635, -32.514709769700254], - [151.6262528041627, -32.08081674221862], - [152.15052873427666, -33.82243006904891], + [148.5776607193635, -35.257132625788756], + [153.15052873427666, -35.257132625788756], + [153.15052873427666, -31.080816742218623], + [148.5776607193635, -31.080816742218623], + [148.5776607193635, -35.257132625788756], ] ], "type": "Polygon", @@ -603,7 +603,7 @@ async def test_search_filter_extension_cql2text_s_contains_property(app_client, @pytest.mark.asyncio async def test_search_filter_extension_cql2text_s_within_property(app_client, ctx): - 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)))' + 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", From 57e75b68885b861917abebb8b31865fd25e080a3 Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Sat, 10 May 2025 12:07:41 +0800 Subject: [PATCH 4/4] Move changelog entry to unreleased section --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf5f3ad..5b9d4a09 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) ### Changed @@ -22,7 +23,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - 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) - 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) - Added code coverage reporting to the test suite using pytest-cov. [#87](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/87) -- Added support for `S_CONTAINS`, `S_WITHIN`, `S_DISJOINT` spatial filter operations [#371](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/371) ### Changed