Skip to content

Add support for S_CONTAINS, S_WITHIN, S_DISJOINT spatial operations #372

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions stac_fastapi/core/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
addopts = -sv
asyncio_mode = auto
27 changes: 22 additions & 5 deletions stac_fastapi/core/stac_fastapi/core/extensions/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {}
144 changes: 144 additions & 0 deletions stac_fastapi/tests/extensions/test_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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