Audit of models vs fresh DB vs production (all at 0019): 20 columns are NOT NULL in the models but nullable in the database, all timestamps with DEFAULT now() created by 0011/0012/0014/0017 without nullable=False. Classified DB_CONSTRAINT_MISSING: no application path writes NULL to them (inserts omit them, updates set now; no raw SQL inserts), and there were zero NULLs in production and in the development database (246,682 rows checked). The upgrade aborts without changing anything if any NULL is present, instead of inventing a value. After this revision a fresh database matches the models on nullability, column types, PKs, FKs, uniqueness and checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
64 lines
2.3 KiB
Python
64 lines
2.3 KiB
Python
"""Enforce NOT NULL on server-defaulted timestamps the models already require.
|
|
|
|
0011, 0012, 0014 and 0017 created these columns with ``server_default=now()``
|
|
but without ``nullable=False``, so the database accepted NULL while the models
|
|
declare them non-optional. No application path writes NULL to them (inserts
|
|
omit them and rely on the default; updates set ``now``), and before this
|
|
revision was written no NULL existed in production or in the development
|
|
database.
|
|
|
|
The upgrade refuses to run — changing nothing — if any NULL is found, instead
|
|
of guessing a value for it.
|
|
|
|
Revision ID: 0020_not_null_timestamps
|
|
Revises: 0019_fix_timestamp_defaults
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision = "0020_not_null_timestamps"
|
|
down_revision = "0019_fix_timestamp_defaults"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
COLUMNS = (
|
|
("admin_users", "created_at"),
|
|
("agent_events", "occurred_at"),
|
|
("agent_snapshots", "updated_at"),
|
|
("ai_eval_cache", "created_at"),
|
|
("ai_runs", "started_at"),
|
|
("ai_work_items", "created_at"),
|
|
("ai_work_items", "updated_at"),
|
|
("audit_events", "occurred_at"),
|
|
("fiscal_context_entries", "created_at"),
|
|
("fiscal_context_entries", "updated_at"),
|
|
("installations", "created_at"),
|
|
("installations", "updated_at"),
|
|
("kb_knowledge", "created_at"),
|
|
("kb_observations", "observed_at"),
|
|
("kb_products", "created_at"),
|
|
("kb_products", "updated_at"),
|
|
("tenant_product_context", "created_at"),
|
|
("tenant_product_context", "updated_at"),
|
|
("web_sessions", "created_at"),
|
|
("web_sessions", "last_seen_at"),
|
|
)
|
|
|
|
|
|
def upgrade():
|
|
bind = op.get_bind()
|
|
offenders = []
|
|
for table, column in COLUMNS:
|
|
nulls = bind.execute(sa.text(f'SELECT count(*) FROM "{table}" WHERE "{column}" IS NULL')).scalar()
|
|
if nulls:
|
|
offenders.append(f"{table}.{column}={nulls}")
|
|
if offenders:
|
|
raise RuntimeError("0020 aborted, NULL values present (fix them explicitly first): " + ", ".join(offenders))
|
|
for table, column in COLUMNS:
|
|
op.alter_column(table, column, nullable=False, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())
|
|
|
|
|
|
def downgrade():
|
|
for table, column in reversed(COLUMNS):
|
|
op.alter_column(table, column, nullable=True, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())
|