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

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

6 

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) 

25 

26 

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

50 

51 class Event(SQLModel, table=True): 1stuvwxyzA

52 id: int | None = Field(default=None, primary_key=True) 1stuvwxyzA

53 occurred_at: annotation 1stuvwxyzA

54 

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

59 

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

73 

74 

75def test_inherited_annotated_datetime() -> None: 1abcdefghi

76 class EventBase(SQLModel): 2kblbmbnbobpbqbrbsb

77 occurred_at: Annotated[datetime, NaiveDatetime, Field(index=True)] 2kblbmbnbobpbqbrbsb

78 

79 class Event(EventBase, table=True): 2kblbmbnbobpbqbrbsb

80 id: int | None = Field(default=None, primary_key=True) 2kblbmbnbobpbqbrbsb

81 

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

85 

86 

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

98 

99 class Event(SQLModel, table=True): 2bbcbdbebfbgbhbibjb

100 id: int | None = Field(default=None, primary_key=True) 2bbcbdbebfbgbhbibjb

101 occurred_at: annotation = field 2bbcbdbebfbgbhbibjb

102 

103 assert SQLModel.metadata.tables["event"].c.occurred_at.type is sa_type 2bbcbdbebfbgbhbibjb

104 

105 

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

110 

111 assert SQLModel.metadata.tables["event"].c.occurred_at.type.timezone is False 2CbDbEbFbGbHbIbJbKb

112 

113 

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

120 

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

125 

126 

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

144 

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

148 

149 

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

156 

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

174 

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

191 

192 

193class UndefinedOffset(tzinfo): 1abcdefghi

194 def utcoffset(self, dt: datetime | None) -> None: 1abcdefghi

195 return None 1BCDEFGHIJ

196 

197 

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

206 

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

225 

226 

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#$%'()*+,

234 

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#$%'()*+,

245 

246 

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

265 

266 

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-./:;=?@[

277 

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-./:;=?@[

293 

294 

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

299 

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

311 

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

317 

318 

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!

323 

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!