Skip to content
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

Pgstac v0.4.0b #308

Merged
merged 8 commits into from
Dec 14, 2021
Merged
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ services:

database:
container_name: stac-db
image: ghcr.io/stac-utils/pgstac:v0.3.4
image: ghcr.io/stac-utils/pgstac:v0.4.0
environment:
- POSTGRES_USER=username
- POSTGRES_PASSWORD=password
Expand Down
4 changes: 2 additions & 2 deletions stac_fastapi/api/stac_fastapi/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ def create_get_request_model(


def create_post_request_model(
extensions, base_model: BaseSearchPostRequest = BaseSearchGetRequest
extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest
):
"""Wrap create_request_model to create the POST request model."""
return create_request_model(
"SearchPostRequest",
base_model=BaseSearchPostRequest,
base_model=base_model,
extensions=extensions,
request_type="POST",
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
"""Filter extension request models."""

from enum import Enum
from typing import Any, Dict, Optional

import attr
from pydantic import BaseModel
from pydantic import BaseModel, Field

from stac_fastapi.types.search import APIRequest


class FilterLang(str, Enum):
"""Choices for filter-lang value in a POST request.

Based on https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables

Note the addition of cql2-json, which is used by the pgstac backend,
but is not included in the spec above.
"""

cql_json = "cql-json"
cql2_json = "cql2-json"
cql_text = "cql-text"


@attr.s
class FilterExtensionGetRequest(APIRequest):
"""Filter extension GET request model."""
Expand All @@ -19,3 +34,5 @@ class FilterExtensionPostRequest(BaseModel):
"""Filter extension POST request model."""

filter: Optional[Dict[str, Any]] = None
filter_crs: Optional[str] = Field(alias="filter-crs", default=None)
filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default=None)
2 changes: 1 addition & 1 deletion stac_fastapi/pgstac/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"pytest-asyncio",
"pre-commit",
"requests",
"pypgstac==0.3.4",
"pypgstac==0.4.0",
"httpx",
"shapely",
],
Expand Down
2 changes: 1 addition & 1 deletion stac_fastapi/pgstac/stac_fastapi/pgstac/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ async def _search_base(
pool = request.app.state.readpool

# pool = kwargs["request"].app.state.readpool
req = search_request.json(exclude_none=True)
req = search_request.json(exclude_none=True, by_alias=True)

try:
async with pool.acquire() as conn:
Expand Down
24 changes: 23 additions & 1 deletion stac_fastapi/pgstac/stac_fastapi/pgstac/types/search.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""stac_fastapi.types.search module."""

from typing import Optional
from typing import Dict, Optional

from pydantic import validator

Expand All @@ -14,8 +14,30 @@ class PgstacSearch(BaseSearchPostRequest):
"""

datetime: Optional[str] = None
conf: Optional[Dict] = None

@validator("datetime")
def validate_datetime(cls, v):
"""Pgstac does not require the base validator for datetime."""
return v

@validator("filter_lang", pre=False, check_fields=False, always=True)
def validate_query_uses_cql(cls, v, values):
"""If using query syntax, forces cql-json."""
retval = v
if values.get("query", None) is not None:
retval = "cql-json"
if values.get("collections", None) is not None:
retval = "cql-json"
if values.get("ids", None) is not None:
retval = "cql-json"
if values.get("datetime", None) is not None:
retval = "cql-json"
if values.get("bbox", None) is not None:
retval = "cql-json"
if v == "cql2-json" and retval == "cql-json":
raise ValueError(
"query, collections, ids, datetime, and bbox"
"parameters are not available in cql2-json"
)
return retval
Comment on lines +27 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cql-text and cql-json share the parameters listed here, but if filter_lang is "cql-text", it will be transformed to "cql-json". Is that the intention?

We might be able to condense this and avoid some duplicate code with the following snippet. It does change the logic slightly, though:

  • It will throw if any CQL parameter is provided, even if its value is null. e.g. {"filter-lang": "cql2-json", "query": null} will throw the ValueError. I feel like that is still correct.
  • It will only default to cql-json if filter-lang isn't specified (i.e. it will preserve cql-text)
cql_params = ("query", "collections", "ids", "datetime", "bbox")
if any(cql_param in values for cql_param in cql_params):
    if v == FilterLang.cql2_json:
        raise ValueError(f"{', '.join(invalid_cql2_params[:-1])} and {invalid_cql2_params[-1]} parameters are not available in cql2-json")
    else:
        return v or FilterLang.cql_json  # if `filter-lang` isn't specified and CQL parameters are provided, default to `cql-json`
return v

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PGStac only accepts json, so in the case of a GET request, that request is just modified into CQL json. The ultimate plan (which I will start working on this week, but probably won't finish before the holidays) for stac-fastapi-pgstac will be to add a cql2-json output to pygeofilter and to use pygeofilter to translate cql-text or cql2-text into cql2-json and then send that to pgstac.

14 changes: 12 additions & 2 deletions stac_fastapi/pgstac/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ async def pg():
try:
await conn.execute("CREATE DATABASE pgstactestdb;")
await conn.execute(
"ALTER DATABASE pgstactestdb SET search_path to pgstac, public;"
"""
ALTER DATABASE pgstactestdb SET search_path to pgstac, public;
ALTER DATABASE pgstactestdb SET log_statement to 'all';
"""
)
except asyncpg.exceptions.DuplicateDatabaseError:
await conn.execute("DROP DATABASE pgstactestdb;")
Expand Down Expand Up @@ -78,7 +81,14 @@ async def pgstac(pg):
yield
print("Truncating Data")
conn = await asyncpg.connect(dsn=settings.testing_connection_string)
await conn.execute("TRUNCATE items CASCADE; TRUNCATE collections CASCADE;")
await conn.execute(
"""
TRUNCATE pgstac.items CASCADE;
TRUNCATE pgstac.collections CASCADE;
TRUNCATE pgstac.searches CASCADE;
TRUNCATE pgstac.search_wheres CASCADE;
"""
)
await conn.close()


Expand Down
121 changes: 121 additions & 0 deletions stac_fastapi/pgstac/tests/resources/test_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -1029,3 +1029,124 @@ async def test_preserves_extra_link(
extra_link = [link for link in item["links"] if link["rel"] == "preview"]
assert extra_link
assert extra_link[0]["href"] == expected_href


@pytest.mark.asyncio
async def test_item_search_get_filter_extension_cql_explicitlang(
app_client, load_test_data, load_test_collection
):
"""Test GET search with JSONB query (cql json filter extension)"""
test_item = load_test_data("test_item.json")
resp = await app_client.post(
f"/collections/{test_item['collection']}/items", json=test_item
)
assert resp.status_code == 200

# EPSG is a JSONB key
params = {
"collections": [test_item["collection"]],
"filter-lang": "cql-json",
"filter": {
"gt": [
{"property": "proj:epsg"},
test_item["properties"]["proj:epsg"] + 1,
]
},
}
resp = await app_client.post("/search", json=params)
resp_json = resp.json()

assert resp.status_code == 200
assert len(resp_json.get("features")) == 0

params = {
"collections": [test_item["collection"]],
"filter-lang": "cql-json",
"filter": {
"eq": [
{"property": "proj:epsg"},
test_item["properties"]["proj:epsg"],
]
},
}
resp = await app_client.post("/search", json=params)
resp_json = resp.json()
assert len(resp.json()["features"]) == 1
assert (
resp_json["features"][0]["properties"]["proj:epsg"]
== test_item["properties"]["proj:epsg"]
)


@pytest.mark.asyncio
async def test_item_search_get_filter_extension_cql2(
app_client, load_test_data, load_test_collection
):
"""Test GET search with JSONB query (cql json filter extension)"""
test_item = load_test_data("test_item.json")
resp = await app_client.post(
f"/collections/{test_item['collection']}/items", json=test_item
)
assert resp.status_code == 200

# EPSG is a JSONB key
params = {
"filter-lang": "cql2-json",
"filter": {
"op": "and",
"args": [
{
"op": "eq",
"args": [
{"property": "proj:epsg"},
test_item["properties"]["proj:epsg"] + 1,
],
},
{
"op": "in",
"args": [
{"property": "collection"},
[test_item["collection"]],
],
},
],
},
}
print(json.dumps(params))
resp = await app_client.post("/search", json=params)
resp_json = resp.json()
print(resp_json)

assert resp.status_code == 200
assert len(resp_json.get("features")) == 0

params = {
"filter-lang": "cql2-json",
"filter": {
"op": "and",
"args": [
{
"op": "eq",
"args": [
{"property": "proj:epsg"},
test_item["properties"]["proj:epsg"],
],
},
{
"op": "in",
"args": [
{"property": "collection"},
[test_item["collection"]],
],
},
],
},
}
resp = await app_client.post("/search", json=params)
resp_json = resp.json()
print(resp_json)
assert len(resp.json()["features"]) == 1
assert (
resp_json["features"][0]["properties"]["proj:epsg"]
== test_item["properties"]["proj:epsg"]
)
2 changes: 1 addition & 1 deletion stac_fastapi/types/stac_fastapi/types/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class BaseSearchGetRequest(APIRequest):
ids: Optional[str] = attr.ib(default=None, converter=str2list)
bbox: Optional[str] = attr.ib(default=None, converter=str2list)
intersects: Optional[str] = attr.ib(default=None, converter=str2list)
datetime: Optional[Union[str]] = attr.ib(default=None)
datetime: Optional[Union[str, Dict]] = attr.ib(default=None)
limit: Optional[int] = attr.ib(default=10)


Expand Down