from sqlalchemy import create_engine, inspect, text from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from app.db.models import Base # Columns added to existing tables after their initial release. create_all() # only creates missing tables, never adds columns to tables that already # exist, so a column added to a model here must also be listed below or an # already-deployed database will never receive it and the app will crash # reading/writing that column. _ADDITIVE_COLUMNS: dict[str, list[tuple[str, str]]] = { "sync_users": [ ("notify_email_enabled", "BOOLEAN NOT NULL DEFAULT 0"), ("notification_email", "VARCHAR(255)"), ], } def create_db_engine(database_url: str) -> Engine: connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {} return create_engine(database_url, connect_args=connect_args, future=True) def create_session_factory(engine: Engine) -> sessionmaker[Session]: return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) def initialize_schema(engine: Engine) -> None: Base.metadata.create_all(engine) _apply_additive_migrations(engine) def _apply_additive_migrations(engine: Engine) -> None: if engine.dialect.name != "sqlite": # ALTER TABLE ... ADD COLUMN syntax/type names below are only # verified against sqlite, the only backend this app is deployed # against; a fresh create_all() on another backend already has every # current column, so skipping here only matters for a pre-existing # non-sqlite database, which does not exist in practice. return inspector = inspect(engine) existing_tables = set(inspector.get_table_names()) with engine.begin() as conn: for table, columns in _ADDITIVE_COLUMNS.items(): if table not in existing_tables: continue existing_columns = {col["name"] for col in inspector.get_columns(table)} for name, ddl_type in columns: if name not in existing_columns: conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl_type}"))