"""Additive schema synchronisation. This project has no Alembic, by design: it is SQLite-only and the schema is created at startup. That was fine until the first live instance had data in it, at which point adding a column to a model stopped being free -- ``create_all`` only creates missing *tables*, so a new column silently never appears and every query mentioning it fails. What this module does instead is derive the migration from the models: compare each table's declared columns against what the database actually has, and ``ALTER TABLE ... ADD COLUMN`` for whatever is missing. That covers new tables and new columns, which is essentially every schema change this project makes. What it deliberately does NOT do: * rename, drop or retype a column * add a PRIMARY KEY or UNIQUE constraint to an existing table * backfill anything requiring application logic SQLite cannot do most of those with ALTER TABLE anyway; they need the create-copy-swap dance. Anything in that category is a hand-written job and should be added to MANUAL_STEPS below so it is at least visible. """ from __future__ import annotations import logging from typing import Any from sqlalchemy import Engine, inspect, text from sqlalchemy.schema import Column, Table from lembas.db.base import Base log = logging.getLogger(__name__) # Schema changes that this module cannot perform. Kept as documentation so a # failure has somewhere to point rather than being a mystery. MANUAL_STEPS: list[str] = [] def _literal_default(column: Column) -> str | None: """A SQL literal to backfill an existing row's new column with. SQLite refuses to add a NOT NULL column without a default, and refuses a non-constant default. Python-side defaults (``default=dict``, ``default=utcnow``) are callables and cannot be expressed in DDL, so the value is derived from the column type instead. New rows still get the real Python default; this only fills the rows that already exist. """ default = column.default if default is not None and not default.is_callable and not default.is_clause_element: value: Any = 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}'" affinity = column.type.__class__.__name__.upper() if "JSON" in affinity: # MutableList columns must start as [] and MutableDict as {}; guessing # wrong makes the first read blow up rather than return empty. python_type = getattr(column.type, "python_type", None) return "'[]'" if python_type is list else "'{}'" if "BOOL" in affinity: return "0" if any(token in affinity for token in ("INT", "FLOAT", "NUMERIC", "DECIMAL")): return "0" if "DATE" in affinity or "TIME" in affinity: return "CURRENT_TIMESTAMP" if any(token in affinity for token in ("STRING", "TEXT", "VARCHAR", "CHAR")): return "''" return None def _add_column_sql(table: Table, column: Column, dialect) -> str | None: type_sql = column.type.compile(dialect) default = _literal_default(column) if not column.nullable and default is None: log.error( "cannot add NOT NULL column %s.%s: no usable default. Add it by hand.", table.name, column.name, ) return None parts = [f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {type_sql}'] if not column.nullable: # SQLite refuses a NOT NULL column with no default, so existing rows # have to be given something. That is the only reason a default is # emitted at all. parts.append("NOT NULL") parts.append(f"DEFAULT {default}") # A nullable column gets no default on purpose. Backfilling one would give # existing rows a value the model does not consider absent -- an added # foreign key would arrive as "" rather than NULL, and every "is this set?" # check downstream would be wrong about rows that predate it. return " ".join(parts) # --- Full-text search -------------------------------------------------------- # The library stores are searched rather than listed, and LIKE over a few # hundred documents ranks nothing and matches badly. SQLite ships FTS5, so the # index costs no dependency and works offline like everything else here. # # These are the one part of the schema this module's model-diffing cannot # derive: an FTS5 virtual table is not a SQLAlchemy model, has no columns to # compare, and needs triggers to stay in step with the table it shadows. So it # is written out -- but written out *idempotently*, with IF NOT EXISTS # throughout, which keeps it the same kind of thing as the column sync: run it # at every startup and it converges. # # `content=` makes each index external-content: the text is not stored twice, # and the triggers below are what the FTS5 documentation calls for to keep an # external-content index correct through updates and deletes. FTS_INDEXES: tuple[tuple[str, str, tuple[str, ...]], ...] = ( ("documents_fts", "documents", ("title", "description", "extracted_text")), ("notes_fts", "notes", ("title", "body")), ("skills_fts", "skills", ("name", "description", "body")), ("reports_fts", "reports", ("title", "summary", "body")), ) def _fts_statements(index: str, table: str, columns: tuple[str, ...]) -> list[str]: # `id` rides along UNINDEXED so a match can be turned straight back into an # ORM row. The alternative is joining on rowid, which SQLAlchemy models do # not expose and which changes under VACUUM. columns = ("id", *columns) column_list = ", ".join(columns) declared = ", ".join( f"{name} UNINDEXED" if name == "id" else name for name in columns ) new_values = ", ".join(f"new.{name}" for name in columns) old_values = ", ".join(f"old.{name}" for name in columns) return [ f"CREATE VIRTUAL TABLE IF NOT EXISTS {index} USING fts5(" f"{declared}, content='{table}', content_rowid='rowid')", # 'delete' rows carry the old values because an external-content index # cannot look them up itself once the source row has gone. f"""CREATE TRIGGER IF NOT EXISTS {index}_ai AFTER INSERT ON {table} BEGIN INSERT INTO {index}(rowid, {column_list}) VALUES (new.rowid, {new_values}); END""", f"""CREATE TRIGGER IF NOT EXISTS {index}_ad AFTER DELETE ON {table} BEGIN INSERT INTO {index}({index}, rowid, {column_list}) VALUES ('delete', old.rowid, {old_values}); END""", f"""CREATE TRIGGER IF NOT EXISTS {index}_au AFTER UPDATE ON {table} BEGIN INSERT INTO {index}({index}, rowid, {column_list}) VALUES ('delete', old.rowid, {old_values}); INSERT INTO {index}(rowid, {column_list}) VALUES (new.rowid, {new_values}); END""", ] def ensure_fts(engine: Engine) -> list[str]: """Create the search indexes and their triggers if they are missing. Returns the indexes it created. A failure here is logged and swallowed: search degrading to "finds nothing" is bad, but it is much better than the application refusing to start. """ created: list[str] = [] inspector = inspect(engine) known = set(inspector.get_table_names()) with engine.begin() as connection: for index, table, columns in FTS_INDEXES: if table not in known: continue fresh = index not in known for statement in _fts_statements(index, table, columns): connection.execute(text(statement)) if fresh: # Backfill anything already in the table. Only on creation -- # the triggers keep it current from then on. column_list = ", ".join(("id", *columns)) connection.execute( text( f"INSERT INTO {index}(rowid, {column_list}) " f"SELECT rowid, {column_list} FROM {table}" ) ) created.append(index) return created def sync_schema(engine: Engine) -> list[str]: """Bring the database up to the declared schema. Returns what it changed.""" import lembas.db.models # noqa: F401 (registers every table on the metadata) changes: list[str] = [] inspector = inspect(engine) known_tables = set(inspector.get_table_names()) for table in Base.metadata.sorted_tables: if table.name not in known_tables: changes.append(f"create table {table.name}") # Creates anything missing; existing tables are left alone. Base.metadata.create_all(bind=engine) inspector = inspect(engine) with engine.begin() as connection: for table in Base.metadata.sorted_tables: existing = {col["name"] for col in inspector.get_columns(table.name)} for column in table.columns: if column.name in existing: continue statement = _add_column_sql(table, column, engine.dialect) if statement is None: continue connection.execute(text(statement)) changes.append(f"add column {table.name}.{column.name}") log.info("schema: %s", statement) try: for index in ensure_fts(engine): changes.append(f"create search index {index}") except Exception: # noqa: BLE001 - search is not worth refusing to start over log.exception("could not create the full-text search indexes") if changes: log.info("schema synchronised: %d change(s)", len(changes)) for step in MANUAL_STEPS: log.warning("manual schema step still required: %s", step) return changes