Skip to content

Commit 27d71c1

Browse files
authored
🐛 Fix SQLite IN filters for non-JSON-serializable values (#7486)
Serialize SQLite smart IN-clause values through the column bind processor before passing them to `json_each`. This supports values such as datetimes that are not directly JSON serializable, and makes their JSON representation match how they are stored. Fixes stfc/aiida-mlip#262, introduced in 8d562b4.
1 parent 65dba5f commit 27d71c1

3 files changed

Lines changed: 51 additions & 6 deletions

File tree

src/aiida/storage/utils.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@
1313
import json
1414
from collections.abc import Sequence
1515
from functools import singledispatch
16-
from typing import TYPE_CHECKING, TypeVar
16+
from typing import TYPE_CHECKING, Any, TypeVar
1717

1818
from sqlalchemy import Select, or_, select, type_coerce
19-
from sqlalchemy import cast as sql_cast
2019
from sqlalchemy import func as sa_func
2120
from sqlalchemy.dialects.postgresql.base import PGDialect
2221
from sqlalchemy.dialects.sqlite.base import SQLiteDialect
@@ -64,10 +63,18 @@ def _build_select_stmt_psql(dialect: PGDialect, coltype: TypeEngine[T], values:
6463

6564
@_build_select_stmt.register
6665
def _build_select_stmt_sqlite(dialect: SQLiteDialect, coltype: TypeEngine[T], values: Sequence[T]) -> Select[tuple[T]]:
67-
"""SQLite: ``SELECT CAST(value AS coltype) FROM json_each(:json)`` — passes the list as 1 parameter."""
68-
json_each_table = sa_func.json_each(json.dumps(list(values))).table_valued('value')
69-
value_col = json_each_table.c.value
70-
return select(sql_cast(expression=value_col, type_=coltype)).select_from(json_each_table)
66+
"""SQLite: ``SELECT value FROM json_each(:json)`` — passes the list as 1 parameter.
67+
68+
Values are serialized through the column's bind processor so their JSON representation matches
69+
how they are stored; SQLite then applies the column's comparison affinity to evaluate the ``IN``.
70+
"""
71+
processor = coltype.dialect_impl(dialect).bind_processor(dialect)
72+
73+
def process(value: T) -> Any:
74+
return processor(value) if processor is not None and value is not None else value
75+
76+
json_each_table = sa_func.json_each(json.dumps([process(value) for value in values])).table_valued('value')
77+
return select(json_each_table.c.value).select_from(json_each_table)
7178

7279

7380
def _create_smarter_in_clause(

tests/orm/test_querybuilder.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ def test_date_filters_support(self):
3838
builder = orm.QueryBuilder().append(orm.Node, filters={'ctime': {'>': date.today() - timedelta(days=1)}})
3939
assert builder.count() == 1
4040

41+
@pytest.mark.usefixtures('aiida_profile_clean')
42+
def test_datetime_in_filter_support(self):
43+
"""Verify that ``datetime`` values are supported for ``in`` and negated ``in`` filters."""
44+
node = orm.Data().store()
45+
46+
builder = orm.QueryBuilder().append(orm.Data, filters={'ctime': {'in': [node.ctime]}})
47+
assert builder.count() == 1
48+
49+
builder = orm.QueryBuilder().append(orm.Data, filters={'ctime': {'!in': [node.ctime]}})
50+
assert builder.count() == 0
51+
4152
def test_ormclass_type_classification(self):
4253
"""This tests the classifications of the QueryBuilder"""
4354
from aiida.common.exceptions import DbContentError

tests/storage/test_utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"""Tests for :mod:`aiida.storage.utils`."""
1010

1111
from collections.abc import Generator
12+
from datetime import datetime, timezone
1213
from typing import cast
1314

1415
import pytest
@@ -64,6 +65,32 @@ def test_sqlite_batches_large_lists(sqlite_session: SqliteSessionFixture) -> Non
6465
assert ' OR ' in sql
6566

6667

68+
def test_sqlite_in_clause_datetime_values() -> None:
69+
"""SQLite: datetime values are serialized using the column bind processor."""
70+
engine: sa.Engine = sa.create_engine(url='sqlite://')
71+
metadata: sa.MetaData = sa.MetaData()
72+
table: sa.Table = sa.Table(
73+
'items',
74+
metadata,
75+
sa.Column(name='id', type_=sa.Integer, primary_key=True),
76+
sa.Column(name='ctime', type_=sa.DateTime(timezone=True)),
77+
)
78+
metadata.create_all(bind=engine)
79+
80+
timestamp = datetime(2026, 7, 23, 12, 0, 0, 123456, tzinfo=timezone.utc)
81+
with Session(bind=engine) as session:
82+
session.execute(table.insert().values(id=1, ctime=timestamp))
83+
session.execute(table.insert().values(id=2, ctime=datetime(2026, 7, 24, tzinfo=timezone.utc)))
84+
session.commit()
85+
86+
in_clause: ColumnElement[bool] = _create_smarter_in_clause(
87+
session=session, column=table.c.ctime, values=[timestamp]
88+
)
89+
result = session.execute(sa.select(table.c.id).where(in_clause)).scalars().all()
90+
91+
assert result == [1]
92+
93+
6794
@pytest.mark.requires_psql
6895
@pytest.mark.usefixtures('aiida_profile_clean')
6996
def test_psql_uses_unnest() -> None:

0 commit comments

Comments
 (0)