Skip to content

Commit 86c95fe

Browse files
Yuri ZmytrakovYuri Zmytrakov
authored andcommitted
fix: maintain backward compatibility for datetime searches
1 parent 407bcf6 commit 86c95fe

File tree

4 files changed

+159
-93
lines changed

4 files changed

+159
-93
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ You can customize additional settings in your `.env` file:
366366
| `STAC_DEFAULT_ITEM_LIMIT` | Configures the default number of STAC items returned when no limit parameter is specified in the request. | `10` | Optional |
367367
| `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 |
368368
| `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 |
369+
| `USE_DATETIME_NANOS` | Enables nanosecond precision handling for `datetime` field searches as per the `date_nanos` type. When `False`, it uses 3 millisecond precision as per the type `date`. | `true` | Optional |
369370
| `EXCLUDED_FROM_QUERYABLES` | Comma-separated list of fully qualified field names to exclude from the queryables endpoint and filtering. Use full paths like `properties.auth:schemes,properties.storage:schemes`. Excluded fields and their nested children will not be exposed in queryables. | None | Optional |
370371
| `EXCLUDED_FROM_ITEMS` | Specifies fields to exclude from STAC item responses. Supports comma-separated field names and dot notation for nested fields (e.g., `private_data,properties.confidential,assets.internal`). | `None` | Optional |
371372

stac_fastapi/core/stac_fastapi/core/datetime_utils.py

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from datetime import datetime, timezone
44

5+
from stac_fastapi.core.utilities import get_bool_env
6+
from stac_fastapi.opensearch.config import MAX_DATE_NANOS, MIN_DATE_NANOS
57
from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime
68

79

