Coverage for tests / test_datetime.py: 100%
216 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-11-09 00:02 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-11-09 00:02 +0000
1from collections.abc import Callable 1abcdefghi
2from datetime import date, datetime, time, timedelta, timezone, tzinfo 1abcdefghi
3from io import StringIO 1abcdefghi
4from operator import add, sub 1abcdefghi
5from typing import Annotated, Any 1abcdefghi
7import pytest 1abcdefghi
8from alembic.autogenerate import compare_metadata, render_python_code 1abcdefghi
9from alembic.migration import MigrationContext 1abcdefghi
10from alembic.operations import Operations, ops 1abcdefghi
11from pydantic import AwareDatetime, NaiveDatetime, ValidationError 1abcdefghi
12from sqlalchemy import Column, Date, DateTime, Engine, Interval, Time 1abcdefghi
13from sqlalchemy.dialects import mysql, postgresql, sqlite 1abcdefghi
14from sqlalchemy.exc import StatementError 1abcdefghi
15from sqlalchemy.schema import CreateTable 1abcdefghi
16from sqlmodel import ( 1abcdefghi
17 Field,
18 Session,
19 SQLModel,
20 UTCDateTime,
21 create_engine,
22 select,
23 update,
24)
27@pytest.mark.parametrize("nullable", [False, True]) 1abcdefghi
28@pytest.mark.parametrize( 1abcdefghi
29 "annotation, timezone_enabled, error_type",
30 [
31 (datetime, True, None),
32 (AwareDatetime, True, "timezone_aware"),
33 (NaiveDatetime, False, "timezone_naive"),
34 (Annotated[datetime, AwareDatetime], True, "timezone_aware"),
35 (Annotated[datetime, NaiveDatetime], False, "timezone_naive"),
36 (Annotated[datetime, AwareDatetime()], True, "timezone_aware"),
37 (Annotated[datetime, NaiveDatetime()], False, "timezone_naive"),
38 (Annotated[NaiveDatetime, AwareDatetime], True, "timezone_aware"),
39 (Annotated[AwareDatetime, NaiveDatetime], False, "timezone_naive"),
40 (Annotated[datetime, NaiveDatetime, AwareDatetime], True, "timezone_aware"),
41 (Annotated[datetime, AwareDatetime, NaiveDatetime], False, "timezone_naive"),
42 (Annotated[datetime, "other metadata"], True, None),
43 ],
44)
45def test_datetime_types( 1abcdefghi
46 annotation: Any, timezone_enabled: bool, error_type: str | None, nullable: bool
47) -> None:
48 if nullable: 1stuvwxyzA
49 annotation = annotation | None 1stuvwxyzA
51 class Event(SQLModel, table=True): 1stuvwxyzA
52 id: int | None = Field(default=None, primary_key=True) 1stuvwxyzA
53 occurred_at: annotation 1stuvwxyzA
55 column = SQLModel.metadata.tables["event"].c.occurred_at 1stuvwxyzA
56 assert isinstance(column.type, UTCDateTime if timezone_enabled else DateTime) 1stuvwxyzA
57 assert column.type.timezone is timezone_enabled 1stuvwxyzA
58 assert column.nullable is nullable 1stuvwxyzA
60 aware = datetime(2026, 1, 1, 12, tzinfo=timezone(timedelta(hours=2))) 1stuvwxyzA
61 naive = datetime(2026, 1, 1, 12) 1stuvwxyzA
62 valid = aware if timezone_enabled else naive 1stuvwxyzA
63 assert Event.model_validate({"occurred_at": valid}).occurred_at == valid 1stuvwxyzA
64 if error_type is None: 1stuvwxyzA
65 assert Event.model_validate({"occurred_at": naive}).occurred_at == naive 1stuvwxyzA
66 else:
67 invalid = naive if timezone_enabled else aware 1stuvwxyzA
68 with pytest.raises(ValidationError) as exc_info: 1stuvwxyzA
69 Event.model_validate({"occurred_at": invalid}) 1stuvwxyzA
70 assert exc_info.value.errors()[0]["type"] == error_type 1stuvwxyzA
71 if nullable: 1stuvwxyzA
72 assert Event.model_validate({"occurred_at": None}).occurred_at is None 1stuvwxyzA
75def test_inherited_annotated_datetime() -> None: 1abcdefghi
76 class EventBase(SQLModel): 2kblbmbnbobpbqbrbsb
77 occurred_at: Annotated[datetime, NaiveDatetime, Field(index=True)] 2kblbmbnbobpbqbrbsb
79 class Event(EventBase, table=True): 2kblbmbnbobpbqbrbsb
80 id: int | None = Field(default=None, primary_key=True) 2kblbmbnbobpbqbrbsb
82 column = SQLModel.metadata.tables["event"].c.occurred_at 2kblbmbnbobpbqbrbsb
83 assert column.type.timezone is False 2kblbmbnbobpbqbrbsb
84 assert column.index is True 2kblbmbnbobpbqbrbsb
87@pytest.mark.parametrize("annotation", [datetime, AwareDatetime, NaiveDatetime]) 1abcdefghi
88@pytest.mark.parametrize("timezone_enabled", [False, True]) 1abcdefghi
89@pytest.mark.parametrize("use_column", [False, True]) 1abcdefghi
90def test_explicit_datetime_type( 1abcdefghi
91 annotation: Any, timezone_enabled: bool, use_column: bool
92) -> None:
93 sa_type = DateTime(timezone=timezone_enabled) 2bbcbdbebfbgbhbibjb
94 if use_column: 2bbcbdbebfbgbhbibjb
95 field = Field(sa_column=Column(sa_type)) 2bbcbdbebfbgbhbibjb
96 else:
97 field = Field(sa_type=sa_type) 2bbcbdbebfbgbhbibjb
99 class Event(SQLModel, table=True): 2bbcbdbebfbgbhbibjb
100 id: int | None = Field(default=None, primary_key=True) 2bbcbdbebfbgbhbibjb
101 occurred_at: annotation = field 2bbcbdbebfbgbhbibjb
103 assert SQLModel.metadata.tables["event"].c.occurred_at.type is sa_type 2bbcbdbebfbgbhbibjb
106def test_explicit_datetime_type_class() -> None: 1abcdefghi
107 class Event(SQLModel, table=True): 2CbDbEbFbGbHbIbJbKb
108 id: int | None = Field(default=None, primary_key=True) 2CbDbEbFbGbHbIbJbKb
109 occurred_at: datetime = Field(sa_type=DateTime) 2CbDbEbFbGbHbIbJbKb
111 assert SQLModel.metadata.tables["event"].c.occurred_at.type.timezone is False 2CbDbEbFbGbHbIbJbKb
114def test_other_temporal_types() -> None: 1abcdefghi
115 class Event(SQLModel, table=True): 2] ^ _ ` { | } ~ ab
116 id: int | None = Field(default=None, primary_key=True) 2] ^ _ ` { | } ~ ab
117 day: date 2] ^ _ ` { | } ~ ab
118 at: time 2] ^ _ ` { | } ~ ab
119 duration: timedelta 2] ^ _ ` { | } ~ ab
121 columns = SQLModel.metadata.tables["event"].c 2] ^ _ ` { | } ~ ab
122 assert isinstance(columns.day.type, Date) 2] ^ _ ` { | } ~ ab
123 assert isinstance(columns.at.type, Time) 2] ^ _ ` { | } ~ ab
124 assert isinstance(columns.duration.type, Interval) 2] ^ _ ` { | } ~ ab
127@pytest.mark.parametrize( 1abcdefghi
128 "dialect, aware_type, naive_type",
129 [
130 (
131 postgresql.dialect(),
132 "TIMESTAMP WITH TIME ZONE",
133 "TIMESTAMP WITHOUT TIME ZONE",
134 ),
135 (mysql.dialect(), "DATETIME", "DATETIME"),
136 (sqlite.dialect(), "DATETIME", "DATETIME"),
137 ],
138)
139def test_datetime_column_types(dialect: Any, aware_type: str, naive_type: str) -> None: 1abcdefghi
140 class Event(SQLModel, table=True): 2tbubvbwbxbybzbAbBb
141 id: int | None = Field(default=None, primary_key=True) 2tbubvbwbxbybzbAbBb
142 occurred_at: datetime 2tbubvbwbxbybzbAbBb
143 local_time: NaiveDatetime 2tbubvbwbxbybzbAbBb
145 ddl = str(CreateTable(SQLModel.metadata.tables["event"]).compile(dialect=dialect)) 2tbubvbwbxbybzbAbBb
146 assert f"occurred_at {aware_type} NOT NULL" in ddl 2tbubvbwbxbybzbAbBb
147 assert f"local_time {naive_type} NOT NULL" in ddl 2tbubvbwbxbybzbAbBb
150def test_datetime_round_trip(database_engine: Engine) -> None: 1abcdefghi
151 class Event(SQLModel, table=True): 1jklmnopqr
152 id: int | None = Field(default=None, primary_key=True) 1jklmnopqr
153 occurred_at: datetime 1jklmnopqr
154 local_time: NaiveDatetime 1jklmnopqr
155 optional_time: datetime | None = None 1jklmnopqr
157 aware = datetime(2026, 1, 1, 12, tzinfo=timezone(timedelta(hours=2))) 1jklmnopqr
158 naive = datetime(2026, 1, 1, 12) 1jklmnopqr
159 engine = database_engine 1jklmnopqr
160 SQLModel.metadata.create_all(engine) 1jklmnopqr
161 with Session(engine) as session: 1jklmnopqr
162 event = Event(occurred_at=aware, local_time=naive) 1jklmnopqr
163 session.add(event) 1jklmnopqr
164 session.flush() 1jklmnopqr
165 # Binding doesn't change the original value on the model.
166 assert event.occurred_at is aware 1jklmnopqr
167 session.commit() 1jklmnopqr
168 session.refresh(event) 1jklmnopqr
169 assert event.occurred_at == aware 1jklmnopqr
170 assert event.occurred_at.tzinfo is timezone.utc 1jklmnopqr
171 assert event.local_time == naive 1jklmnopqr
172 assert event.optional_time is None 1jklmnopqr
173 event_id = event.id 1jklmnopqr
175 with Session(engine) as session: 1jklmnopqr
176 loaded = session.get(Event, event_id) 1jklmnopqr
177 assert loaded is not None 1jklmnopqr
178 assert loaded.occurred_at.tzinfo is timezone.utc 1jklmnopqr
179 # Query parameters use the same UTC normalization as inserted values.
180 assert ( 1jklmnopqr
181 session.exec(select(Event).where(Event.occurred_at == aware)).one()
182 is loaded
183 )
184 assert session.exec(select(Event.occurred_at)).one().tzinfo is timezone.utc 1jklmnopqr
185 later = aware + timedelta(hours=1) 1jklmnopqr
186 session.exec(update(Event).values(occurred_at=later)) 1jklmnopqr
187 session.commit() 1jklmnopqr
188 session.refresh(loaded) 1jklmnopqr
189 assert loaded.occurred_at == later 1jklmnopqr
190 assert loaded.occurred_at.tzinfo is timezone.utc 1jklmnopqr
193class UndefinedOffset(tzinfo): 1abcdefghi
194 def utcoffset(self, dt: datetime | None) -> None: 1abcdefghi
195 return None 1BCDEFGHIJ
198@pytest.mark.parametrize("offset", [None, UndefinedOffset()]) 1abcdefghi
199@pytest.mark.parametrize("operation", ["insert", "update", "filter"]) 1abcdefghi
200def test_naive_database_parameters( 1abcdefghi
201 database_engine: Engine, offset: tzinfo | None, operation: str
202) -> None:
203 class Event(SQLModel, table=True): 1BCDEFGHIJ
204 id: int | None = Field(default=None, primary_key=True) 1BCDEFGHIJ
205 occurred_at: datetime 1BCDEFGHIJ
207 naive = datetime(2026, 1, 1, 12, tzinfo=offset) 1BCDEFGHIJ
208 event = Event.model_validate({"occurred_at": naive}) 1BCDEFGHIJ
209 # Plain datetime validation remains permissive, including a tzinfo object
210 # that doesn't actually provide an offset.
211 assert event.occurred_at is naive 1BCDEFGHIJ
212 engine = database_engine 1BCDEFGHIJ
213 SQLModel.metadata.create_all(engine) 1BCDEFGHIJ
214 with Session(engine) as session: 1BCDEFGHIJ
215 with pytest.raises(StatementError, match="timezone information") as exc_info: 1BCDEFGHIJ
216 if operation == "insert": 1BCDEFGHIJ
217 session.add(event) 1BCDEFGHIJ
218 session.flush() 1BCDEFGHIJ
219 elif operation == "update": 1BCDEFGHIJ
220 session.exec(update(Event).values(occurred_at=naive)) 1BCDEFGHIJ
221 else:
222 session.exec(select(Event).where(Event.occurred_at > naive)) 1BCDEFGHIJ
223 assert isinstance(exc_info.value.orig, ValueError) 1BCDEFGHIJ
224 assert "NaiveDatetime" in str(exc_info.value.orig) 1BCDEFGHIJ
227@pytest.mark.parametrize("timezone_enabled", [False, True]) 1abcdefghi
228def test_sqlite_explicit_datetime_bypasses_utc_processing( 1abcdefghi
229 timezone_enabled: bool,
230) -> None:
231 class Event(SQLModel, table=True): 1#$%'()*+,
232 id: int | None = Field(default=None, primary_key=True) 1#$%'()*+,
233 occurred_at: datetime = Field(sa_type=DateTime(timezone=timezone_enabled)) 1#$%'()*+,
235 aware = datetime(2026, 1, 1, 12, tzinfo=timezone(timedelta(hours=2))) 1#$%'()*+,
236 engine = create_engine("sqlite://") 1#$%'()*+,
237 SQLModel.metadata.create_all(engine) 1#$%'()*+,
238 with Session(engine) as session: 1#$%'()*+,
239 event = Event(occurred_at=aware) 1#$%'()*+,
240 session.add(event) 1#$%'()*+,
241 session.commit() 1#$%'()*+,
242 session.refresh(event) 1#$%'()*+,
243 assert event.occurred_at == datetime(2026, 1, 1, 12) 1#$%'()*+,
244 assert event.occurred_at.tzinfo is None 1#$%'()*+,
247def test_postgresql_datetime_processing() -> None: 1abcdefghi
248 # PostgreSQL drivers receive aware parameters and may return values in the
249 # connection's timezone. Exercise the complete SQLAlchemy type processors.
250 dialect = postgresql.dialect() 1KLMNOPQRS
251 type_ = UTCDateTime().dialect_impl(dialect) 1KLMNOPQRS
252 bind = type_.bind_processor(dialect) 1KLMNOPQRS
253 result = type_.result_processor(dialect, None) 1KLMNOPQRS
254 assert bind is not None 1KLMNOPQRS
255 assert result is not None 1KLMNOPQRS
256 aware = datetime(2026, 1, 1, 12, tzinfo=timezone(timedelta(hours=2))) 1KLMNOPQRS
257 bound = bind(aware) 1KLMNOPQRS
258 assert bound == aware 1KLMNOPQRS
259 assert bound.tzinfo is timezone.utc 1KLMNOPQRS
260 loaded = result(aware) 1KLMNOPQRS
261 assert loaded == aware 1KLMNOPQRS
262 assert loaded.tzinfo is timezone.utc 1KLMNOPQRS
263 assert bind(None) is None 1KLMNOPQRS
264 assert result(None) is None 1KLMNOPQRS
267@pytest.mark.parametrize( 1abcdefghi
268 "operation, reverse",
269 [(add, False), (sub, False), (add, True)],
270)
271def test_postgresql_datetime_interval_arithmetic( 1abcdefghi
272 operation: Callable[[Any, Any], Any], reverse: bool
273) -> None:
274 class Event(SQLModel, table=True): 1-./:;=?@[
275 id: int | None = Field(default=None, primary_key=True) 1-./:;=?@[
276 occurred_at: datetime 1-./:;=?@[
278 occurred_at = SQLModel.metadata.tables["event"].c.occurred_at 1-./:;=?@[
279 duration = timedelta(days=30) 1-./:;=?@[
280 expression = ( 1-./:;=?@[
281 operation(duration, occurred_at)
282 if reverse
283 else operation(occurred_at, duration)
284 )
285 # Adding or subtracting a duration still produces a UTC datetime.
286 assert isinstance(expression.type, UTCDateTime) 1-./:;=?@[
287 # Exercise the actual parameter processor used for database execution.
288 parameter = expression.left if reverse else expression.right 1-./:;=?@[
289 dialect = postgresql.dialect() 1-./:;=?@[
290 bind = parameter.type.dialect_impl(dialect).bind_processor(dialect) 1-./:;=?@[
291 bound = bind(parameter.value) if bind is not None else parameter.value 1-./:;=?@[
292 assert bound == duration 1-./:;=?@[
295def test_existing_sqlite_utc_data() -> None: 1abcdefghi
296 class Event(SQLModel, table=True): 1TUVWXYZ01
297 id: int | None = Field(default=None, primary_key=True) 1TUVWXYZ01
298 occurred_at: datetime 1TUVWXYZ01
300 engine = create_engine("sqlite://") 1TUVWXYZ01
301 with engine.begin() as connection: 1TUVWXYZ01
302 connection.exec_driver_sql( 1TUVWXYZ01
303 "CREATE TABLE event (id INTEGER NOT NULL PRIMARY KEY, "
304 "occurred_at DATETIME NOT NULL)"
305 )
306 connection.exec_driver_sql( 1TUVWXYZ01
307 "INSERT INTO event VALUES (1, '2026-01-01 12:00:00.000000')"
308 )
309 context = MigrationContext.configure(connection, opts={"compare_type": True}) 1TUVWXYZ01
310 assert compare_metadata(context, SQLModel.metadata) == [] 1TUVWXYZ01
312 with Session(engine) as session: 1TUVWXYZ01
313 event = session.get(Event, 1) 1TUVWXYZ01
314 assert event is not None 1TUVWXYZ01
315 assert event.occurred_at == datetime(2026, 1, 1, 12, tzinfo=timezone.utc) 1TUVWXYZ01
316 assert event.occurred_at.tzinfo is timezone.utc 1TUVWXYZ01
319def test_alembic_datetime_rendering() -> None: 1abcdefghi
320 class Event(SQLModel, table=True): 123456789!
321 id: int | None = Field(default=None, primary_key=True) 123456789!
322 occurred_at: datetime 123456789!
324 table = SQLModel.metadata.tables["event"] 123456789!
325 migration = ops.UpgradeOps(ops=[ops.CreateTableOp.from_table(table)]) 123456789!
326 rendered = render_python_code(migration) 123456789!
327 assert "sqlmodel.sql.sqltypes.UTCDateTime()" in rendered 123456789!
328 # Execute the generated migration, including the custom type constructor.
329 namespace: dict[str, Any] = {} 123456789!
330 exec( 123456789!
331 "import sqlalchemy as sa\nimport sqlmodel.sql.sqltypes\n"
332 "from alembic import op\ndef upgrade():\n " + rendered,
333 namespace,
334 )
335 output = StringIO() 123456789!
336 context = MigrationContext.configure( 123456789!
337 dialect_name="postgresql",
338 opts={"as_sql": True, "output_buffer": output},
339 )
340 with Operations.context(context): 123456789!
341 namespace["upgrade"]() 123456789!
342 assert "occurred_at TIMESTAMP WITH TIME ZONE NOT NULL" in output.getvalue() 123456789!