From 627675fff1535d167332a6ba4d36f0cbf1fb8ff0 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Fri, 13 Feb 2026 13:02:34 +0100 Subject: [PATCH] feat: add SQLite migration runner --- .../store/sqlite/migrations.py | 48 +++++++++++++++++++ tests/test_store/test_migrations.py | 42 ++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/fastapi_oidc_op/store/sqlite/migrations.py create mode 100644 tests/test_store/test_migrations.py diff --git a/src/fastapi_oidc_op/store/sqlite/migrations.py b/src/fastapi_oidc_op/store/sqlite/migrations.py new file mode 100644 index 0000000..afc5628 --- /dev/null +++ b/src/fastapi_oidc_op/store/sqlite/migrations.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import aiosqlite + + +async def run_migrations(db: aiosqlite.Connection, migrations_dir: Path) -> int: + """Apply unapplied SQL migration files in order. Returns count of newly applied migrations.""" + if not migrations_dir.is_dir(): + raise FileNotFoundError(f"Migrations directory not found: {migrations_dir}") + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """ + ) + await db.commit() + + async with db.execute("SELECT filename FROM _migrations") as cursor: + applied = {row[0] async for row in cursor} + + migration_files = sorted(migrations_dir.glob("*.sql")) + + count = 0 + for migration_file in migration_files: + if migration_file.name in applied: + continue + sql = migration_file.read_text(encoding="utf-8") + await db.execute("BEGIN") + try: + for statement in sql.split(";"): + statement = statement.strip() + if statement: + await db.execute(statement) + await db.execute( + "INSERT INTO _migrations (filename) VALUES (?)", + (migration_file.name,), + ) + await db.commit() + except Exception: + await db.rollback() + raise + count += 1 + + return count diff --git a/tests/test_store/test_migrations.py b/tests/test_store/test_migrations.py new file mode 100644 index 0000000..1ca8f68 --- /dev/null +++ b/tests/test_store/test_migrations.py @@ -0,0 +1,42 @@ +from pathlib import Path + +import aiosqlite + +from fastapi_oidc_op.store.sqlite.migrations import run_migrations + +MIGRATIONS_DIR = ( + Path(__file__).resolve().parent.parent.parent / "src" / "fastapi_oidc_op" / "store" / "sqlite" / "migrations" +) + + +async def test_run_migrations_applies_initial() -> None: + async with aiosqlite.connect(":memory:") as db: + await db.execute("PRAGMA foreign_keys=ON") + count = await run_migrations(db, MIGRATIONS_DIR) + assert count == 1 + async with db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") as cursor: + row = await cursor.fetchone() + assert row is not None + + +async def test_run_migrations_skips_already_applied() -> None: + async with aiosqlite.connect(":memory:") as db: + await db.execute("PRAGMA foreign_keys=ON") + first_count = await run_migrations(db, MIGRATIONS_DIR) + second_count = await run_migrations(db, MIGRATIONS_DIR) + assert first_count == 1 + assert second_count == 0 + + +async def test_run_migrations_creates_all_tables() -> None: + async with aiosqlite.connect(":memory:") as db: + await db.execute("PRAGMA foreign_keys=ON") + await run_migrations(db, MIGRATIONS_DIR) + async with db.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") as cursor: + tables = [row[0] async for row in cursor] + assert "users" in tables + assert "user_groups" in tables + assert "webauthn_credentials" in tables + assert "password_credentials" in tables + assert "magic_links" in tables + assert "_migrations" in tables