@@ -15,45 +17,69 @@ def format_datetime_range(date_str: str) -> str:
1517
Returns:
1618
str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None.
1719
"""
18-
MIN_DATE_NANOS = datetime(1970, 1, 1, tzinfo=timezone.utc)
19-
MAX_DATE_NANOS = datetime(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)
20-
21-
def normalize(dt):
22-
"""Normalize datetime string and preserve millisecond precision."""
23-
dt = dt.strip()
24-
if not dt or dt == "..":
25-
return ".."
26-
dt_utc = rfc3339_str_to_datetime(dt).astimezone(timezone.utc)
27-
if dt_utc < MIN_DATE_NANOS:
28-
dt_utc = MIN_DATE_NANOS
29-
if dt_utc > MAX_DATE_NANOS:
30-
dt_utc = MAX_DATE_NANOS
31-
return dt_utc.isoformat(timespec="auto").replace("+00:00", "Z")
32-
33-
if not isinstance(date_str, str):
34-
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
35-
36-
if "/" not in date_str:
37-
return f"{normalize(date_str)}/{normalize(date_str)}"
38-
39-
try:
40-
start, end = date_str.split("/", 1)
41-
except Exception:
42-
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
43-
44-
normalized_start = normalize(start)
45-
normalized_end = normalize(end)
46-
47-
if normalized_start == "..":
48-
normalized_start = MIN_DATE_NANOS.isoformat(timespec="auto").replace(
49-
"+00:00", "Z"
50-
)
51-
if normalized_end == "..":
52-
normalized_end = MAX_DATE_NANOS.isoformat(timespec="auto").replace(
53-
"+00:00", "Z"
54-
)
55-
56-
return f"{normalized_start}/{normalized_end}"
20+
use_datetime_nanos = get_bool_env("USE_DATETIME_NANOS", default=True)
21+
22+
if use_datetime_nanos:
23+
24+
def normalize(dt):
25+
"""Normalize datetime string and preserve nano second precision."""
26+
dt = dt.strip()
27+
if not dt or dt == "..":
28+
return ".."
29+
dt_utc = rfc3339_str_to_datetime(dt).astimezone(timezone.utc)
30+
if dt_utc < MIN_DATE_NANOS:
31+
dt_utc = MIN_DATE_NANOS
32+
if dt_utc > MAX_DATE_NANOS:
33+
dt_utc = MAX_DATE_NANOS
34+
return dt_utc.isoformat(timespec="auto").replace("+00:00", "Z")
35+
36+
if not isinstance(date_str, str):
37+
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
38+
39+
if "/" not in date_str:
40+
return f"{normalize(date_str)}/{normalize(date_str)}"
41+
42+
try:
43+
start, end = date_str.split("/", 1)
44+
except Exception:
45+
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
46+
47+
normalized_start = normalize(start)
48+
normalized_end = normalize(end)
49+
50+
if normalized_start == "..":
51+
normalized_start = MIN_DATE_NANOS.isoformat(timespec="auto").replace(
52+
"+00:00", "Z"
53+
)
54+
if normalized_end == "..":
55+
normalized_end = MAX_DATE_NANOS.isoformat(timespec="auto").replace(
56+
"+00:00", "Z"
57+
)
58+
59+
return f"{normalized_start}/{normalized_end}"
60+
61+
else:
62+
63+
def normalize(dt):
64+
"""Normalize datetime string and preserve millisecond precision."""
65+
dt = dt.strip()
66+
if not dt or dt == "..":
67+
return ".."
68+
dt_obj = rfc3339_str_to_datetime(dt)
69+
dt_utc = dt_obj.astimezone(timezone.utc)
70+
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
71+
72+
if not isinstance(date_str, str):
73+
return "../.."
74+
75+
if "/" not in date_str:
76+
return f"{normalize(date_str)}/{normalize(date_str)}"
77+
78+
try:
79+
start, end = date_str.split("/", 1)
80+
except Exception:
81+
return "../.."
82+
return f"{normalize(start)}/{normalize(end)}"
5783

5884

5985
# Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394

stac_fastapi/opensearch/stac_fastapi/opensearch/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import os
55
import ssl
6+
from datetime import datetime, timezone
67
from typing import Any, Dict, Set, Union
78

89
import certifi
@@ -13,6 +14,10 @@
1314
from stac_fastapi.sfeos_helpers.database import validate_refresh
1415
from stac_fastapi.types.config import ApiSettings
1516

17+
# Date constants for date_nanos type
18+
MIN_DATE_NANOS = datetime(1970, 1, 1, tzinfo=timezone.utc)
19+
MAX_DATE_NANOS = datetime(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)
20+
1621

1722
def _es_config() -> Dict[str, Any]:
1823
# Determine the scheme (http or https)

stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/datetime.py

Lines changed: 88 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from datetime import timezone
1212
from typing import Dict, Optional, Union
1313

14+
from stac_fastapi.core.utilities import get_bool_env
15+
from stac_fastapi.opensearch.config import MAX_DATE_NANOS, MIN_DATE_NANOS
1416
from stac_fastapi.types.rfc3339 import DateTimeType
1517

1618
logger = logging.getLogger(__name__)
@@ -38,67 +40,99 @@ def return_date(
3840
always containing 'gte' and 'lte' keys.
3941
"""
4042
result: Dict[str, Optional[str]] = {"gte": None, "lte": None}
41-
MIN_DATE_NANOS = datetime_type(1970, 1, 1, tzinfo=timezone.utc)
42-
MAX_DATE_NANOS = datetime_type(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)
43-
43+
use_datetime_nanos = get_bool_env("USE_DATETIME_NANOS", default=True)
4444
if interval is None:
4545
return result
4646

