Skip to content

Commit a3ce002

Browse files
authored
Support SQLModel UTC datetime migrations without downgrades (#7462)
* Support SQLModel UTC datetime migrations without downgrades * Address SQLModel compatibility review feedback
1 parent f35b3cd commit a3ce002

6 files changed

Lines changed: 147 additions & 14 deletions

File tree

‎docs/database/tables.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,43 @@ It is not currently possible to create a table without a primary key.
3232

3333
## Advanced Column Types
3434

35+
### Datetimes and SQLModel upgrades
36+
37+
From SQLModel 0.0.45, a plain `datetime` field uses `UTCDateTime`:
38+
database writes require timezone-aware values, and reads return UTC-aware values,
39+
including on SQLite. Migrations generated with these versions reference
40+
`sqlmodel.sql.sqltypes.UTCDateTime()` and require SQLModel 0.0.45 or later when
41+
they run. Keep the SQLModel version used to generate migrations in your deployment
42+
dependencies; for example, `sqlmodel>=0.0.45` if a migration uses `UTCDateTime`.
43+
44+
For existing apps that store naive datetimes, declare the SQLAlchemy type
45+
explicitly to preserve that behavior across SQLModel versions:
46+
47+
```python
48+
from datetime import datetime
49+
50+
from sqlalchemy import DateTime
51+
from sqlmodel import Field
52+
53+
54+
class Post(rx.Model, table=True):
55+
created_at: datetime = Field(
56+
default_factory=datetime.now,
57+
sa_type=DateTime(timezone=False),
58+
)
59+
```
60+
61+
On SQLModel 0.0.45 or later, Pydantic's `NaiveDatetime` annotation also selects
62+
naive storage. For UTC storage, use aware producers such as
63+
`datetime.now(timezone.utc)`, and update comparisons, query parameters, and
64+
database defaults to use aware values. Changing the Python field type does not
65+
convert existing database values: determine the timezone of stored data and
66+
apply a database-specific migration before adopting UTC storage. Follow
67+
[SQLModel's datetime upgrade guide](https://sqlmodel.tiangolo.com/advanced/datetime/#upgrade-existing-applications)
68+
for the PostgreSQL, SQLite, and MySQL migration details.
69+
70+
### Explicit column types
71+
3572
SQLModel automatically maps basic python types to SQLAlchemy column types, but
3673
for more advanced use cases, it is possible to define the column type using
3774
`sqlalchemy` directly. For example, we can add a last updated timestamp to the

‎news/+refresh-dependencies.misc.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Allow wrapt 2.4 and 2.5, and keep SQLModel below 0.0.45 to preserve existing datetime storage behavior.
1+
Allow wrapt 2.4 and 2.5.

‎pyproject.toml‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ classifiers = [
6363
db = [
6464
"alembic >=1.15.2,<2.0",
6565
"pydantic >=2.12.0,<3.0",
66-
# 0.0.45 changes datetime columns to UTC and rejects existing naive values.
67-
"sqlmodel >=0.0.24,<0.0.45",
66+
"sqlmodel >=0.0.24",
6867
]
6968
pydantic = ["reflex-base[pydantic]"]
7069
testing = [
@@ -487,9 +486,6 @@ backend_held = [
487486
# (including `get_state`, so it is not test-only). See #7026 for the reproduction
488487
# and the A/B that isolated it; requested in #6764.
489488
"redis",
490-
# 0.0.45 changes datetime storage and requires existing database migrations.
491-
# Keep naive datetime defaults working until a compatibility path is available.
492-
"sqlmodel",
493489
]
494490
backend_pinned = [
495491
# Linters are pinned deliberately and bumped on their own cadence.

‎tests/units/test_check_outdated_deps.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,28 @@ def test_load_config_reads_the_repo_config():
173173
# and the hold is policy, not a blocker.
174174
assert "ruff" in check_outdated_deps._load_config("backend")[1]
175175
assert "ag-grid*" in check_outdated_deps._load_config("frontend")[1]
176+
177+
178+
@pytest.mark.parametrize("sqlmodel_outdated", [False, True])
179+
def test_sqlmodel_updates_are_reported(sqlmodel_outdated: bool):
180+
"""SQLModel stays visible to the checker without a stale hold at latest.
181+
182+
Args:
183+
sqlmodel_outdated: Whether SQLModel has an available update.
184+
"""
185+
held, pinned = check_outdated_deps._load_config("backend")
186+
other_held = [
187+
package
188+
for package in held
189+
if not check_outdated_deps.matches(package, "sqlmodel")
190+
]
191+
outdated = ["sqlmodel"] if sqlmodel_outdated else []
192+
reportable, suppressed, stale = check_outdated_deps.partition(
193+
outdated=[*other_held, *outdated],
194+
installed=[*other_held, "sqlmodel"],
195+
held=held,
196+
pinned=pinned,
197+
)
198+
assert reportable == outdated
199+
assert suppressed == other_held
200+
assert stale == []

‎tests/units/test_model.py‎

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import datetime
12
import math
3+
import tomllib
24
from pathlib import Path
35
from unittest import mock
46

57
import pytest
8+
from packaging.requirements import Requirement
69
from reflex_base.constants.state import FIELD_MARKER
710
from reflex_base.event import Event
811

@@ -23,8 +26,74 @@
2326
)
2427

2528
pytest.importorskip("alembic")
26-
pytest.importorskip("sqlalchemy")
27-
pytest.importorskip("sqlmodel")
29+
sa = pytest.importorskip("sqlalchemy")
30+
sqlmodel = pytest.importorskip("sqlmodel")
31+
32+
33+
@pytest.mark.parametrize("version", ["0.0.45", "0.0.47"])
34+
def test_db_extra_accepts_utc_sqlmodel(version: str):
35+
"""The database extra must accept versions used to generate UTC migrations.
36+
37+
Args:
38+
version: A SQLModel version supporting UTCDateTime.
39+
"""
40+
project = tomllib.loads((Path(__file__).parents[2] / "pyproject.toml").read_text())
41+
requirement = next(
42+
parsed
43+
for dep in project["project"]["optional-dependencies"]["db"]
44+
if (parsed := Requirement(dep)).name == "sqlmodel"
45+
)
46+
assert requirement.specifier.contains(version)
47+
48+
49+
@pytest.mark.skipif(
50+
not hasattr(sqlmodel, "UTCDateTime"), reason="SQLModel before 0.0.45"
51+
)
52+
def test_utc_datetime_migration_and_roundtrip(
53+
tmp_working_dir: Path,
54+
monkeypatch: pytest.MonkeyPatch,
55+
model_registry: type[ModelRegistry],
56+
):
57+
"""Generated UTC migrations work on fresh databases and retain aware datetimes.
58+
59+
Args:
60+
tmp_working_dir: The database and migration directory.
61+
monkeypatch: The configuration patch fixture.
62+
model_registry: The isolated model registry.
63+
"""
64+
monkeypatch.setattr(
65+
reflex.constants, "ALEMBIC_CONFIG", str(tmp_working_dir / "alembic.ini")
66+
)
67+
config = mock.Mock(db_url=f"sqlite:///{tmp_working_dir}/reflex.db")
68+
monkeypatch.setattr(reflex.model, "get_config", lambda: config)
69+
alembic_init()
70+
71+
class UTCPost(Model, table=True):
72+
created_at: datetime.datetime
73+
74+
with get_engine().connect() as connection:
75+
assert alembic_autogenerate(connection=connection, message="UTC datetime")
76+
migration = next((tmp_working_dir / "alembic" / "versions").glob("*.py"))
77+
assert "sqlmodel.sql.sqltypes.UTCDateTime()" in migration.read_text()
78+
assert migrate()
79+
80+
value = datetime.datetime(
81+
2026, 9, 21, 12, tzinfo=datetime.timezone(datetime.timedelta(hours=5))
82+
)
83+
with reflex.model.session() as session:
84+
session.add(UTCPost(created_at=value))
85+
session.commit()
86+
stored = session.exec(sqlmodel.select(UTCPost)).one()
87+
assert stored.created_at == value
88+
assert stored.created_at.utcoffset() == datetime.timedelta(0)
89+
90+
get_engine().dispose()
91+
(tmp_working_dir / "reflex.db").unlink()
92+
assert migrate()
93+
with reflex.model.session() as session:
94+
assert session.exec(sqlmodel.select(UTCPost)).all() == []
95+
96+
model_registry.get_metadata().clear()
2897

2998

3099
@pytest.fixture
@@ -115,6 +184,10 @@ class AlembicThing(Model, table=True): # pyright: ignore [reportRedeclaration]
115184
version_scripts = list(versions.glob("*.py"))
116185
assert len(version_scripts) == 1
117186
assert version_scripts[0].name.endswith("initial_revision.py")
187+
assert set(sa.inspect(get_engine()).get_table_names()) == {
188+
"alembic_version",
189+
"alembicthing",
190+
}
118191

119192
with reflex.model.session() as session:
120193
session.add(AlembicThing(id=None, t1="foo"))
@@ -263,7 +336,8 @@ class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaratio
263336
class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaration]
264337
t1: str
265338
created: datetime.datetime = sqlmodel.Field(
266-
default_factory=datetime.datetime.now
339+
default_factory=datetime.datetime.now,
340+
sa_type=sa.DateTime(timezone=False),
267341
)
268342
count: int = 5
269343

@@ -290,7 +364,8 @@ class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaratio
290364
class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaration]
291365
t1: str
292366
created: datetime.datetime = sqlmodel.Field(
293-
default_factory=datetime.datetime.now
367+
default_factory=datetime.datetime.now,
368+
sa_type=sa.DateTime(timezone=False),
294369
)
295370
count: int = 5
296371
note: str | None = sqlmodel.Field(default_factory=lambda: "generated")

‎uv.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)