Skip to content

feat(schema): declare table checks and emit them inline on CREATE TABLE - #350

Merged
0x054 merged 1 commit into
feat/table-checksfrom
feat/table-checks-341
Aug 18, 2026
Merged

feat(schema): declare table checks and emit them inline on CREATE TABLE#350
0x054 merged 1 commit into
feat/table-checksfrom
feat/table-checks-341

Conversation

@0x054

@0x054 0x054 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #341

What an author can now write

class Transfer(Model):
    __ferro_checks__: ClassVar[tuple[Check, ...]] = (
        Check(
            "at_most_one_outflow",
            lambda transfer: (transfer.outflow_transaction == None)
            | (transfer.outflow_activity == None),
        ),
    )

    id: int | None = Field(default=None, primary_key=True)
    outflow_transaction: Annotated[Txn | None, ForeignKey(related_name="outflow_transfers")] = None
    outflow_activity: Annotated[Activity | None, ForeignKey(related_name="outflow_transfers")] = None

connect(db_url, auto_migrate=True) creates the table with the constraint
folded into CREATE TABLE — the same bytes on both backends (ADR-0014):

CREATE TABLE "transfer" (
  "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  "outflow_transaction_id" integer NULL,
  "outflow_activity_id" integer NULL,
  CONSTRAINT "fk_transfer_outflow_transaction_id_txn" FOREIGN KEY ...,
  CONSTRAINT "ck_transfer_at_most_one_outflow" CHECK (("outflow_transaction_id" IS NULL) OR ("outflow_activity_id" IS NULL))
)

An insert that sets both sides raises CheckViolationError; on Postgres
.constraint == "ck_transfer_at_most_one_outflow". On SQLite the driver does
not report a constraint name, so the typed error is the contract and
.constraint may be None — no name parser was invented.

Why the predicate is a lambda, not a SQL string

A raw SQL body in the IR would mean two body languages, and the Alembic bridge
and the Rust emitter would drift the moment either changed (I-1). Instead the
lambda is evaluated at compile time and lowered to the structured CheckExpr
IR that #348 landed. One Rust renderer (render_check_expr) then produces both
the CREATE TABLE clause and the sa.CheckConstraint body, so the parity test
compares two renderings of the same source. A typo'd column fails at class
definition, naming the model and the check, instead of surfacing as a migration
error later.

Both FK-null spellings compile to the same leaf:

Check("via_relation", lambda s: s.target == None)     # -> is_null("target_id")
Check("via_shadow",   lambda s: s.target_id == None)  # -> is_null("target_id")

That works for a forward-referenced FK too, because a shadow column's name is
{field}_id by convention and never needs the resolved target class.
Compilation runs inside compile_model_schema_ir, so both the class-definition
pass and the resolved-registration pass lower against that pass's own column
specs.

Dialect scope and the rejections

This release compiles == None / != None combined with &, |, ~
(ADR-0016). Everything richer is rejected at class definition with a message
that says what is supported and where it lands:

Rejecting.__ferro_checks__ check 'rule' uses the comparison operator '>', which
check predicates do not support yet. This release compiles NULL tests
(== None / != None, on a column, a forward-FK relation, or its shadow *_id
column) combined with & / | / ~ (ADR-0016). Richer predicates land with #346.

_lower_node in src/ferro/checks.py is the single place #346 attaches new
arms.

Not in this PR (by design)

Implementation notes

  • src/ferro/checks.py (new): the Check declaration type, a validating
    predicate proxy over the compile's own specs, and QueryNode -> CheckExpr
    lowering.
  • src/ferro/ir/compiler.py: table_checks in the model payload — names from
    the shared Rust builder, bodies as structured CheckExpr. The key is absent
    (not empty) when nothing is declared, so existing envelopes and fingerprints
    are byte-identical.
  • crates/ferro-migrate/src/emit.rs: append_named_table_checks splices the
    named clauses into the constraint list, since sea-query has no named-CHECK
    form. If a sea-query upgrade changes the closing bytes it is a loud
    EmissionError, never a silently dropped constraint.
  • src/ferro/migrations/alembic.py: one sa.CheckConstraint per IR entry,
    mirroring the column-check loop above it.

A name collision with a Field(db_check=True) column check's live name
(ck_<table>_<col>) fails at class definition rather than letting one emitter
shadow the other.

