Skip to content

Commit 738b59f

Browse files
authored
Merge branch 'main' into get-collections-query
2 parents ebfc053 + 859e456 commit 738b59f

File tree

7 files changed

+202
-7
lines changed

7 files changed

+202
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
- GET `/collections` collection search structured filter extension with support for both cql2-json and cql2-text formats. [#475](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/475)
1313
- GET `/collections` collection search query extension. [#476](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/476)
14+
- GET `/collections` collections search datetime filtering support. [#476](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/476)
1415

1516
### Changed
1617

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,19 @@ SFEOS implements extended capabilities for the `/collections` endpoint, allowing
138138
- Supports both CQL2 JSON and CQL2 text formats with various operators
139139
- Enables precise filtering on any collection property
140140

141-
> **Note on HTTP Methods**: All collection search extensions (sorting, field selection, free text search, and structured filtering) currently only support GET requests. POST requests with these parameters in the request body are not yet supported.
141+
- **Datetime Filtering**: Filter collections by their temporal extent using the `datetime` parameter
142+
- Example: `/collections?datetime=2020-01-01T00:00:00Z/2020-12-31T23:59:59Z` (finds collections with temporal extents that overlap this range)
143+
- Example: `/collections?datetime=2020-06-15T12:00:00Z` (finds collections whose temporal extent includes this specific time)
144+
- Example: `/collections?datetime=2020-01-01T00:00:00Z/..` (finds collections with temporal extents that extend to or beyond January 1, 2020)
145+
- Example: `/collections?datetime=../2020-12-31T23:59:59Z` (finds collections with temporal extents that begin on or before December 31, 2020)
146+
- Collections are matched if their temporal extent overlaps with the provided datetime parameter
147+
- This allows for efficient discovery of collections based on time periods
148+
149+
> **Note on HTTP Methods**: All collection search extensions (sorting, field selection, free text search, structured filtering, and datetime filtering) currently only support GET requests. POST requests with these parameters in the request body are not yet supported.
142150
143151
These extensions make it easier to build user interfaces that display and navigate through collections efficiently.
144152

145-
> **Configuration**: Collection search extensions (sorting, field selection, free text search, and structured filtering) can be disabled by setting the `ENABLE_COLLECTIONS_SEARCH` environment variable to `false`. By default, these extensions are enabled.
153+
> **Configuration**: Collection search extensions (sorting, field selection, free text search, structured filtering, and datetime filtering) can be disabled by setting the `ENABLE_COLLECTIONS_SEARCH` environment variable to `false`. By default, these extensions are enabled.
146154
147155
> **Note**: Sorting is only available on fields that are indexed for sorting in Elasticsearch/OpenSearch. With the default mappings, you can sort on:
148156
> - `id` (keyword field)
@@ -283,12 +291,12 @@ You can customize additional settings in your `.env` file:
283291
| `ENABLE_DIRECT_RESPONSE` | Enable direct response for maximum performance (disables all FastAPI dependencies, including authentication, custom status codes, and validation) | `false` | Optional |
284292
| `RAISE_ON_BULK_ERROR` | Controls whether bulk insert operations raise exceptions on errors. If set to `true`, the operation will stop and raise an exception when an error occurs. If set to `false`, errors will be logged, and the operation will continue. **Note:** STAC Item and ItemCollection validation errors will always raise, regardless of this flag. | `false` | Optional |
285293
| `DATABASE_REFRESH` | Controls whether database operations refresh the index immediately after changes. If set to `true`, changes will be immediately searchable. If set to `false`, changes may not be immediately visible but can improve performance for bulk operations. If set to `wait_for`, changes will wait for the next refresh cycle to become visible. | `false` | Optional |
286-
| `ENABLE_COLLECTIONS_SEARCH` | Enable collection search extensions (sort, fields). | `true` | Optional |
294+
| `ENABLE_COLLECTIONS_SEARCH` | Enable collection search extensions (sort, fields, free text search, structured filtering, and datetime filtering). | `true` | Optional |
287295
| `ENABLE_TRANSACTIONS_EXTENSIONS` | Enables or disables the Transactions and Bulk Transactions API extensions. If set to `false`, the POST `/collections` route and related transaction endpoints (including bulk transaction operations) will be unavailable in the API. This is useful for deployments where mutating the catalog via the API should be prevented. | `true` | Optional |
288296
| `STAC_ITEM_LIMIT` | Sets the environment variable for result limiting to SFEOS for the number of returned items and STAC collections. | `10` | Optional |
289297
| `STAC_INDEX_ASSETS` | Controls if Assets are indexed when added to Elasticsearch/Opensearch. This allows asset fields to be included in search queries. | `false` | Optional |
290298
| `ENV_MAX_LIMIT` | Configures the environment variable in SFEOS to override the default `MAX_LIMIT`, which controls the limit parameter for returned items and STAC collections. | `10,000` | Optional |
291-
| `USE_DATETIME` | Configures the datetime search behavior in SFEOS. When enabled, searches both datetime field and falls back to start_datetime/end_datetime range for items with null datetime. When disabled, searches only by start_datetime/end_datetime range. | True | Optional |
299+
| `USE_DATETIME` | Configures the datetime search behavior in SFEOS. When enabled, searches both datetime field and falls back to start_datetime/end_datetime range for items with null datetime. When disabled, searches only by start_datetime/end_datetime range. | `true` | Optional |
292300

293301
> [!NOTE]
294302
> The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, `ES_VERIFY_CERTS` and `ES_TIMEOUT` apply to both Elasticsearch and OpenSearch backends, so there is no need to rename the key names to `OS_` even if you're using OpenSearch.

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage:
226226

227227
async def all_collections(
228228
self,
229+
datetime: Optional[str] = None,
229230
fields: Optional[List[str]] = None,
230231
sortby: Optional[str] = None,
231232
filter_expr: Optional[str] = None,
@@ -237,6 +238,7 @@ async def all_collections(
237238
"""Read all collections from the database.
238239
239240
Args:
241+
datetime (Optional[str]): Filter collections by datetime range.
240242
fields (Optional[List[str]]): Fields to include or exclude from the results.
241243
sortby (Optional[str]): Sorting options for the results.
242244
filter_expr (Optional[str]): Structured filter expression in CQL2 JSON or CQL2-text format.
@@ -340,6 +342,10 @@ async def all_collections(
340342
status_code=400, detail=f"Invalid filter parameter: {e}"
341343
)
342344

345+
parsed_datetime = None
346+
if datetime:
347+
parsed_datetime = format_datetime_range(date_str=datetime)
348+
343349
collections, next_token = await self.database.get_all_collections(
344350
token=token,
345351
limit=limit,
@@ -348,6 +354,7 @@ async def all_collections(
348354
q=q_list,
349355
filter=parsed_filter,
350356
query=parsed_query,
357+
datetime=parsed_datetime,
351358
)
352359

353360
# Apply field filtering if fields parameter was provided

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ async def get_all_collections(
178178
q: Optional[List[str]] = None,
179179
filter: Optional[Dict[str, Any]] = None,
180180
query: Optional[Dict[str, Dict[str, Any]]] = None,
181+
datetime: Optional[str] = None,
181182
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
182183
"""Retrieve a list of collections from Elasticsearch, supporting pagination.
183184
@@ -187,8 +188,9 @@ async def get_all_collections(
187188
request (Request): The FastAPI request object.
188189
sort (Optional[List[Dict[str, Any]]]): Optional sort parameter from the request.
189190
q (Optional[List[str]]): Free text search terms.
190-
filter (Optional[Dict[str, Any]]): Structured filter in CQL2 format.
191191
query (Optional[Dict[str, Dict[str, Any]]]): Query extension parameters.
192+
filter (Optional[Dict[str, Any]]): Structured query in CQL2 format.
193+
datetime (Optional[str]): Temporal filter.
192194
193195
Returns:
194196
A tuple of (collections, next pagination token if any).
@@ -301,6 +303,13 @@ async def get_all_collections(
301303
raise
302304

303305
# Combine all query parts with AND logic if there are multiple
306+
datetime_filter = None
307+
if datetime:
308+
datetime_filter = self._apply_collection_datetime_filter(datetime)
309+
if datetime_filter:
310+
query_parts.append(datetime_filter)
311+
312+
# Combine all query parts with AND logic
304313
if query_parts:
305314
body["query"] = (
306315
query_parts[0]
@@ -330,6 +339,41 @@ async def get_all_collections(
330339

331340
return collections, next_token
332341

342+
@staticmethod
343+
def _apply_collection_datetime_filter(
344+
datetime_str: Optional[str],
345+
) -> Optional[Dict[str, Any]]:
346+
"""Create a temporal filter for collections based on their extent."""
347+
if not datetime_str:
348+
return None
349+
350+
# Parse the datetime string into start and end
351+
if "/" in datetime_str:
352+
start, end = datetime_str.split("/")
353+
# Replace open-ended ranges with concrete dates
354+
if start == "..":
355+
# For open-ended start, use a very early date
356+
start = "1800-01-01T00:00:00Z"
357+
if end == "..":
358+
# For open-ended end, use a far future date
359+
end = "2999-12-31T23:59:59Z"
360+
else:
361+
# If it's just a single date, use it for both start and end
362+
start = end = datetime_str
363+
364+
return {
365+
"bool": {
366+
"must": [
367+
# Check if any date in the array is less than or equal to the query end date
368+
# This will match if the collection's start date is before or equal to the query end date
369+
{"range": {"extent.temporal.interval": {"lte": end}}},
370+
# Check if any date in the array is greater than or equal to the query start date
371+
# This will match if the collection's end date is after or equal to the query start date
372+
{"range": {"extent.temporal.interval": {"gte": start}}},
373+
]
374+
}
375+
}
376+
333377
async def get_one_item(self, collection_id: str, item_id: str) -> Dict:
334378
"""Retrieve a single item from the database.
335379

stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ async def get_all_collections(
162162
q: Optional[List[str]] = None,
163163
filter: Optional[Dict[str, Any]] = None,
164164
query: Optional[Dict[str, Dict[str, Any]]] = None,
165+
datetime: Optional[str] = None,
165166
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
166167
"""Retrieve a list of collections from OpenSearch, supporting pagination.
167168
@@ -173,6 +174,7 @@ async def get_all_collections(
173174
q (Optional[List[str]]): Free text search terms.
174175
filter (Optional[Dict[str, Any]]): Structured filter in CQL2 format.
175176
query (Optional[Dict[str, Dict[str, Any]]]): Query extension parameters.
177+
datetime (Optional[str]): Temporal filter.
176178
177179
Returns:
178180
A tuple of (collections, next pagination token if any).
@@ -283,7 +285,13 @@ async def get_all_collections(
283285
query_parts.append({"bool": {"must_not": {"match_all": {}}}})
284286
raise
285287

286-
# Combine all query parts with AND logic if there are multiple
288+
datetime_filter = None
289+
if datetime:
290+
datetime_filter = self._apply_collection_datetime_filter(datetime)
291+
if datetime_filter:
292+
query_parts.append(datetime_filter)
293+
294+
# Combine all query parts with AND logic
287295
if query_parts:
288296
body["query"] = (
289297
query_parts[0]
@@ -399,6 +407,41 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str]
399407
search=search, free_text_queries=free_text_queries
400408
)
401409

410+
@staticmethod
411+
def _apply_collection_datetime_filter(
412+
datetime_str: Optional[str],
413+
) -> Optional[Dict[str, Any]]:
414+
"""Create a temporal filter for collections based on their extent."""
415+
if not datetime_str:
416+
return None
417+
418+
# Parse the datetime string into start and end
419+
if "/" in datetime_str:
420+
start, end = datetime_str.split("/")
421+
# Replace open-ended ranges with concrete dates
422+
if start == "..":
423+
# For open-ended start, use a very early date
424+
start = "1800-01-01T00:00:00Z"
425+
if end == "..":
426+
# For open-ended end, use a far future date
427+
end = "2999-12-31T23:59:59Z"
428+
else:
429+
# If it's just a single date, use it for both start and end
430+
start = end = datetime_str
431+
432+
return {
433+
"bool": {
434+
"must": [
435+
# Check if any date in the array is less than or equal to the query end date
436+
# This will match if the collection's start date is before or equal to the query end date
437+
{"range": {"extent.temporal.interval": {"lte": end}}},
438+
# Check if any date in the array is greater than or equal to the query start date
439+
# This will match if the collection's end date is after or equal to the query start date
440+
{"range": {"extent.temporal.interval": {"gte": start}}},
441+
]
442+
}
443+
}
444+
402445
@staticmethod
403446
def apply_datetime_filter(
404447
search: Search, datetime: Optional[str]

stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,10 @@ class Geometry(Protocol): # noqa
161161
"properties": {
162162
"id": {"type": "keyword"},
163163
"extent.spatial.bbox": {"type": "long"},
164-
"extent.temporal.interval": {"type": "date"},
164+
"extent.temporal.interval": {
165+
"type": "date",
166+
"format": "strict_date_optional_time||epoch_millis",
167+
},
165168
"providers": {"type": "object", "enabled": False},
166169
"links": {"type": "object", "enabled": False},
167170
"item_assets": {"type": "object", "enabled": get_bool_env("STAC_INDEX_ASSETS")},

stac_fastapi/tests/api/test_api_search_collections.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,92 @@ async def test_collections_query_extension(app_client, txn_client, ctx):
437437
assert f"{test_prefix}-sentinel" not in found_ids
438438
assert f"{test_prefix}-landsat" in found_ids
439439
assert f"{test_prefix}-modis" in found_ids
440+
441+
442+
async def test_collections_datetime_filter(app_client, load_test_data, txn_client):
443+
"""Test filtering collections by datetime."""
444+
# Create a test collection with a specific temporal extent
445+
446+
base_collection = load_test_data("test_collection.json")
447+
base_collection["extent"]["temporal"]["interval"] = [
448+
["2020-01-01T00:00:00Z", "2020-12-31T23:59:59Z"]
449+
]
450+
test_collection_id = base_collection["id"]
451+
452+
await create_collection(txn_client, base_collection)
453+
await refresh_indices(txn_client)
454+
455+
# Test 1: Datetime range that overlaps with collection's temporal extent
456+
resp = await app_client.get(
457+
"/collections?datetime=2020-06-01T00:00:00Z/2021-01-01T00:00:00Z"
458+
)
459+
assert resp.status_code == 200
460+
resp_json = resp.json()
461+
found_collections = [
462+
c for c in resp_json["collections"] if c["id"] == test_collection_id
463+
]
464+
assert (
465+
len(found_collections) == 1
466+
), f"Expected to find collection {test_collection_id} with overlapping datetime range"
467+
468+
# Test 2: Datetime range that is completely before collection's temporal extent
469+
resp = await app_client.get(
470+
"/collections?datetime=2019-01-01T00:00:00Z/2019-12-31T23:59:59Z"
471+
)
472+
assert resp.status_code == 200
473+
resp_json = resp.json()
474+
found_collections = [
475+
c for c in resp_json["collections"] if c["id"] == test_collection_id
476+
]
477+
assert (
478+
len(found_collections) == 0
479+
), f"Expected not to find collection {test_collection_id} with non-overlapping datetime range"
480+
481+
# Test 3: Datetime range that is completely after collection's temporal extent
482+
resp = await app_client.get(
483+
"/collections?datetime=2021-01-01T00:00:00Z/2021-12-31T23:59:59Z"
484+
)
485+
assert resp.status_code == 200
486+
resp_json = resp.json()
487+
found_collections = [
488+
c for c in resp_json["collections"] if c["id"] == test_collection_id
489+
]
490+
assert (
491+
len(found_collections) == 0
492+
), f"Expected not to find collection {test_collection_id} with non-overlapping datetime range"
493+
494+
# Test 4: Single datetime that falls within collection's temporal extent
495+
resp = await app_client.get("/collections?datetime=2020-06-15T12:00:00Z")
496+
assert resp.status_code == 200
497+
resp_json = resp.json()
498+
found_collections = [
499+
c for c in resp_json["collections"] if c["id"] == test_collection_id
500+
]
501+
assert (
502+
len(found_collections) == 1
503+
), f"Expected to find collection {test_collection_id} with datetime point within range"
504+
505+
# Test 5: Open-ended range (from a specific date to the future)
506+
resp = await app_client.get("/collections?datetime=2020-06-01T00:00:00Z/..")
507+
assert resp.status_code == 200
508+
resp_json = resp.json()
509+
found_collections = [
510+
c for c in resp_json["collections"] if c["id"] == test_collection_id
511+
]
512+
assert (
513+
len(found_collections) == 1
514+
), f"Expected to find collection {test_collection_id} with open-ended future range"
515+
516+
# Test 6: Open-ended range (from the past to a date within the collection's range)
517+
# TODO: This test is currently skipped due to an unresolved issue with open-ended past range queries.
518+
# The query works correctly in Postman but fails in the test environment.
519+
# Further investigation is needed to understand why this specific query pattern fails.
520+
"""
521+
resp = await app_client.get(
522+
"/collections?datetime=../2025-02-01T00:00:00Z"
523+
)
524+
assert resp.status_code == 200
525+
resp_json = resp.json()
526+
found_collections = [c for c in resp_json["collections"] if c["id"] == test_collection_id]
527+
assert len(found_collections) == 1, f"Expected to find collection {test_collection_id} with open-ended past range to a date within its range"
528+
"""

0 commit comments

Comments
 (0)