47-
if isinstance(interval, str):
48-
if "/" in interval:
49-
parts = interval.split("/")
50-
result["gte"] = (
51-
parts[0] if parts[0] != ".." else MIN_DATE_NANOS.isoformat() + "Z"
52-
)
53-
result["lte"] = (
54-
parts[1]
55-
if len(parts) > 1 and parts[1] != ".."
56-
else MAX_DATE_NANOS.isoformat() + "Z"
47+
if use_datetime_nanos:
48+
49+
if isinstance(interval, str):
50+
if "/" in interval:
51+
parts = interval.split("/")
52+
result["gte"] = (
53+
parts[0] if parts[0] != ".." else MIN_DATE_NANOS.isoformat() + "Z"
54+
)
55+
result["lte"] = (
56+
parts[1]
57+
if len(parts) > 1 and parts[1] != ".."
58+
else MAX_DATE_NANOS.isoformat() + "Z"
59+
)
60+
else:
61+
converted_time = interval if interval != ".." else None
62+
result["gte"] = result["lte"] = converted_time
63+
return result
64+
65+
if isinstance(interval, datetime_type):
66+
dt_utc = (
67+
interval.astimezone(timezone.utc)
68+
if interval.tzinfo
69+
else interval.replace(tzinfo=timezone.utc)
5770
)
58-
else:
59-
converted_time = interval if interval != ".." else None
60-
result["gte"] = result["lte"] = converted_time
71+
if dt_utc < MIN_DATE_NANOS:
72+
dt_utc = MIN_DATE_NANOS
73+
elif dt_utc > MAX_DATE_NANOS:
74+
dt_utc = MAX_DATE_NANOS
75+
datetime_iso = dt_utc.isoformat()
76+
result["gte"] = result["lte"] = datetime_iso
77+
elif isinstance(interval, tuple):
78+
start, end = interval
79+
# Ensure datetimes are converted to UTC and formatted with 'Z'
80+
if start:
81+
start_utc = (
82+
start.astimezone(timezone.utc)
83+
if start.tzinfo
84+
else start.replace(tzinfo=timezone.utc)
85+
)
86+
if start_utc < MIN_DATE_NANOS:
87+
start_utc = MIN_DATE_NANOS
88+
elif start_utc > MAX_DATE_NANOS:
89+
start_utc = MAX_DATE_NANOS
90+
result["gte"] = start_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
91+
if end:
92+
end_utc = (
93+
end.astimezone(timezone.utc)
94+
if end.tzinfo
95+
else end.replace(tzinfo=timezone.utc)
96+
)
97+
if end_utc < MIN_DATE_NANOS:
98+
end_utc = MIN_DATE_NANOS
99+
elif end_utc > MAX_DATE_NANOS:
100+
end_utc = MAX_DATE_NANOS
101+
result["lte"] = end_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
102+
61103
return result
62104

63-
if isinstance(interval, datetime_type):
64-
dt_utc = (
65-
interval.astimezone(timezone.utc)
66-
if interval.tzinfo
67-
else interval.replace(tzinfo=timezone.utc)
68-
)
69-
if dt_utc < MIN_DATE_NANOS:
70-
dt_utc = MIN_DATE_NANOS
71-
elif dt_utc > MAX_DATE_NANOS:
72-
dt_utc = MAX_DATE_NANOS
73-
datetime_iso = dt_utc.isoformat()
74-
result["gte"] = result["lte"] = datetime_iso
75-
elif isinstance(interval, tuple):
76-
start, end = interval
77-
# Ensure datetimes are converted to UTC and formatted with 'Z'
78-
if start:
79-
start_utc = (
80-
start.astimezone(timezone.utc)
81-
if start.tzinfo
82-
else start.replace(tzinfo=timezone.utc)
83-
)
84-
if start_utc < MIN_DATE_NANOS:
85-
start_utc = MIN_DATE_NANOS
86-
elif start_utc > MAX_DATE_NANOS:
87-
start_utc = MAX_DATE_NANOS
88-
result["gte"] = start_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
89-
if end:
90-
end_utc = (
91-
end.astimezone(timezone.utc)
92-
if end.tzinfo
93-
else end.replace(tzinfo=timezone.utc)
94-
)
95-
if end_utc < MIN_DATE_NANOS:
96-
end_utc = MIN_DATE_NANOS
97-
elif end_utc > MAX_DATE_NANOS:
98-
end_utc = MAX_DATE_NANOS
99-
result["lte"] = end_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
105+
else:
106+
if isinstance(interval, str):
107+
if "/" in interval:
108+
parts = interval.split("/")
109+
result["gte"] = (
110+
parts[0]
111+
if parts[0] != ".."
112+
else datetime_type.min.isoformat() + "Z"
113+
)
114+
result["lte"] = (
115+
parts[1]
116+
if len(parts) > 1 and parts[1] != ".."
117+
else datetime_type.max.isoformat() + "Z"
118+
)
119+
else:
120+
converted_time = interval if interval != ".." else None
121+
result["gte"] = result["lte"] = converted_time
122+
return result
123+
124+
if isinstance(interval, datetime_type):
125+
datetime_iso = interval.isoformat()
126+
result["gte"] = result["lte"] = datetime_iso
127+
elif isinstance(interval, tuple):
128+
start, end = interval
129+
# Ensure datetimes are converted to UTC and formatted with 'Z'
130+
if start:
131+
result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
132+
if end:
133+
result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
100134

101-
return result
135+
return result
102136

103137

104138
def extract_date(date_str: str) -> date:

0 commit comments

Comments
 (0)