Test plan

  • cargo test -p ferro-migrate -p ferro-ddl-lowering -p ferro-schema-ir — inline named CHECKs on both dialects, IR order preserved, empty table_checks byte-identical
  • cargo test --no-default-features --features testing (root crate, CI's invocation) — 207 passed
  • Declaration failures: bad suffix shape, full ck_… name, non-callable predicate, duplicate suffix, unknown column, non-Check entry, non-tuple __ferro_checks__, collision with a db_check column-check name
  • Empty __ferro_checks__ emits no table_checks key
  • Every richer predicate form (.in_(), .like(), >, literal ==, traversal, .exists(), aggregate) rejected at class definition
  • Compiler emits canonical names + structured predicates (no SQL string); both FK-null spellings identical; forward-referenced FK compiles
  • CREATE TABLE contains the named CHECK inline on sqlite and postgres, and nothing CHECK-shaped in post_create_sqls
  • Alembic metadata names and bodies match the runtime emitter (I-1)
  • Autogenerate against a Rust-bootstrapped in-sync DB is empty — sqlite and postgres
  • Live matrix: four CHECKs exist on the table; legal inserts succeed; illegal inserts raise CheckViolationError with the live constraint name on Postgres
  • auto_migrate=True without migrate_updates does not ALTER an existing table (ADR-0010)
  • Field(db_check=True) SQLite elision unchanged
  • Full suite both backends: pytest --db-backends=sqlite,postgres — 1743 passed, 11 skipped, 1 xfailed
  • ty check clean on the new module and on the just check scope

Exit steps

Made with Cursor

Application authors can now declare table-level CHECK constraints on a model
as `Check(suffix, predicate)` entries in `__ferro_checks__`, where the
predicate is a ferro lambda over the model's own columns rather than a SQL
string:

    class Transfer(Model):
        __ferro_checks__: ClassVar[tuple[Check, ...]] = (
            Check(
                "at_most_one_outflow",
                lambda transfer: (transfer.outflow_transaction == None)
                | (transfer.outflow_activity == None),
            ),
        )

A new table gets the constraint inline in `CREATE TABLE` on both Postgres and
SQLite (ADR-0014), named `ck_transfer_at_most_one_outflow`, and the Alembic
bridge carries the same named CHECK so autogenerate against a
Rust-bootstrapped database stays empty (I-1). A violating insert raises
`CheckViolationError`; on Postgres `.constraint` is the live name.

Because the predicate compiles to the structured `CheckExpr` IR — never a raw
SQL string — one Rust renderer produces both emitters' bodies, and an unknown
column fails at class definition instead of at migration time. Both FK-null
spellings work (`transfer.outflow_transaction == None` and
`transfer.outflow_transaction_id == None`), including for a forward-referenced
FK, because compilation happens inside `compile_model_schema_ir` on both the
class-definition and resolved-registration passes.

This release supports NULL tests combined with `&`, `|`, and `~` (ADR-0016);
richer forms (`.in_()`, `.like()`, literal comparisons, traversal, existence
tests, aggregates) are rejected at class definition with a message pointing at
the dialect ticket. Existing `Field(db_check=True)` column checks keep their
current ALTER-shaped path unchanged, and `auto_migrate` still leaves existing
tables alone (ADR-0010).

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 (declaration, lowering, compiler, emit splice, Alembic, tests). AC for #341 is met.

  • Check(suffix, predicate) + __ferro_checks__; suffix shape, ck_… reject, duplicate, unknown column, db_check name collision
  • Transfer dialect only (== None / != None / & / | / ~); both FK-null spellings; richer forms rejected pointing at Full check-predicate dialect #346
  • Structured CheckExpr in IR (no SQL string); names from _ddl_table_check_constraint_name
  • CREATE TABLE inlines named CONSTRAINT … CHECK (…) on sqlite and postgres; column db_check stays ALTER-shaped / SQLite-elided
  • Alembic CheckConstraint uses _render_table_check_body; autogenerate-empty pin
  • ADR-0010 pin: auto_migrate=True without migrate_updates does not ALTER an existing table
  • Live matrix: four CHECKs, legal inserts, CheckViolationError with the live name on Postgres
  • src/introspect.rs untouched; CHANGELOG.md untouched

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

@0x054
0x054 merged commit f3705a1 into feat/table-checks Aug 18, 2026
7 checks passed
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