Skip to content

Commit fec12ff

Browse files
committed
add support for more spatial ops
1 parent b8d6c38 commit fec12ff

File tree

3 files changed

+175
-5
lines changed

3 files changed

+175
-5
lines changed

.github/workflows/cicd.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
branches:
66
- main
7+
- spatial-ops
78
pull_request:
89
branches:
910
- main

stac_fastapi/core/stac_fastapi/core/extensions/filter.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# defines the LIKE, IN, and BETWEEN operators.
1111

1212
# Basic Spatial Operators (http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators)
13-
# defines the intersects operator (S_INTERSECTS).
13+
# defines spatial operators (S_INTERSECTS, S_CONTAINS, S_WITHIN, S_DISJOINT).
1414
# """
1515

1616
import re
@@ -82,10 +82,13 @@ class AdvancedComparisonOp(str, Enum):
8282
IN = "in"
8383

8484

85-
class SpatialIntersectsOp(str, Enum):
86-
"""Enumeration for spatial intersection operator as per CQL2 standards."""
85+
class SpatialOp(str, Enum):
86+
"""Enumeration for spatial operators as per CQL2 standards."""
8787

8888
S_INTERSECTS = "s_intersects"
89+
S_CONTAINS = "s_contains"
90+
S_WITHIN = "s_within"
91+
S_DISJOINT = "s_disjoint"
8992

9093

