2f978d84d1
Deploying knowledge bases showed the migration runner doing the wrong thing:
ALTER TABLE "documents" ADD COLUMN "base_id" VARCHAR(32) DEFAULT ''
`base_id` is nullable and its absent value is NULL, but the runner derived a
default from the column type and backfilled every existing row with the empty
string. Nothing then matched `base_id IS NULL`, so the startup sweep that files
pre-bases documents into a default base would have skipped all of them and the
documents would have stayed invisible.
Nobody lost anything -- the live instance had no documents yet -- but the fault
is general: any nullable column added from here would arrive as "" rather than
NULL, and every "is this set?" check would be wrong about the rows that predate
it. So a default is now emitted only for NOT NULL columns, where SQLite requires
one.
The sweep also accepts "" as meaning unfiled, since a deployment that upgraded
through the previous release has rows holding it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
9.7 KiB
Python
233 lines
9.7 KiB
Python
"""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")),
|
|
)
|
|
|
|
|
|
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
|