Skip to content

Commit b2bfcbe

Browse files
Override DATETIME/DATE colspecs for native datetime passthrough
The dqlite DBAPI now returns datetime.datetime directly for columns the server tags as ISO8601 or UNIXTIME (matching psycopg/mysqlclient/PEP 249). SQLAlchemy's inherited sqlite.DATETIME and sqlite.DATE types, by contrast, assume pysqlite-style string returns and call fromisoformat on the value — which crashes when handed a datetime. Registers two dialect-local types via colspecs: - _DqliteDateTime: bind_processor and result_processor both return None, so datetime objects flow through unchanged in both directions (the DBAPI stringifies on bind and datetime-ifies on result). - _DqliteDate: bind is passthrough; result narrows datetime.datetime to datetime.date on read, since the server tags DATE columns as ISO8601 (see dqlite-upstream/src/query.c) and the DBAPI doesn't know to downcast. TIME is deliberately not overridden: the server does not tag TIME columns specially (they're plain TEXT), so SQLAlchemy's string-based Time processor continues to work as-is. Async dialect (aio.DqliteDialect_aio) inherits colspecs from the sync dialect, so it picks up the overrides automatically. Tests tightened and extended in test_orm_operations.py: the existing test_datetime_types now asserts tzinfo-is-None and full value equality (not just field-by-field). New tests cover Column(Date) returning datetime.date exactly (not a datetime subclass) and NULL DateTime round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0bb7913 commit b2bfcbe

2 files changed

Lines changed: 86 additions & 6 deletions

File tree

src/sqlalchemydqlite/base.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,47 @@
11
"""Base dqlite dialect for SQLAlchemy."""
22

3+
import datetime
34
from typing import Any
45

6+
from sqlalchemy import types as sqltypes
57
from sqlalchemy.dialects.sqlite.base import SQLiteDialect
68
from sqlalchemy.engine import URL
79
from sqlalchemy.engine.interfaces import DBAPIConnection, IsolationLevel
810

911

12+
class _DqliteDateTime(sqltypes.DateTime):
13+
"""Passthrough DateTime — ``dqlitedbapi`` already returns ``datetime.datetime``
14+
for columns declared as DATETIME/TIMESTAMP (matching PEP 249 and the
15+
psycopg/mysqlclient convention), so no string parsing is needed.
16+
"""
17+
18+
def bind_processor(self, dialect: Any) -> None:
19+
return None
20+
21+
def result_processor(self, dialect: Any, coltype: Any) -> None:
22+
return None
23+
24+
25+
class _DqliteDate(sqltypes.Date):
26+
"""Passthrough Date — ``dqlitedbapi`` returns ``datetime.datetime`` for
27+
DATE columns (the C server tags all of DATETIME/DATE/TIMESTAMP as
28+
``DQLITE_ISO8601``); narrow to ``datetime.date`` on read.
29+
"""
30+
31+
def bind_processor(self, dialect: Any) -> None:
32+
return None
33+
34+
def result_processor(
35+
self, dialect: Any, coltype: Any
36+
) -> Any:
37+
def process(value: Any) -> Any:
38+
if isinstance(value, datetime.datetime):
39+
return value.date()
40+
return value
41+
42+
return process
43+
44+
1045
class DqliteDialect(SQLiteDialect):
1146
"""SQLAlchemy dialect for dqlite.
1247
@@ -22,6 +57,14 @@ class DqliteDialect(SQLiteDialect):
2257
# Enable SQLAlchemy statement caching
2358
supports_statement_cache = True
2459

60+
# Override the SQLite dialect's string-based DATE/DATETIME processors:
61+
# dqlitedbapi returns datetime objects (PEP 249), not ISO strings.
62+
colspecs = {
63+
**SQLiteDialect.colspecs,
64+
sqltypes.DateTime: _DqliteDateTime,
65+
sqltypes.Date: _DqliteDate,
66+
}
67+
2568
@classmethod
2669
def import_dbapi(cls) -> Any:
2770
import dqlitedbapi

tests/integration/test_orm_operations.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
BigInteger,
99
Boolean,
1010
Column,
11+
Date,
1112
DateTime,
1213
Float,
1314
Integer,
@@ -63,6 +64,13 @@ class DateTimeTest(Base): # type: ignore[valid-type,misc]
6364
updated_at = Column(DateTime, nullable=True)
6465

6566

67+
class DateOnlyTest(Base): # type: ignore[valid-type,misc]
68+
__tablename__ = "date_only_test"
69+
70+
id = Column(Integer, primary_key=True)
71+
d = Column(Date)
72+
73+
6674
@pytest.mark.integration
6775
class TestORMOperations:
6876
@pytest.fixture
@@ -192,12 +200,41 @@ def test_datetime_types(self, engine: Engine) -> None:
192200
result = session.query(DateTimeTest).order_by(DateTimeTest.id.desc()).first()
193201
assert result is not None
194202

195-
assert result.created_at.year == dt.year
196-
assert result.created_at.month == dt.month
197-
assert result.created_at.day == dt.day
198-
assert result.created_at.hour == dt.hour
199-
assert result.created_at.minute == dt.minute
200-
assert result.created_at.second == dt.second
203+
assert isinstance(result.created_at, datetime.datetime)
204+
# SQLAlchemy's default DateTime is timezone=False, so naive
205+
# input must round-trip as naive (not silently UTC-tagged).
206+
assert result.created_at.tzinfo is None
207+
assert result.created_at == dt
208+
209+
def test_datetime_null_roundtrip(self, engine: Engine) -> None:
210+
"""A NULL DateTime column reads back as None."""
211+
with Session(engine) as session:
212+
record = DateTimeTest(
213+
created_at=datetime.datetime(2024, 1, 1, 0, 0, 0),
214+
updated_at=None,
215+
)
216+
session.add(record)
217+
session.commit()
218+
219+
result = session.query(DateTimeTest).order_by(DateTimeTest.id.desc()).first()
220+
assert result is not None
221+
assert result.updated_at is None
222+
223+
def test_date_column_returns_date(self, engine: Engine) -> None:
224+
"""A Column(Date) returns ``datetime.date``, not ``datetime.datetime``.
225+
226+
The dqlite DBAPI returns datetime for ISO8601-tagged columns; the
227+
dialect's _DqliteDate.result_processor narrows to date on read.
228+
"""
229+
d = datetime.date(2024, 3, 14)
230+
with Session(engine) as session:
231+
session.add(DateOnlyTest(d=d))
232+
session.commit()
233+
234+
result = session.query(DateOnlyTest).order_by(DateOnlyTest.id.desc()).first()
235+
assert result is not None
236+
assert type(result.d) is datetime.date
237+
assert result.d == d
201238

202239
def test_null_handling(self, engine: Engine) -> None:
203240
"""Test NULL values across different column types."""

0 commit comments

Comments
 (0)