Skip to content

Commit 81015a1

Browse files
bitnerlossyrob
andauthored
Pgstac cql2text (#346)
* add support for cql2-text with pgstac * update changelog * peg pygeofilter tag, remove comments in docker-compose Co-authored-by: Rob Emanuele <[email protected]>
1 parent 6c22bad commit 81015a1

File tree

9 files changed

+188
-33
lines changed

9 files changed

+188
-33
lines changed

CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@
1111
### Changed
1212

1313
* update FastAPI requirement to allow version >=0.73 ([#337](https://github.com/stac-utils/stac-fastapi/pull/337))
14+
* Bump version of PGStac to 0.4.5 ([#346](https://github.com/stac-utils/stac-fastapi/pull/346))
15+
* Add support for PGStac Backend to use PyGeofilter to convert Get Request with cql2-text into cql2-json to send to PGStac backend ([#346](https://github.com/stac-utils/stac-fastapi/pull/346))
1416

1517
### Removed
1618

1719
### Fixed
1820
* Bumped uvicorn version to 0.17 (from >=0.12, <=0.14) to resolve security vulnerability related to websockets dependency version ([#343](https://github.com/stac-utils/stac-fastapi/pull/343))
1921
* `AttributeError` and/or missing properties when requesting the complete `properties`-field in searches. Added test. ([#339](https://github.com/stac-utils/stac-fastapi/pull/339))
22+
* Fixes issues (and adds tests) for issues caused by regression in pgstac ([#345](https://github.com/stac-utils/stac-fastapi/issues/345)
2023

2124

2225
## [2.3.0]

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ test-sqlalchemy: run-joplin-sqlalchemy
4040

4141
.PHONY: test-pgstac
4242
test-pgstac:
43-
$(run_pgstac) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/pgstac/tests/ && pytest'
43+
$(run_pgstac) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/pgstac/tests/ && pytest -vvv --asyncio-mode=auto'
4444

4545
.PHONY: run-database
4646
run-database:

docker-compose.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@ services:
3232
app-pgstac:
3333
container_name: stac-fastapi-pgstac
3434
image: stac-utils/stac-fastapi
35-
build:
36-
context: .
37-
dockerfile: Dockerfile
3835
platform: linux/amd64
3936
environment:
4037
- APP_HOST=0.0.0.0
@@ -65,7 +62,7 @@ services:
6562

6663
database:
6764
container_name: stac-db
68-
image: ghcr.io/stac-utils/pgstac:v0.4.3
65+
image: ghcr.io/stac-utils/pgstac:v0.4.5
6966
environment:
7067
- POSTGRES_USER=username
7168
- POSTGRES_PASSWORD=password

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,21 @@ class FilterLang(str, Enum):
2020

2121
cql_json = "cql-json"
2222
cql2_json = "cql2-json"
23-
cql_text = "cql-text"
23+
cql2_text = "cql2-text"
2424

2525

2626
@attr.s
2727
class FilterExtensionGetRequest(APIRequest):
2828
"""Filter extension GET request model."""
2929

3030
filter: Optional[str] = attr.ib(default=None)
31+
filter_crs: Optional[str] = Field(alias="filter-crs", default=None)
32+
filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-text")
3133

3234

3335
class FilterExtensionPostRequest(BaseModel):
3436
"""Filter extension POST request model."""
3537

3638
filter: Optional[Dict[str, Any]] = None
3739
filter_crs: Optional[str] = Field(alias="filter-crs", default=None)
38-
filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default=None)
40+
filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql-json")

stac_fastapi/pgstac/setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"asyncpg",
1717
"buildpg",
1818
"brotli_asgi",
19+
"pygeofilter @ git+https://github.com/geopython/[email protected]#egg=pygeofilter",
1920
]
2021

2122
extra_reqs = {
@@ -25,9 +26,8 @@
2526
"pytest-asyncio",
2627
"pre-commit",
2728
"requests",
28-
"pypgstac==0.4.3",
29+
"pypgstac==0.4.5",
2930
"httpx",
30-
"shapely",
3131
],
3232
"docs": ["mkdocs", "mkdocs-material", "pdocs"],
3333
"server": ["uvicorn[standard]==0.17.0"],
@@ -49,7 +49,7 @@
4949
"License :: OSI Approved :: MIT License",
5050
],
5151
keywords="STAC FastAPI COG",
52-
author=u"David Bitner",
52+
author="David Bitner",
5353
author_email="[email protected]",
5454
url="https://github.com/stac-utils/stac-fastapi",
5555
license="MIT",

stac_fastapi/pgstac/stac_fastapi/pgstac/core.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from buildpg import render
1111
from fastapi import HTTPException
1212
from pydantic import ValidationError
13+
from pygeofilter.backends.cql2_json import to_cql2
14+
from pygeofilter.parsers.cql2_text import parse as parse_cql2_text
1315
from stac_pydantic.links import Relations
1416
from stac_pydantic.shared import MimeTypes
1517
from starlette.requests import Request
@@ -256,6 +258,8 @@ async def get_search(
256258
token: Optional[str] = None,
257259
fields: Optional[List[str]] = None,
258260
sortby: Optional[str] = None,
261+
filter: Optional[str] = None,
262+
filter_lang: Optional[str] = None,
259263
**kwargs,
260264
) -> ItemCollection:
261265
"""Cross catalog search (GET).
@@ -265,6 +269,15 @@ async def get_search(
265269
Returns:
266270
ItemCollection containing items which match the search criteria.
267271
"""
272+
request = kwargs["request"]
273+
query_params = str(request.query_params)
274+
275+
# Kludgy fix because using factory does not allow alias for filter-lang
276+
if filter_lang is None:
277+
match = re.search(r"filter-lang=([a-z0-9-]+)", query_params, re.IGNORECASE)
278+
if match:
279+
filter_lang = match.group(1)
280+
268281
# Parse request parameters
269282
base_args = {
270283
"collections": collections,
@@ -275,6 +288,12 @@ async def get_search(
275288
"query": orjson.loads(query) if query else query,
276289
}
277290

291+
if filter:
292+
if filter_lang == "cql2-text":
293+
ast = parse_cql2_text(filter)
294+
base_args["filter"] = orjson.loads(to_cql2(ast))
295+
base_args["filter-lang"] = "cql2-json"
296+
278297
if datetime:
279298
base_args["datetime"] = datetime
280299

@@ -304,9 +323,17 @@ async def get_search(
304323
includes.add(field)
305324
base_args["fields"] = {"include": includes, "exclude": excludes}
306325

326+
# Remove None values from dict
327+
clean = {}
328+
for k, v in base_args.items():
329+
if v is not None and v != []:
330+
clean[k] = v
331+
307332
# Do the request
308333
try:
309-
search_request = self.post_request_model(**base_args)
310-
except ValidationError:
311-
raise HTTPException(status_code=400, detail="Invalid parameters provided")
334+
search_request = self.post_request_model(**clean)
335+
except ValidationError as e:
336+
raise HTTPException(
337+
status_code=400, detail=f"Invalid parameters provided {e}"
338+
)
312339
return await self.post_search(search_request, request=kwargs["request"])

stac_fastapi/pgstac/stac_fastapi/pgstac/types/search.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,10 @@ class PgstacSearch(BaseSearchPostRequest):
1717

1818
@validator("filter_lang", pre=False, check_fields=False, always=True)
1919
def validate_query_uses_cql(cls, v, values):
20-
"""If using query syntax, forces cql-json."""
21-
retval = v
22-
if values.get("query", None) is not None:
23-
retval = "cql-json"
24-
if values.get("collections", None) is not None:
25-
retval = "cql-json"
26-
if values.get("ids", None) is not None:
27-
retval = "cql-json"
28-
if values.get("datetime", None) is not None:
29-
retval = "cql-json"
30-
if values.get("bbox", None) is not None:
31-
retval = "cql-json"
32-
if v == "cql2-json" and retval == "cql-json":
20+
"""Use of Query Extension is not allowed with cql2."""
21+
if values.get("query", None) is not None and v != "cql-json":
3322
raise ValueError(
34-
"query, collections, ids, datetime, and bbox"
35-
"parameters are not available in cql2-json"
23+
"Query extension is not available when using pgstac with cql2"
3624
)
37-
return retval
25+
26+
return v

stac_fastapi/pgstac/tests/clients/test_postgres.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ async def test_delete_item(app_client, load_test_collection, load_test_item):
9797
item = load_test_item
9898

9999
resp = await app_client.delete(f"/collections/{coll.id}/items/{item.id}")
100-
print(resp.content)
101100
assert resp.status_code == 200
102101

103102
resp = await app_client.get(f"/collections/{coll.id}/items/{item.id}")

stac_fastapi/pgstac/tests/resources/test_item.py

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,98 @@ async def test_item_search_get_filter_extension_cql(
810810
)
811811

812812

813+
@pytest.mark.asyncio
814+
async def test_item_search_get_filter_extension_cql2(
815+
app_client, load_test_data, load_test_collection
816+
):
817+
"""Test GET search with JSONB query (cql2 json filter extension)"""
818+
test_item = load_test_data("test_item.json")
819+
resp = await app_client.post(
820+
f"/collections/{test_item['collection']}/items", json=test_item
821+
)
822+
assert resp.status_code == 200
823+
824+
second_test_item = load_test_data("test_item2.json")
825+
resp = await app_client.post(
826+
f"/collections/{test_item['collection']}/items", json=second_test_item
827+
)
828+
assert resp.status_code == 200
829+
830+
# EPSG is a JSONB key
831+
params = {
832+
"collections": [test_item["collection"]],
833+
"filter-lang": "cql2-json",
834+
"filter": {
835+
"op": "gt",
836+
"args": [
837+
{"property": "proj:epsg"},
838+
test_item["properties"]["proj:epsg"] + 1,
839+
],
840+
},
841+
}
842+
print(params)
843+
resp = await app_client.post("/search", json=params)
844+
resp_json = resp.json()
845+
846+
assert resp.status_code == 200
847+
assert len(resp_json.get("features")) == 0
848+
849+
params = {
850+
"collections": [test_item["collection"]],
851+
"filter-lang": "cql2-json",
852+
"filter": {
853+
"op": "eq",
854+
"args": [
855+
{"property": "proj:epsg"},
856+
test_item["properties"]["proj:epsg"],
857+
],
858+
},
859+
}
860+
resp = await app_client.post("/search", json=params)
861+
resp_json = resp.json()
862+
assert len(resp.json()["features"]) == 1
863+
assert (
864+
resp_json["features"][0]["properties"]["proj:epsg"]
865+
== test_item["properties"]["proj:epsg"]
866+
)
867+
868+
869+
@pytest.mark.asyncio
870+
async def test_item_search_get_filter_extension_cql2_with_query_fails(
871+
app_client, load_test_data, load_test_collection
872+
):
873+
"""Test GET search with JSONB query (cql2 json filter extension)"""
874+
test_item = load_test_data("test_item.json")
875+
resp = await app_client.post(
876+
f"/collections/{test_item['collection']}/items", json=test_item
877+
)
878+
assert resp.status_code == 200
879+
880+
second_test_item = load_test_data("test_item2.json")
881+
resp = await app_client.post(
882+
f"/collections/{test_item['collection']}/items", json=second_test_item
883+
)
884+
assert resp.status_code == 200
885+
886+
# EPSG is a JSONB key
887+
params = {
888+
"collections": [test_item["collection"]],
889+
"filter-lang": "cql2-json",
890+
"filter": {
891+
"op": "gt",
892+
"args": [
893+
{"property": "proj:epsg"},
894+
test_item["properties"]["proj:epsg"] + 1,
895+
],
896+
},
897+
"query": {"eo:cloud_cover": {"eq": 0}},
898+
}
899+
print(params)
900+
resp = await app_client.post("/search", json=params)
901+
print(resp.content)
902+
assert resp.status_code == 400
903+
904+
813905
@pytest.mark.asyncio
814906
async def test_get_missing_item_collection(app_client):
815907
"""Test reading a collection which does not exist"""
@@ -888,14 +980,20 @@ async def test_pagination_post(app_client, load_test_data, load_test_collection)
888980
ids.append(uid)
889981

890982
# Paginate through all 5 items with a limit of 1 (expecting 5 requests)
891-
request_body = {"ids": ids, "limit": 1}
983+
request_body = {
984+
"filter-lang": "cql2-json",
985+
"filter": {"op": "in", "args": [{"property": "id"}, ids]},
986+
"limit": 1,
987+
}
988+
print(f"REQUEST BODY: {request_body}")
892989
page = await app_client.post("/search", json=request_body)
893990
idx = 0
894991
item_ids = []
895992
while True:
896993
idx += 1
897994
page_data = page.json()
898995
item_ids.append(page_data["features"][0]["id"])
996+
print(f"PAGING: {page_data['links']}")
899997
next_link = list(filter(lambda l: l["rel"] == "next", page_data["links"]))
900998
if not next_link:
901999
break
@@ -907,6 +1005,7 @@ async def test_pagination_post(app_client, load_test_data, load_test_collection)
9071005
assert False
9081006

9091007
# Our limit is 1 so we expect len(ids) number of requests before we run out of pages
1008+
print(idx, ids)
9101009
assert idx == len(ids)
9111010

9121011
# Confirm we have paginated through all items
@@ -931,8 +1030,16 @@ async def test_pagination_token_idempotent(
9311030
assert resp.status_code == 200
9321031
ids.append(uid)
9331032

934-
page = await app_client.get("/search", params={"ids": ",".join(ids), "limit": 3})
1033+
page = await app_client.post(
1034+
"/search",
1035+
json={
1036+
"filter-lang": "cql2-json",
1037+
"filter": {"op": "in", "args": [{"property": "id"}, ids]},
1038+
"limit": 3,
1039+
},
1040+
)
9351041
page_data = page.json()
1042+
print(f"LINKS: {page_data['links']}")
9361043
next_link = list(filter(lambda l: l["rel"] == "next", page_data["links"]))
9371044

9381045
# Confirm token is idempotent
@@ -1161,7 +1268,7 @@ async def test_item_search_get_filter_extension_cql_explicitlang(
11611268

11621269

11631270
@pytest.mark.asyncio
1164-
async def test_item_search_get_filter_extension_cql2(
1271+
async def test_item_search_get_filter_extension_cql2_2(
11651272
app_client, load_test_data, load_test_collection
11661273
):
11671274
"""Test GET search with JSONB query (cql json filter extension)"""
@@ -1253,3 +1360,34 @@ async def test_search_datetime_validation_errors(app_client):
12531360

12541361
resp = await app_client.get("/search?datetime={}".format(dt))
12551362
assert resp.status_code == 400
1363+
1364+
1365+
@pytest.mark.asyncio
1366+
async def test_filter_cql2text(app_client, load_test_data, load_test_collection):
1367+
"""Test GET search with cql2-text"""
1368+
test_item = load_test_data("test_item.json")
1369+
resp = await app_client.post(
1370+
f"/collections/{test_item['collection']}/items", json=test_item
1371+
)
1372+
assert resp.status_code == 200
1373+
1374+
epsg = test_item["properties"]["proj:epsg"]
1375+
collection = test_item["collection"]
1376+
1377+
filter = f"proj:epsg={epsg} AND collection = '{collection}'"
1378+
params = {"filter": filter, "filter-lang": "cql2-text"}
1379+
resp = await app_client.get("/search", params=params)
1380+
resp_json = resp.json()
1381+
print(resp_json)
1382+
assert len(resp.json()["features"]) == 1
1383+
assert (
1384+
resp_json["features"][0]["properties"]["proj:epsg"]
1385+
== test_item["properties"]["proj:epsg"]
1386+
)
1387+
1388+
filter = f"proj:epsg={epsg + 1} AND collection = '{collection}'"
1389+
params = {"filter": filter, "filter-lang": "cql2-text"}
1390+
resp = await app_client.get("/search", params=params)
1391+
resp_json = resp.json()
1392+
print(resp_json)
1393+
assert len(resp.json()["features"]) == 0

0 commit comments

Comments
 (0)