feat(schema): declare table checks and emit them inline on CREATE TABLE - #350
Merged
Conversation
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>
Contributor
Author
Gate: clearRead the full diff (declaration, lowering, compiler, emit splice, Alembic, tests). AC for #341 is met.
Merging into |
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #341
What an author can now write
connect(db_url, auto_migrate=True)creates the table with the constraintfolded into
CREATE TABLE— the same bytes on both backends (ADR-0014):An insert that sets both sides raises
CheckViolationError; on Postgres.constraint == "ck_transfer_at_most_one_outflow". On SQLite the driver doesnot report a constraint name, so the typed error is the contract and
.constraintmay beNone— 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
CheckExprIR that #348 landed. One Rust renderer (
render_check_expr) then produces boththe
CREATE TABLEclause and thesa.CheckConstraintbody, so the parity testcompares 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:
That works for a forward-referenced FK too, because a shadow column's name is
{field}_idby convention and never needs the resolved target class.Compilation runs inside
compile_model_schema_ir, so both the class-definitionpass and the resolved-registration pass lower against that pass's own column
specs.
Dialect scope and the rejections
This release compiles
== None/!= Nonecombined with&,|,~(ADR-0016). Everything richer is rejected at class definition with a message
that says what is supported and where it lands:
_lower_nodeinsrc/ferro/checks.pyis the single place #346 attaches newarms.
Not in this PR (by design)
auto_migrate=Truewithoutmigrate_updatesstill no-ops anexisting table (ADR-0010) — add/rebuild/drop are Add missing ck_* on migrate_updates #343/Rebuild a table check when the check predicate drifts #344/Drop orphaned ck_* under migrate_destructive #345. Pinned by a
test.
introspection (
src/introspect.rsuntouched, Introspect live ferro-owned CHECKs #342).Field(db_check=True)column checks keep their existing ALTER-shaped pathand SQLite elision, unchanged and pinned.
Implementation notes
src/ferro/checks.py(new): theCheckdeclaration type, a validatingpredicate proxy over the compile's own specs, and
QueryNode -> CheckExprlowering.
src/ferro/ir/compiler.py:table_checksin the model payload — names fromthe 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_checkssplices thenamed 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: onesa.CheckConstraintper 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 emittershadow 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, emptytable_checksbyte-identicalcargo test --no-default-features --features testing(root crate, CI's invocation) — 207 passedck_…name, non-callable predicate, duplicate suffix, unknown column, non-Checkentry, non-tuple__ferro_checks__, collision with adb_checkcolumn-check name__ferro_checks__emits notable_checkskey.in_(),.like(),>, literal==, traversal,.exists(), aggregate) rejected at class definitionCREATE TABLEcontains the named CHECK inline on sqlite and postgres, and nothing CHECK-shaped inpost_create_sqlsCheckViolationErrorwith the live constraint name on Postgresauto_migrate=Truewithoutmigrate_updatesdoes not ALTER an existing table (ADR-0010)Field(db_check=True)SQLite elision unchangedpytest --db-backends=sqlite,postgres— 1743 passed, 11 skipped, 1 xfailedty checkclean on the new module and on thejust checkscopeExit steps
Closes #341in this body;featPRD: table-level CHECK constraints (multi-column), auto-migrated #339 (parent PRD) left open and uneditedCHANGELOG.mduntouched (I-10)feat/table-checks, notmainMade with Cursor