77from abc import ABCMeta
88from collections .abc import AsyncIterator
99from contextvars import ContextVar
10- from datetime import datetime , timezone
10+ from datetime import datetime , timedelta , timezone
1111from typing import Any , Self , cast
1212from uuid import UUID as StdUUID # noqa: N811
1313
1414from pydantic import TypeAdapter
15- from sqlalchemy import DateTime , MetaData , func , select
15+ from sqlalchemy import DateTime , MetaData , and_ , func , or_ , select
1616from sqlalchemy .exc import OperationalError
1717from sqlalchemy .ext .asyncio import AsyncConnection , AsyncEngine , create_async_engine
1818from uuid_utils import UUID , uuid7
2828from diracx .db .exceptions import DBUnavailableError
2929from diracx .db .sql .utils .types import SmarterDateTime
3030
31- from .functions import date_trunc
32-
3331logger = 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+
355424def 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