Skip to content

Commit b820667

Browse files
committed
perf: use sargable range predicates for datetime search filters
Replace date_trunc() (which wraps columns in date_format() on MySQL) with range-based comparisons on the raw column. This allows the database to use indexes on datetime columns instead of performing full table scans.
1 parent 0440a9c commit b820667

1 file changed

Lines changed: 90 additions & 6 deletions

File tree

  • diracx-db/src/diracx/db/sql/utils

diracx-db/src/diracx/db/sql/utils/base.py

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from abc import ABCMeta
88
from collections.abc import AsyncIterator
99
from contextvars import ContextVar
10-
from datetime import datetime, timezone
10+
from datetime import datetime, timedelta, timezone
1111
from typing import Any, Self, cast
1212
from uuid import UUID as StdUUID # noqa: N811
1313

1414
from pydantic import TypeAdapter
15-
from sqlalchemy import DateTime, MetaData, func, select
15+
from sqlalchemy import DateTime, MetaData, and_, func, or_, select
1616
from sqlalchemy.exc import OperationalError
1717
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
1818
from uuid_utils import UUID, uuid7
@@ -28,8 +28,6 @@
2828
from diracx.db.exceptions import DBUnavailableError
2929
from diracx.db.sql.utils.types import SmarterDateTime
3030

31-
from .functions import date_trunc
32-
3331
logger = logging.getLogger(__name__)
3432

3533

@@ -352,6 +350,77 @@ def _get_columns(table, parameters):
352350
return columns
353351

354352

353+
def _datetime_period_bounds(
354+
value_str: str, precision: str
355+
) -> tuple[datetime, datetime]:
356+
"""Compute the inclusive start and exclusive end of a datetime period.
357+
358+
For example, precision="DAY" and value_str="2025-08-25" returns:
359+
(datetime(2025, 8, 25, 0, 0), datetime(2025, 8, 26, 0, 0))
360+
"""
361+
parse_formats = {
362+
"YEAR": "%Y",
363+
"MONTH": "%Y-%m",
364+
"DAY": "%Y-%m-%d",
365+
"HOUR": "%Y-%m-%d %H",
366+
"MINUTE": "%Y-%m-%d %H:%M",
367+
"SECOND": "%Y-%m-%d %H:%M:%S",
368+
}
369+
start = datetime.strptime(value_str, parse_formats[precision]).replace(
370+
tzinfo=timezone.utc
371+
)
372+
373+
if precision == "YEAR":
374+
end = start.replace(year=start.year + 1)
375+
elif precision == "MONTH":
376+
end = (
377+
start.replace(year=start.year + 1, month=1)
378+
if start.month == 12
379+
else start.replace(month=start.month + 1)
380+
)
381+
elif precision == "DAY":
382+
end = start + timedelta(days=1)
383+
elif precision == "HOUR":
384+
end = start + timedelta(hours=1)
385+
elif precision == "MINUTE":
386+
end = start + timedelta(minutes=1)
387+
else: # SECOND
388+
end = start + timedelta(seconds=1)
389+
390+
return start, end
391+
392+
393+
def _build_datetime_range_expr(column, operator: str, start: datetime, end: datetime):
394+
"""Build a sargable range expression for a single datetime period.
395+
396+
Uses range predicates on the raw column so database indexes can be used.
397+
"""
398+
if operator == "eq":
399+
return and_(column >= start, column < end)
400+
elif operator == "neq":
401+
return or_(column < start, column >= end)
402+
elif operator == "gt":
403+
return column >= end
404+
elif operator == "lt":
405+
return column < start
406+
raise InvalidQueryError(
407+
f"Operator '{operator}' is not supported for partial datetime values"
408+
)
409+
410+
411+
def _build_datetime_range_multi_expr(
412+
column, operator: str, bounds: list[tuple[datetime, datetime]]
413+
):
414+
"""Build a sargable range expression for multiple datetime periods (IN/NOT IN)."""
415+
if operator == "in":
416+
return or_(*[and_(column >= s, column < e) for s, e in bounds])
417+
elif operator == "not in":
418+
return and_(*[or_(column < s, column >= e) for s, e in bounds])
419+
raise InvalidQueryError(
420+
f"Operator '{operator}' is not supported for partial datetime values"
421+
)
422+
423+
355424
def apply_search_filters(column_mapping, stmt, search):
356425
for query in search:
357426
try:
@@ -363,7 +432,13 @@ def apply_search_filters(column_mapping, stmt, search):
363432
if "value" in query and isinstance(query["value"], str):
364433
resolution, value = find_time_resolution(query["value"])
365434
if resolution:
366-
column = date_trunc(column, time_resolution=resolution)
435+
start, end = _datetime_period_bounds(value, resolution)
436+
stmt = stmt.where(
437+
_build_datetime_range_expr(
438+
column, query["operator"], start, end
439+
)
440+
)
441+
continue
367442
query["value"] = value
368443

369444
if query.get("values"):
@@ -375,7 +450,16 @@ def apply_search_filters(column_mapping, stmt, search):
375450
f"Cannot mix different time resolutions in {query=}"
376451
)
377452
if resolution := resolutions[0]:
378-
column = date_trunc(column, time_resolution=resolution)
453+
bounds = [
454+
_datetime_period_bounds(cast(str, v), resolution)
455+
for v in values
456+
]
457+
stmt = stmt.where(
458+
_build_datetime_range_multi_expr(
459+
column, query["operator"], bounds
460+
)
461+
)
462+
continue
379463
query["values"] = values
380464

381465
if query["operator"] == "eq":

0 commit comments

Comments
 (0)