Add nullable columns without a default

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>
This commit is contained in:
Jaroslav Beneš
2026-07-21 20:03:01 +02:00
parent 35b9d8c8d2
commit 2a79d962a3
4 changed files with 64 additions and 4 deletions
+7 -1
View File
@@ -90,9 +90,15 @@ def _add_column_sql(table: Table, column: Column, dialect) -> str | 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")
if default is not None:
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)
+6 -1
View File
@@ -133,7 +133,12 @@ def sweep_unfiled(db: DBSession) -> int:
table that already had rows. This is what makes "always set" true in
practice, and it runs at startup beside the orphaned-upload sweep.
"""
unfiled = list(db.scalars(select(Document).where(Document.base_id.is_(None))))
# Empty string as well as NULL: an earlier release added the column with a
# type-derived default, so a deployment that upgraded through it has rows
# holding "" rather than NULL. Both mean the same thing here.
unfiled = list(
db.scalars(select(Document).where((Document.base_id.is_(None)) | (Document.base_id == "")))
)
if not unfiled:
return 0