Coverage for tests/runtime/test_database_initialization.py: 99%
206 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 21:13 +0300
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 21:13 +0300
1"""Database initialization and schema verification tests."""
3from __future__ import annotations
5from pathlib import Path
6from sqlite3 import connect
7from tempfile import TemporaryDirectory
8from typing import Any, ClassVar, cast
10from snektest import assert_eq, assert_raises, assert_true, test
12from snekql import (
13 MISSING,
14 Database,
15 DatabaseRuntimeError,
16 Fetched,
17 Index,
18 Integer,
19 Model,
20 Pending,
21 SchemaError,
22 SchemaVerificationError,
23 Text,
24 sqlite,
25)
26from tests.helpers import NULL_LOGGER
29def _execute_sql(database_path: Path, sql: str) -> None:
30 connection = connect(database_path)
31 try:
32 _ = connection.execute(sql)
33 connection.commit()
34 finally:
35 connection.close()
38def _fetch_create_table(database_path: Path, table_name: str) -> str:
39 connection = connect(database_path)
40 try:
41 cursor = connection.execute(
42 "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
43 (table_name,),
44 )
45 row = cursor.fetchone()
46 assert row is not None
47 value = row[0]
48 assert isinstance(value, str)
49 return value
50 finally:
51 connection.close()
54def _fetch_create_indexes(database_path: Path, table_name: str) -> list[str]:
55 connection = connect(database_path)
56 try:
57 cursor = connection.execute(
58 """
59 SELECT sql FROM sqlite_master
60 WHERE type = 'index' AND tbl_name = ?
61 ORDER BY rowid
62 """,
63 (table_name,),
64 )
65 values: list[str] = []
66 for row in cursor.fetchall():
67 value = row[0]
68 assert isinstance(value, str)
69 values.append(value)
70 return values
71 finally:
72 connection.close()
75def _table_exists(database_path: Path, table_name: str) -> bool:
76 connection = connect(database_path)
77 try:
78 cursor = connection.execute(
79 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
80 (table_name,),
81 )
82 return cursor.fetchone() is not None
83 finally:
84 connection.close()
87class _RecordingStructuredLogger:
88 """Structured logger fake that stores event calls for assertions."""
90 def __init__(self) -> None:
91 self.events: list[tuple[str, str, dict[str, object]]] = []
93 def debug(self, event: str, **fields: object) -> None:
94 self.events.append(("debug", event, fields))
96 def info(self, event: str, **fields: object) -> None:
97 self.events.append(("info", event, fields))
99 def warning(self, event: str, **fields: object) -> None:
100 self.events.append(("warning", event, fields))
102 def error(self, event: str, **fields: object) -> None:
103 self.events.append(("error", event, fields))
106@test(mark="medium")
107async def initialize_creates_missing_strict_tables() -> None:
108 """Initialization creates deterministic quoted STRICT tables."""
110 class User[S = Pending](Model[S, "User[Fetched]"]):
111 """Table model used for schema creation."""
113 id: User.GenCol[int] = Integer(
114 primary_key=True,
115 auto_increment=True,
116 default=MISSING,
117 )
118 email: User.Col[str] = Text(nullable=False)
120 with TemporaryDirectory() as directory:
121 database_path = Path(directory) / "app.db"
122 database = await Database.initialize(
123 logger=NULL_LOGGER, database=database_path, models=[User]
124 )
125 await database.close()
127 create_table = _fetch_create_table(database_path, "user")
129 expected_sql = (
130 'CREATE TABLE "user" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, '
131 '"email" TEXT NOT NULL) STRICT'
132 )
133 assert_eq(create_table, expected_sql)
136@test(mark="medium")
137async def initialize_accepts_sqlite_config_object() -> None:
138 """SQLite configuration objects select the SQLite runtime explicitly."""
140 class User[S = Pending](sqlite.Model[S, "User[Fetched]"]):
141 """Table model used for SQLite config initialization."""
143 id: User.GenCol[int] = sqlite.Integer(
144 primary_key=True,
145 auto_increment=True,
146 default=MISSING,
147 )
148 email: User.Col[str] = sqlite.Text(nullable=False)
150 with TemporaryDirectory() as directory:
151 database_path = Path(directory) / "app.db"
152 config = sqlite.Config(database=database_path, pool_size=2)
153 database = await Database.initialize(config, logger=NULL_LOGGER, models=[User])
154 await database.close()
156 create_table = _fetch_create_table(database_path, "user")
158 expected_sql = (
159 'CREATE TABLE "user" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, '
160 '"email" TEXT NOT NULL) STRICT'
161 )
162 assert_eq(create_table, expected_sql)
165@test(mark="medium")
166async def initialize_rejects_mixed_sqlite_config_and_legacy_database() -> None:
167 """A backend config cannot be combined with legacy database arguments."""
169 with TemporaryDirectory() as directory:
170 database_path = Path(directory) / "app.db"
171 config = sqlite.Config(database=database_path)
173 initialize = cast("Any", Database.initialize)
175 with assert_raises(DatabaseRuntimeError):
176 _ = await initialize(config, logger=NULL_LOGGER, database=database_path)
179@test(mark="medium")
180async def initialize_creates_column_unique_indexes_after_tables() -> None:
181 """Column unique declarations create separate deterministic unique indexes."""
183 class User[S = Pending](Model[S, "User[Fetched]"]):
184 """Table model with a unique public identifier."""
186 email: User.Col[str] = Text(nullable=False, unique=True)
187 status: User.Col[str] = Text(nullable=False)
189 with TemporaryDirectory() as directory:
190 database_path = Path(directory) / "app.db"
191 database = await Database.initialize(
192 logger=NULL_LOGGER, database=database_path, models=[User]
193 )
194 await database.close()
196 create_table = _fetch_create_table(database_path, "user")
197 create_indexes = _fetch_create_indexes(database_path, "user")
199 assert_eq(
200 create_table,
201 'CREATE TABLE "user" ("email" TEXT NOT NULL, "status" TEXT NOT NULL) STRICT',
202 )
203 assert_eq(
204 create_indexes,
205 ['CREATE UNIQUE INDEX "ux_user_email" ON "user" ("email")'],
206 )
209@test(mark="medium")
210async def initialize_creates_table_indexes_in_declaration_order() -> None:
211 """Table index declarations create deterministic index SQL after uniques."""
213 class User[S = Pending](Model[S, "User[Fetched]"]):
214 """Table model with single and composite table indexes."""
216 email: User.Col[str] = Text(nullable=False, unique=True)
217 status: User.Col[str] = Text(nullable=False)
218 tenant_id: User.Col[int] = Integer(nullable=False)
220 __indexes__: ClassVar[list[Index[Any]]] = [
221 Index(status),
222 Index(tenant_id, email, unique=True),
223 Index(tenant_id, name="ix_user_tenant_custom"),
224 ]
226 with TemporaryDirectory() as directory:
227 database_path = Path(directory) / "app.db"
228 database = await Database.initialize(
229 logger=NULL_LOGGER, database=database_path, models=[User]
230 )
231 await database.close()
233 create_indexes = _fetch_create_indexes(database_path, "user")
235 assert_eq(
236 create_indexes,
237 [
238 'CREATE UNIQUE INDEX "ux_user_email" ON "user" ("email")',
239 'CREATE INDEX "ix_user_status" ON "user" ("status")',
240 'CREATE UNIQUE INDEX "ux_user_tenant_id_email" ON "user" ("tenant_id", "email")',
241 'CREATE INDEX "ix_user_tenant_custom" ON "user" ("tenant_id")',
242 ],
243 )
246@test(mark="medium")
247async def initialize_accepts_only_path_objects_and_exact_memory_string() -> None:
248 """Only pathlib.Path and the exact in-memory database string are accepted."""
250 initialize = cast("Any", Database.initialize)
252 class OtherPath:
253 """Path-like object that is intentionally not pathlib.Path."""
255 def __fspath__(self) -> str:
256 return "app.db"
258 with assert_raises(DatabaseRuntimeError):
259 _ = await initialize(logger=NULL_LOGGER, database="app.db")
261 with assert_raises(DatabaseRuntimeError):
262 _ = await Database.initialize(
263 logger=NULL_LOGGER, database=cast("Any", "app.db")
264 )
266 with assert_raises(DatabaseRuntimeError):
267 _ = await Database.initialize(
268 logger=NULL_LOGGER, database=cast("Any", "sqlite:///app.db")
269 )
271 with assert_raises(DatabaseRuntimeError):
272 _ = await Database.initialize(
273 logger=NULL_LOGGER, database=cast("Any", b"app.db")
274 )
276 with assert_raises(DatabaseRuntimeError):
277 _ = await Database.initialize(
278 logger=NULL_LOGGER, database=cast("Any", OtherPath())
279 )
281 database = await Database.initialize(logger=NULL_LOGGER, database=":memory:")
282 await database.close()
285@test(mark="medium")
286async def initialize_verifies_existing_tables_after_controlled_normalization() -> None:
287 """Equivalent snekql DDL with formatting differences verifies cleanly."""
289 class User[S = Pending](Model[S, "User[Fetched]"]):
290 """Table model used for existing schema verification."""
292 id: User.GenCol[int] = Integer(
293 primary_key=True,
294 auto_increment=True,
295 default=MISSING,
296 )
297 email: User.Col[str] = Text(nullable=False)
299 with TemporaryDirectory() as directory:
300 database_path = Path(directory) / "app.db"
301 existing_sql = (
302 'CREATE TABLE "user" (\n'
303 ' "id" INTEGER PRIMARY KEY AUTOINCREMENT,\n'
304 ' "email" TEXT NOT NULL\n'
305 ") STRICT"
306 )
307 _execute_sql(database_path, existing_sql)
309 database = await Database.initialize(
310 logger=NULL_LOGGER, database=database_path, models=[User]
311 )
312 await database.close()
314 create_table = _fetch_create_table(database_path, "user")
316 assert_true("STRICT" in create_table)
319@test(mark="medium")
320async def strict_schema_policy_raises_on_index_drift() -> None:
321 """Strict schema verification rejects missing managed indexes."""
323 class User[S = Pending](Model[S, "User[Fetched]"]):
324 """Table model requiring an index for verification."""
326 email: User.Col[str] = Text(nullable=False, unique=True)
328 with TemporaryDirectory() as directory:
329 database_path = Path(directory) / "app.db"
330 _execute_sql(
331 database_path, 'CREATE TABLE "user" ("email" TEXT NOT NULL) STRICT'
332 )
334 with assert_raises(SchemaVerificationError):
335 _ = await Database.initialize(
336 logger=NULL_LOGGER, database=database_path, models=[User]
337 )
340@test(mark="medium")
341async def duplicate_resolved_index_names_are_rejected() -> None:
342 """Initialization rejects duplicate index names across configured models."""
344 class User[S = Pending](Model[S, "User[Fetched]"]):
345 """First model using an explicit index name."""
347 email: User.Col[str] = Text(nullable=False)
348 __indexes__: ClassVar[list[Index[Any]]] = [
349 Index(email, name="ix_conflict"),
350 ]
352 class Account[S = Pending](Model[S, "Account[Fetched]"]):
353 """Second model reusing the explicit index name."""
355 email: Account.Col[str] = Text(nullable=False)
356 __indexes__: ClassVar[list[Index[Any]]] = [
357 Index(email, name="ix_conflict"),
358 ]
360 with TemporaryDirectory() as directory:
361 database_path = Path(directory) / "app.db"
362 with assert_raises(SchemaError):
363 _ = await Database.initialize(
364 logger=NULL_LOGGER,
365 database=database_path,
366 models=[User, Account],
367 )
370@test(mark="medium")
371async def strict_schema_policy_raises_on_schema_drift() -> None:
372 """Strict schema verification rejects existing non-STRICT tables."""
374 class User[S = Pending](Model[S, "User[Fetched]"]):
375 """Table model used for drift detection."""
377 email: User.Col[str] = Text(nullable=False)
379 with TemporaryDirectory() as directory:
380 database_path = Path(directory) / "app.db"
381 _execute_sql(database_path, 'CREATE TABLE "user" ("email" TEXT NOT NULL)')
383 with assert_raises(SchemaVerificationError):
384 _ = await Database.initialize(
385 logger=NULL_LOGGER, database=database_path, models=[User]
386 )
389@test(mark="medium")
390async def warn_schema_policy_logs_drift_and_continues() -> None:
391 """Warn schema verification reports drift without blocking startup."""
393 class User[S = Pending](Model[S, "User[Fetched]"]):
394 """Table model used for warn policy drift detection."""
396 email: User.Col[str] = Text(nullable=False)
398 logger = _RecordingStructuredLogger()
399 with TemporaryDirectory() as directory:
400 database_path = Path(directory) / "app.db"
401 _execute_sql(database_path, 'CREATE TABLE "user" ("email" TEXT NOT NULL)')
403 database = await Database.initialize(
404 logger=logger,
405 database=database_path,
406 models=[User],
407 schema_policy="warn",
408 )
409 await database.close()
411 assert_true(
412 any(
413 level == "warning" and event == "schema drift detected"
414 for level, event, _fields in logger.events
415 )
416 )
419@test(mark="medium")
420async def duplicate_resolved_table_names_are_rejected() -> None:
421 """Initialization rejects duplicate table names before schema setup."""
423 class User[S = Pending](Model[S, "User[Fetched]"]):
424 """First model for duplicate table detection."""
426 __tablename__ = "account"
427 email: User.Col[str] = Text(nullable=False)
429 class Account[S = Pending](Model[S, "Account[Fetched]"]):
430 """Second model with the same resolved table name."""
432 email: Account.Col[str] = Text(nullable=False)
434 with TemporaryDirectory() as directory:
435 database_path = Path(directory) / "app.db"
436 with assert_raises(SchemaError):
437 _ = await Database.initialize(
438 logger=NULL_LOGGER,
439 database=database_path,
440 models=[User, Account],
441 )
444@test(mark="medium")
445async def schema_setup_rolls_back_created_tables_on_strict_drift() -> None:
446 """Schema setup is transactional for create plus verification failure."""
448 class CreatedFirst[S = Pending](Model[S, "CreatedFirst[Fetched]"]):
449 """Missing table that should be rolled back."""
451 email: CreatedFirst.Col[str] = Text(nullable=False)
453 class ExistingDrift[S = Pending](Model[S, "ExistingDrift[Fetched]"]):
454 """Existing drift table that aborts initialization."""
456 email: ExistingDrift.Col[str] = Text(nullable=False)
458 with TemporaryDirectory() as directory:
459 database_path = Path(directory) / "app.db"
460 _execute_sql(
461 database_path,
462 'CREATE TABLE "existing_drift" ("email" TEXT NOT NULL)',
463 )
465 with assert_raises(SchemaVerificationError):
466 _ = await Database.initialize(
467 logger=NULL_LOGGER,
468 database=database_path,
469 models=[CreatedFirst, ExistingDrift],
470 )
472 assert_true(not _table_exists(database_path, "created_first"))