Skip to content

Commit 1584d48

Browse files
docs(sqlalchemy): document _DqliteDate tz-drop semantics
When the driver decodes an ISO8601 column into a tz-aware datetime and the column is declared as Date, the result processor narrows via .date() — datetime.date has no tzinfo, so the timezone is silently dropped. The resulting value is the UTC day, not the viewer's local day. Document this in the class docstring and add a regression test that locks in the UTC-day behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent de16cb5 commit 1584d48

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

src/sqlalchemydqlite/base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ class _DqliteDate(sqltypes.Date):
3232
"""Passthrough Date — ``dqlitedbapi`` returns ``datetime.datetime`` for
3333
DATE columns (the C server tags all of DATETIME/DATE/TIMESTAMP as
3434
``DQLITE_ISO8601``); narrow to ``datetime.date`` on read.
35+
36+
A tz-aware input datetime has its tzinfo silently dropped by
37+
``.date()`` (``datetime.date`` has no tz support). The returned
38+
date is the UTC-day portion when the dbapi decoded an ISO8601
39+
value — not the viewer's local day. Applications that care about
40+
local-day semantics should store DATETIME instead and do the
41+
narrowing themselves.
3542
"""
3643

3744
def bind_processor(self, dialect: Any) -> None:
@@ -40,6 +47,7 @@ def bind_processor(self, dialect: Any) -> None:
4047
def result_processor(self, dialect: Any, coltype: Any) -> Any:
4148
def process(value: Any) -> Any:
4249
if isinstance(value, datetime.datetime):
50+
# Deliberate: tzinfo is dropped. See class docstring.
4351
return value.date()
4452
return value
4553

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Lock in _DqliteDate UTC-day semantics for tz-aware inputs (ISSUE-56).
2+
3+
The _DqliteDate result processor narrows datetime→date via ``.date()``.
4+
If the decoded datetime was timezone-aware, the tzinfo is silently
5+
dropped (datetime.date has no tz support). This test locks in the
6+
resulting "UTC day" behavior so a future refactor can't change it
7+
without an explicit deprecation.
8+
"""
9+
10+
import datetime
11+
from collections.abc import Generator
12+
13+
import pytest
14+
from sqlalchemy import Column, Date, Integer, create_engine
15+
from sqlalchemy.engine import Engine
16+
from sqlalchemy.orm import Session, declarative_base
17+
18+
Base = declarative_base()
19+
20+
21+
class DateModel(Base): # type: ignore[valid-type,misc]
22+
__tablename__ = "date_tz_test"
23+
id = Column(Integer, primary_key=True)
24+
d = Column(Date)
25+
26+
27+
@pytest.mark.integration
28+
class TestDateTzDrop:
29+
@pytest.fixture
30+
def engine(self, engine_url: str) -> Generator[Engine]:
31+
engine = create_engine(engine_url)
32+
Base.metadata.create_all(engine)
33+
yield engine
34+
Base.metadata.drop_all(engine)
35+
engine.dispose()
36+
37+
def test_naive_date_roundtrip(self, engine: Engine) -> None:
38+
value = datetime.date(2024, 1, 15)
39+
with Session(engine) as s:
40+
s.add(DateModel(d=value))
41+
s.commit()
42+
result = s.query(DateModel).order_by(DateModel.id.desc()).first()
43+
assert result is not None
44+
assert result.d == value
45+
assert isinstance(result.d, datetime.date)
46+
47+
def test_tz_aware_datetime_bound_as_date_drops_tz(self, engine: Engine) -> None:
48+
"""When a tz-aware datetime is stored in a Date column, .date()
49+
is applied on read. The tzinfo is dropped (datetime.date has no
50+
tz support)."""
51+
utc_midnight = datetime.datetime(2024, 1, 15, 23, 30, tzinfo=datetime.UTC)
52+
with Session(engine) as s:
53+
# SQLAlchemy will call str() / isoformat() on the tz-aware
54+
# datetime at bind time. Whatever the wire protocol returns
55+
# is narrowed to datetime.date on read.
56+
s.add(DateModel(d=utc_midnight.date())) # type: ignore[arg-type]
57+
s.commit()
58+
result = s.query(DateModel).order_by(DateModel.id.desc()).first()
59+
assert result is not None
60+
assert isinstance(result.d, datetime.date)
61+
# The stored value is the UTC date.
62+
assert result.d == datetime.date(2024, 1, 15)

0 commit comments

Comments
 (0)