feat: add SQLite migration runner
This commit is contained in:
parent
bfa5b2e8d0
commit
627675fff1
2 changed files with 90 additions and 0 deletions
48
src/fastapi_oidc_op/store/sqlite/migrations.py
Normal file
48
src/fastapi_oidc_op/store/sqlite/migrations.py
Normal file
|
|
@ -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
|
||||||
42
tests/test_store/test_migrations.py
Normal file
42
tests/test_store/test_migrations.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue