Skip to content

feat(migrate): add missing ck_* constraints on migrate_updates - #353

Merged
0x054 merged 2 commits into
feat/table-checksfrom
feat/table-checks-343
Aug 18, 2026
Merged

feat(migrate): add missing ck_* constraints on migrate_updates#353
0x054 merged 2 commits into
feat/table-checksfrom
feat/table-checks-343

Conversation

@0x054

@0x054 0x054 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #343

Slice of #339 (do not close). Base is feat/table-checks.

What a user sees

A model gains an invariant, and the next boot enforces it on the table that is already there:

class Transfer(Model):
    __ferro_checks__: ClassVar[tuple[Check, ...]] = (
        Check("at_most_one_side", lambda transfer: (transfer.left == None) | (transfer.right == None)),
    )
    id: int | None = Field(default=None, primary_key=True)
    left: str | None = None
    right: str | None = None

await connect(url, migrate_updates=True)
ALTER TABLE "transfer" ADD CONSTRAINT "ck_transfer_at_most_one_side"
  CHECK (("left" IS NULL) OR ("right" IS NULL))

Same for a column check: flipping Field(db_check=True) on a column that already exists now actually creates ck_<table>_<col> (it used to be create-time only). If existing rows violate the new CHECK, the connect fails with CheckViolationError and that table's whole plan rolls back — the column added in the same run is gone too, so the run is safely re-runnable.

On SQLite nothing is altered: adding a table constraint needs a full table rebuild, so the pass warns naming the constraint and points at Alembic's batch mode (ADR-0014).

Alembic autogenerate proposes the byte-identical statement, with no migrate_updates gate — running autogenerate is itself the request for a diff.

How

  • One decision table, two consumers (I-1). ferro_ddl_lowering::missing_check_names decides which declared ck_* names have no live counterpart; render_check_addition renders the ADD (a plain ALTER TABLE … ADD CONSTRAINT for a table check; the existing idempotent render_db_check DO-block for a column check, single-sourced with the create path). The reconciliation pass consumes them through ferro_migrate::plan_missing_checks + the new MigrationOp::AddCheck; the Alembic comparator consumes the same pair over FFI (_plan_check_addition).
  • Live CHECKs travel beside the IR, not inside it. live_table_checks() reports a name plus the backend's rendering of the body. Feeding that rendering into a SchemaCheck/SchemaTableCheck would create a second body language next to the IR predicate, which I-1 forbids — so plan_table_migration takes live_checks: &[LiveCheck] and the decision stays name-based.
  • Ordering. plan_missing_checks runs after plan_from_ir, so a CHECK over a newly added column lands after its ADD COLUMN. A column check on a column being added in this same run is filtered out — emit_add_column already emits its DO-block (the same dedup diff_model_indexes does for single-column indexes).
  • Create-pass ownership (ADR-0010). The reconciliation loop now skips tables the create pass built in this same run: they are already exactly the model, and re-diffing them only replayed that pass's own backend-limitation warnings (a fresh SQLite db_check table warned twice). internal_create_tables returns the pre-existing table set to make that explicit.

Non-goals (untouched)

Body drift of a same-named CHECK is a rebuild (#344) — comparison is by name only. Orphan ck_* drops are #345. src/ferro/checks.py is unchanged (#346). Docs are #347. A live CHECK ferro does not own (ferro_owned: false) is never added over or dropped.

Test plan

  • cargo test --workspace --no-default-features --features testing — 367 tests, new pins in ferro-ddl-lowering (decision + both renderers + SQLite skip + unknown-name None) and ferro-migrate (planning, dedup, emission, ordering, loud failure on an undeclared name)
  • uv run pytest --db-backends=sqlite,postgres — 1777 passed, 11 skipped, 1 xfailed
  • New tests/test_table_check_reconcile.py (tests/test_table_checks.py untouched):
    • table check added on the second migrate_updates boot; name present in pg_constraint; the invariant then rejects a violating insert
    • rows violating the new CHECK fail the connect and the whole table plan rolls back (added column gone, data intact)
    • toggling db_check=True on a live column adds ck_cookie_flavor and rejects an out-of-domain value
    • a new column and a table check over it land in one run, column first
    • second boot with no model change is a no-op (no warnings, no phantom add)
    • SQLite: one warning naming the constraint, no rebuild, no ALTER; and a table created in this run warns once, not twice
    • user-owned live CHECK is neither a counterpart nor touched
    • migrate_updates=False plans nothing (ADR-0010)
    • Alembic autogenerate against the drifted database proposes the runtime's exact statement, and proposes nothing once reconciled
    • FFI/runtime statement parity pin (I-1)

Exit steps

Made with Cursor

0x054 and others added 2 commits August 18, 2026 10:45
`migrate_updates=True` now reconciles CHECK constraints: a table check or
column check that the model declares but the live table is missing is added
with `ALTER TABLE … ADD CONSTRAINT` on Postgres. Existing rows must satisfy
it — otherwise connect fails and that table's plan rolls back through the
per-table transaction. Toggling `Field(db_check=True)` on an existing column
now actually creates `ck_<table>_<col>`.

The decision (which declared names have no live counterpart) and the rendered
DDL are single-sourced in `ferro_ddl_lowering::missing_check_names` /
`render_check_addition`. The reconciliation pass consumes them through
`ferro_migrate::plan_missing_checks` + the new `MigrationOp::AddCheck`; the
Alembic autogenerate comparator consumes the same pair over FFI
(`_plan_check_addition`) and executes byte-identical statements (I-1).

Comparison is by name only: a live `ck_*` whose body drifted is a rebuild
(#344), a live CHECK ferro does not own is never touched, and orphaned `ck_*`
are left alone (#345). On SQLite the add is skipped with a warning naming the
constraint — adding a table constraint needs a full table rebuild, which is
Alembic's batch-mode door (ADR-0014).

The reconciliation pass now also skips tables the create pass built in the
same run (ADR-0010): they are already exactly the model, so re-diffing could
only replay that pass's own backend-limitation warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
The add path is the same shape as enum-label addition: one Rust pair,
consumed by auto-migrate and Alembic over FFI. Name it here so a third
emitter cannot grow a second missing-check table.

Co-authored-by: Cursor <cursoragent@cursor.com>
@0x054

0x054 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Gate: clear

Read the full diff. AC for #343 is met.

  • Postgres migrate_updates adds a missing table check (ALTER TABLE … ADD CONSTRAINT) and a toggled column db_check (render_db_check DO-block); violating rows fail connect and roll the per-table plan back
  • SQLite warns naming the constraint, no ALTER/rebuild
  • Alembic autogenerate proposes the runtime's exact statement via _plan_check_addition; no migrate_updates gate
  • Second boot is a no-op; user-owned CHECKs are not counterparts; AddColumn-before-AddCheck for a new column a table check references
  • Name-only comparison (body drift stays Rebuild a table check when the check predicate drifts #344; orphan drops stay Drop orphaned ck_* under migrate_destructive #345)
  • Extra vs brief, accepted: skip tables the create pass built this run (ADR-0010) — stops the double SQLite db_check warning. internal_create_tables returning the pre-existing set makes that ownership explicit.

Nit pushed: I-1 item 12 names missing_check_names / render_check_addition / _plan_check_addition, same shape as enum-label addition.

Merging into feat/table-checks once CI is green on 4cdca83. Does not close parent #339.

@0x054
0x054 merged commit c0c69d3 into feat/table-checks Aug 18, 2026
7 checks passed
@0x054 0x054 mentioned this pull request Aug 18, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant