Skip to content
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
2 changes: 2 additions & 0 deletions docs/index-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Entries in `queryables` and `sortables` must have a corresponding entry in `inde

Each queryable and sortable property must include a list of collections for which the property is queryable or sortable. The `*` wildcard value can be used to indicate all collections. It is **not** currently possible to wildcard partial collection IDs, such as `collection-*`.

`storage_type` **must** reference a valid [DuckDB data type](https://duckdb.org/docs/stable/sql/data_types/overview.html).

### Queryables

Queryables require a `json_schema` property containing a schema that could be used to validate values of this property. This JSON schema is not used directly by the API but is provided to API clients via the `/queryables` endpoints such that a client can validate any value it intends to send as query value for this property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def add_items_columns(config: IndexConfig, connection: DuckDBPyConnection) -> No
connection.execute(
f"""
ALTER TABLE items
ADD COLUMN {indexable.table_column_name} {indexable.storage_type.value}
ADD COLUMN {indexable.table_column_name} {indexable.storage_type}
"""
)

Expand Down Expand Up @@ -57,14 +57,15 @@ def _configure_sortables(config: IndexConfig, connection: DuckDBPyConnection) ->
sortables_collections.append((collection_id, name))
connection.execute(
"""
INSERT INTO sortables (name, description, json_path, items_column)
VALUES (?, ?, ?, ?)
INSERT INTO sortables (name, description, json_path, items_column, json_type)
VALUES (?, ?, ?, ?, ?)
""",
[
name,
indexable.description,
indexable.json_path,
indexable.table_column_name,
indexable.json_type,
],
)
for collection_id, name in sortables_collections:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ CREATE TABLE sortables (
description VARCHAR NOT NULL,
json_path VARCHAR NOT NULL,
items_column VARCHAR,
json_type VARCHAR NOT NULL,
UNIQUE(json_path),
UNIQUE(items_column),
);
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CREATE VIEW sortables_by_collection AS
, s.description
, s.json_path
, COALESCE(s.items_column, s.name) AS items_column
, s.json_type
FROM sortables s
JOIN sortables_collections sc ON s.name == sc.name
UNION
Expand All @@ -12,6 +13,7 @@ CREATE VIEW sortables_by_collection AS
, s.description
, s.json_path
, COALESCE(s.items_column, s.name) AS items_column
, s.json_type
FROM sortables s
JOIN (
SELECT sac.name
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
INSERT INTO sortables (name, description, json_path, items_column) VALUES
('id', 'Item ID', 'id', NULL),
('collection', 'Collection ID', 'collection', 'collection_id'),
('datetime', 'Datetime, NULL if datetime is a range', 'datetime', NULL),
('start_datetime', 'Start datetime if datetime is a range, NULL if not', 'start_datetime', NULL),
('end_datetime', 'End datetime if datetime is a range, NULL if not', 'end_datetime', NULL),
INSERT INTO sortables (name, description, json_path, items_column, json_type) VALUES
('id', 'Item ID', 'id', NULL, 'string'),
('collection', 'Collection ID', 'collection', 'collection_id', 'string'),
('datetime', 'Datetime, NULL if datetime is a range', 'datetime', NULL, 'string'),
('start_datetime', 'Start datetime if datetime is a range, NULL if not', 'start_datetime', NULL, 'string'),
('end_datetime', 'End datetime if datetime is a range, NULL if not', 'end_datetime', NULL, 'string'),
;
33 changes: 24 additions & 9 deletions packages/stac-index/src/stac_index/indexer/types/index_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from enum import Enum
from re import sub
from typing import Any, Dict, Final, List, Optional
from re import IGNORECASE, match, sub
from typing import Any, Dict, Final, List, Optional, Self

from pydantic import BaseModel

Expand All @@ -11,20 +10,36 @@ class StorageTypeProperties(BaseModel):
pass


class StorageType(str, Enum):
DOUBLE = "DOUBLE"


class Indexable(BaseModel):
json_path: str
description: str
storage_type: StorageType
# storage_type is required to match the name of a valid DuckDB type as per https://duckdb.org/docs/stable/sql/data_types/overview.html.
# This could be an enum for more strict control, but this enum would require ongoing maintenance to match DuckDB's types.
# If a caller provided an invalid enum value it would result in a runtime failure from this class, which is
# functionally no different to a runtime failure when DuckDB attempts to add a table column of this type.
# Don't attempt to validate that storage_type is a valid DuckDB type and provide documentation around this.
storage_type: str
storage_type_properties: Optional[StorageTypeProperties] = None

@property
def table_column_name(self) -> str:
def table_column_name(self: Self) -> str:
return "i_{}".format(sub("[^A-Za-z0-9]", "_", self.json_path))

@property
def json_type(self: Self) -> str:
if self.storage_type in (
"JSON",
"UUID",
"VARCHAR",
):
return "string"
elif match("^(DATE|TIME)", self.storage_type, flags=IGNORECASE):
return "string"
elif self.storage_type == "BOOLEAN":
return "boolean"
else:
return "number"


class Queryable(BaseModel):
json_schema: Dict[str, Any]
Expand Down
21 changes: 15 additions & 6 deletions src/stac_fastapi/indexed/sortables/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
from typing import Final, List
from typing import Any, Callable, Final, Self, cast

from pydantic import BaseModel
from pydantic import BaseModel, model_serializer


class SortableField(BaseModel):
title: str
description: str
type: str


class SortablesResponse(BaseModel):
title: Final[str] = "STAC Sortables"
fields: List[SortableField]
title: Final[str] = "Sortables"
properties: dict[str, SortableField]

@model_serializer(mode="wrap")
def serialize_model(self: Self, serializer: Callable) -> dict[str, Any]:
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/sortables",
"title": "Sortables",
"type": "object",
**cast(dict[str, Any], serializer(self)),
}
18 changes: 6 additions & 12 deletions src/stac_fastapi/indexed/sortables/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,11 @@ def add_routes(app: FastAPI) -> None:
)
async def get_all_sortables() -> SortablesResponse:
return SortablesResponse(
fields=[
SortableField(
title=config.name,
description=config.description,
)
properties={
config.name: SortableField(type=config.type)
for config in await get_sortable_configs()
if config.collection_id == collection_wildcard
]
}
)

@app.get(
Expand All @@ -36,12 +33,9 @@ async def get_all_sortables() -> SortablesResponse:
)
async def get_collection_sortables(collection_id: str) -> SortablesResponse:
return SortablesResponse(
fields=[
SortableField(
title=config.name,
description=config.description,
)
properties={
config.name: SortableField(type=config.type)
for config in await get_sortable_configs()
if config.collection_id in [collection_id, collection_wildcard]
]
}
)
5 changes: 4 additions & 1 deletion src/stac_fastapi/indexed/sortables/sortable_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
_logger: Final[Logger] = getLogger(__name__)


@dataclass
@dataclass(kw_only=True)
class SortableConfig:
name: str
type: str
collection_id: str
description: str
items_column: str
Expand All @@ -31,13 +32,15 @@ async def _get_sortable_configs(_: str) -> List[SortableConfig]:
collection_id=row[1],
description=row[2],
items_column=row[3],
type=row[4],
)
for row in await fetchall(
f"""
SELECT name
, collection_id
, description
, items_column
, json_type
FROM {format_query_object_name('sortables_by_collection')}
"""
)
Expand Down
6 changes: 4 additions & 2 deletions tests/with_environment/integration_tests/test_get_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,10 @@ def test_get_search_token_immutable() -> None:

def test_get_search_alternate_order() -> None:
sortable_field_names: List[str] = [
entry["title"]
for entry in requests.get(f"{api_base_url}/sortables").json()["fields"]
entry
for entry in requests.get(f"{api_base_url}/sortables")
.json()["properties"]
.keys()
]
assert "datetime" in sortable_field_names
assert "id" in sortable_field_names
Expand Down
6 changes: 4 additions & 2 deletions tests/with_environment/integration_tests/test_post_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,10 @@ def test_post_search_token_immutable() -> None:

def test_post_search_alternate_order() -> None:
sortable_field_names: List[str] = [
entry["title"]
for entry in requests.get(f"{api_base_url}/sortables").json()["fields"]
entry
for entry in requests.get(f"{api_base_url}/sortables")
.json()["properties"]
.keys()
]
assert "datetime" in sortable_field_names
assert "id" in sortable_field_names
Expand Down
16 changes: 10 additions & 6 deletions tests/with_environment/integration_tests/test_sortables.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ def setup_module():

def test_sortables_all_collections() -> None:
sortable_property_titles = [
entry["title"]
for entry in requests.get(f"{api_base_url}/sortables").json()["fields"]
entry
for entry in requests.get(f"{api_base_url}/sortables")
.json()["properties"]
.keys()
]
assert (
len(set(_global_sortable_titles).difference(set(sortable_property_titles))) == 0
Expand All @@ -37,14 +39,16 @@ def test_sortables_by_collection() -> None:
for collection_id in queryable["collections"]:
if collection_id not in sortables_by_collection:
sortables_by_collection[collection_id] = []
sortables_by_collection[collection_id].append(name)
sortables_by_collection[collection_id].append(name)
for collection_id, sortable_names in sortables_by_collection.items():
expected_sortables = sortable_names
collection_sortable_property_titles = [
entry["title"]
for entry in requests.get(
name
for name in requests.get(
f"{api_base_url}/collections/{collection_id}/sortables"
).json()["fields"]
)
.json()["properties"]
.keys()
]
assert (
len(
Expand Down