9194
queryables_mapping = {
@@ -194,9 +197,18 @@ def to_es(query: Dict[str, Any]) -> Dict[str, Any]:
194197
pattern = cql2_like_to_es(query["args"][1])
195198
return {"wildcard": {field: {"value": pattern, "case_insensitive": True}}}
196199

197-
elif query["op"] == SpatialIntersectsOp.S_INTERSECTS:
200+
elif query["op"] in [SpatialOp.S_INTERSECTS, SpatialOp.S_CONTAINS, SpatialOp.S_WITHIN, SpatialOp.S_DISJOINT]:
198201
field = to_es_field(query["args"][0]["property"])
199202
geometry = query["args"][1]
200-
return {"geo_shape": {field: {"shape": geometry, "relation": "intersects"}}}
203+
204+
relation_mapping = {
205+
SpatialOp.S_INTERSECTS: "intersects",
206+
SpatialOp.S_CONTAINS: "contains",
207+
SpatialOp.S_WITHIN: "within",
208+
SpatialOp.S_DISJOINT: "disjoint"
209+
}
210+
211+
relation = relation_mapping[query["op"]]
212+
return {"geo_shape": {field: {"shape": geometry, "relation": relation}}}
201213

202214
return {}

stac_fastapi/tests/extensions/test_filter.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,160 @@ async def test_search_filter_extension_isnull_get(app_client, ctx):
481481

482482
assert resp.status_code == 200
483483
assert len(resp.json()["features"]) == 1
484+
485+
async def test_search_filter_extension_s_contains_property(app_client, ctx):
486+
contains_geom = {
487+
"coordinates": [-81.29499784193122, 35.0538290963502],
488+
"type": "Point",
489+
}
490+
params = {
491+
"filter": {
492+
"op": "s_contains",
493+
"args": [
494+
{"property": "properties.foo__geog"},
495+
contains_geom,
496+
],
497+
},
498+
}
499+
resp = await app_client.post("/search", json=params)
500+
assert resp.status_code == 200
501+
resp_json = resp.json()
502+
assert len(resp_json["features"]) == 1
503+
504+
505+
async def test_search_filter_extension_s_intersects_property(app_client, ctx):
506+
intersecting_geom = {
507+
"coordinates": [-81.29499784193122, 35.0538290963502],
508+
"type": "Point",
509+
}
510+
params = {
511+
"filter": {
512+
"op": "s_intersects",
513+
"args": [
514+
{"property": "properties.foo__geog"},
515+
intersecting_geom,
516+
],
517+
},
518+
}
519+
resp = await app_client.post("/search", json=params)
520+
assert resp.status_code == 200
521+
resp_json = resp.json()
522+
assert len(resp_json["features"]) == 1
523+
524+
525+
async def test_search_filter_extension_s_contains_property(app_client, ctx):
526+
contains_geom = {
527+
"coordinates": [-81.29499784193122, 35.0538290963502],
528+
"type": "Point",
529+
}
530+
params = {
531+
"filter": {
532+
"op": "s_contains",
533+
"args": [
534+
{"property": "properties.foo__geog"},
535+
contains_geom,
536+
],
537+
},
538+
}
539+
resp = await app_client.post("/search", json=params)
540+
assert resp.status_code == 200
541+
resp_json = resp.json()
542+
assert len(resp_json["features"]) == 1
543+
544+
545+
async def test_search_filter_extension_s_within_property(app_client, ctx):
546+
within_geom = {
547+
"coordinates": [
548+
[
549+
[-81.29599784193122, 35.06440047386145],
550+
[-81.29599784193122, 35.0528290963502],
551+
[-81.27041291067454, 35.0528290963502],
552+
[-81.27041291067454, 35.0640047386145],
553+
[-81.29599784193122, 35.06440047386145],
554+
]
555+
],
556+
"type": "Polygon",
557+
}
558+
params = {
559+
"filter": {
560+
"op": "s_within",
561+
"args": [
562+
{"property": "properties.foo__geog"},
563+
within_geom,
564+
],
565+
},
566+
}
567+
resp = await app_client.post("/search", json=params)
568+
assert resp.status_code == 200
569+
resp_json = resp.json()
570+
assert len(resp_json["features"]) == 1
571+
572+
573+
async def test_search_filter_extension_s_disjoint_property(app_client, ctx):
574+
intersecting_geom = {
575+
"coordinates": [0, 0],
576+
"type": "Point",
577+
}
578+
params = {
579+
"filter": {
580+
"op": "s_disjoint",
581+
"args": [
582+
{"property": "properties.foo__geog"},
583+
intersecting_geom,
584+
],
585+
},
586+
}
587+
resp = await app_client.post("/search", json=params)
588+
assert resp.status_code == 200
589+
resp_json = resp.json()
590+
assert len(resp_json["features"]) == 1
591+
592+
593+
async def test_search_filter_extension_cql2text_s_intersects_property(app_client, ctx):
594+
filter = 'S_INTERSECTS("properties.foo__geog",POINT(-81.29499784193122 35.0538290963502))'
595+
params = {
596+
"filter": filter,
597+
"filter_lang": "cql2-text",
598+
}
599+
resp = await app_client.post("/search", json=params)
600+
assert resp.status_code == 200
601+
resp_json = resp.json()
602+
assert len(resp_json["features"]) == 1
603+
604+
605+
async def test_search_filter_extension_cql2text_s_contains_property(app_client, ctx):
606+
filter = (
607+
'S_CONTAINS("properties.foo__geog",POINT(-81.29499784193122 35.0538290963502))'
608+
)
609+
params = {
610+
"filter": filter,
611+
"filter_lang": "cql2-text",
612+
}
613+
resp = await app_client.post("/search", json=params)
614+
assert resp.status_code == 200
615+
resp_json = resp.json()
616+
assert len(resp_json["features"]) == 1
617+
618+
619+
async def test_search_filter_extension_cql2text_s_within_property(app_client, ctx):
620+
filter = 'S_WITHIN("properties.foo__geog",POLYGON((-81.29599784193122 35.06440047386145,-81.29599784193122 35.0528290963502,-81.27041291067454 35.0528290963502,-81.27041291067454 35.0640047386145,-81.29599784193122 35.06440047386145)))'
621+
params = {
622+
"filter": filter,
623+
"filter_lang": "cql2-text",
624+
}
625+
resp = await app_client.post("/search", json=params)
626+
assert resp.status_code == 200
627+
resp_json = resp.json()
628+
assert len(resp_json["features"]) == 1
629+
630+
631+
async def test_search_filter_extension_cql2text_s_disjoint_property(app_client, ctx):
632+
filter = 'S_DISJOINT("properties.foo__geog",POINT(0 0))'
633+
params = {
634+
"filter": filter,
635+
"filter_lang": "cql2-text",
636+
}
637+
resp = await app_client.post("/search", json=params)
638+
assert resp.status_code == 200
639+
resp_json = resp.json()
640+
assert len(resp_json["features"]) == 1

0 commit comments

Comments
 (0)