Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -22,6 +22,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

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