"""SQLModel database setup.""" from __future__ import annotations import logging import os from collections.abc import Generator from typing import Optional from sqlalchemy import inspect, text from sqlmodel import Session, SQLModel, create_engine from config import settings logger = logging.getLogger("stackpilot.database") os.makedirs(settings.DATA_DIR, exist_ok=True) _DB_PATH = os.path.join(settings.DATA_DIR, "stackpilot.db") _DB_URL = f"sqlite:///{_DB_PATH}" engine = create_engine( _DB_URL, echo=False, connect_args={"check_same_thread": False}, ) def _default_literal(col) -> Optional[str]: """SQL literal for a column's scalar default, or None if it has none. Only plain values are rendered — a callable default (``default_factory``, e.g. a timestamp) has no fixed literal, so those columns are added nullable as before and filled by the ORM on the next write. """ default = col.default if default is None or not getattr(default, "is_scalar", False): return None value = default.arg if isinstance(value, bool): return "1" if value else "0" if isinstance(value, (int, float)): return str(value) if isinstance(value, str): escaped = value.replace("'", "''") return f"'{escaped}'" return None def _ensure_model_columns() -> None: """Add columns that models define but a pre-existing table is missing. ``SQLModel.create_all`` creates missing *tables* but never ALTERs an existing one, so installs that predate a newly-added column keep the old schema — and every ORM query that names the column fails with ``OperationalError: no such column``. For each mapped table we diff the model's columns against the live table and ``ADD COLUMN`` the safe (nullable, or defaulted) ones. Idempotent: on a fresh DB create_all already made every column, so this is a no-op. Requires ``models`` to have been imported, or ``SQLModel.metadata`` is empty and this silently does nothing. :func:`init_db` imports it first. A column with a scalar default is added ``NOT NULL DEFAULT `` so existing rows are backfilled in the same statement. Without that clause SQLite fills them with NULL, which is how a new non-nullable field turns into a runtime surprise — for ``User.token_version`` it would have meant every existing session failing its version check after the upgrade. """ insp = inspect(engine) live_tables = set(insp.get_table_names()) with engine.begin() as conn: for table_name, table in SQLModel.metadata.tables.items(): if table_name not in live_tables: continue existing = {c["name"] for c in insp.get_columns(table_name)} for col in table.columns: if col.name in existing: continue # SQLite can only ADD a NOT NULL column if it has a default to # backfill existing rows; skip the rest rather than crash. if not col.nullable and col.default is None and col.server_default is None: logger.warning( "Cannot auto-add non-nullable column %s.%s (no default); " "manual migration needed", table_name, col.name ) continue ddl = f'ALTER TABLE "{table_name}" ADD COLUMN "{col.name}" ' ddl += col.type.compile(dialect=engine.dialect) if (literal := _default_literal(col)) is not None: # Backfills existing rows and satisfies SQLite's rule that a # NOT NULL column may only be added together with a default. ddl += f" NOT NULL DEFAULT {literal}" conn.execute(text(ddl)) logger.info("Schema migration: added column %s.%s", table_name, col.name) def init_db() -> None: # Import models so they are registered on SQLModel.metadata. import models # noqa: F401 SQLModel.metadata.create_all(engine) _ensure_model_columns() def get_session() -> Generator[Session, None, None]: with Session(engine) as session: yield session