feat: ADR-0035 4a.3 — table-level / multi-column CHECK

Add table-level CHECK (e.g. `CREATE TABLE t (a int, b int, CHECK (a < b))`)
to advanced-mode SQL CREATE TABLE. Since SQLite exposes no PRAGMA for CHECK
constraints, a table-level CHECK cannot be read back from the engine and
becomes the source of truth in a new internal metadata table
`__rdbms_playground_table_checks (table_name, seq, check_expr)`.

- Grammar: new TABLE_CHECK element in ELEMENT_CHOICES.
- Builder: distinguishes a table-level CHECK from a column-level one by
  element position (no column-def open in the element), using depth-aware
  boundary tracking so a length-arg comma (`numeric(10,2)`) or a
  table-PRIMARY KEY's inner comma is not mistaken for an element separator.
- Worker: do_create_table emits the CHECK clauses and writes the metadata
  rows in its transaction; schema_to_ddl emits them identically on rebuild;
  read_schema / read_schema_snapshot read them from the metadata table;
  do_drop_table clears them.
- Persistence: TableSchema.check_constraints round-trips through project.yaml
  (#[serde(default)], optional on read), mirroring unique_constraints.
- Composite UNIQUE deliberately stays PRAGMA-detected (engine-reportable,
  unlike CHECK) — user-confirmed.

DA/runda round added cross-cutting tests and a forward-looking doc fix:
- table CHECK survives a rebuild triggered by `add column`, and a later
  rebuild_from_text (the ADR-0013 rebuild primitive uses a raw DROP, so the
  metadata rows keyed on the final name are preserved);
- dropping a column a table CHECK references fails cleanly (rollback, table
  intact); detection is 4e, friendly wording is H1;
- dropping a table clears its CHECK metadata (no orphan rows on re-create);
- amended ADR §6 so 4h's RENAME also updates the new metadata table.

20 Tier-3 + 9 grammar/builder + 2 YAML tests. Docs: ADR-0035 Status/§13/§6,
README index, requirements.md Q1. Help/usage skeleton + describe display of
table-level constraints deferred to 4i (symmetric with 4a.2).

Tests: 1769 passing, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-25 14:06:52 +00:00
parent 1991fb4fc7
commit 60111f69d5
12 changed files with 899 additions and 39 deletions
+262
View File
@@ -52,6 +52,7 @@ fn created_table_appears_with_playground_types() {
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table Widget (id int primary key, name text)".to_string()),
))
@@ -89,6 +90,7 @@ fn integer_primary_key_is_plain_int() {
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (id integer primary key)".to_string()),
))
@@ -114,6 +116,7 @@ fn serial_pk_autoincrements_in_multi_column_table() {
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (id serial primary key, name text)".to_string()),
))
@@ -157,6 +160,7 @@ fn if_not_exists_is_a_noop_when_table_exists() {
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (id int)".to_string()),
))
@@ -168,6 +172,7 @@ fn if_not_exists_is_a_noop_when_table_exists() {
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
true, // IF NOT EXISTS
Some("create table if not exists T (id int)".to_string()),
))
@@ -194,6 +199,7 @@ fn table_without_primary_key_is_allowed() {
vec![ColumnSpec::new("body", Type::Text)],
vec![], // no primary key
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table Notes (body text)".to_string()),
))
@@ -236,6 +242,7 @@ fn check_constraint_is_enforced() {
vec![ColumnSpec::new("id", Type::Serial), col_check("price", Type::Real, "price >= 0")],
vec!["id".to_string()],
vec![],
vec![], // no table CHECK
false,
Some("create table T (id serial primary key, price real check (price >= 0))".to_string()),
))
@@ -270,6 +277,7 @@ fn default_is_applied_when_column_omitted() {
],
vec!["id".to_string()],
vec![],
vec![], // no table CHECK
false,
Some("create table T (id serial primary key, label text, n int default 7)".to_string()),
))
@@ -298,6 +306,7 @@ fn composite_unique_is_enforced() {
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![vec!["a".to_string(), "b".to_string()]],
vec![], // no table CHECK
false,
Some("create table T (a int, b int, unique (a, b))".to_string()),
))
@@ -332,6 +341,7 @@ fn check_default_and_composite_unique_survive_rebuild() {
],
vec![],
vec![vec!["a".to_string(), "b".to_string()]],
vec![], // no table CHECK
false,
Some(
"create table T (a int, b int, price real check (price >= 0), \
@@ -369,6 +379,197 @@ fn check_default_and_composite_unique_survive_rebuild() {
assert!(r.block_on(ins("1", "1", "5")).is_err(), "composite UNIQUE survived rebuild");
}
#[test]
fn table_level_check_is_enforced() {
// ADR-0035 §4a.3: a multi-column CHECK has no column to hang on and
// the engine reports no CHECKs, so it round-trips via a metadata
// table. Here we prove the engine actually enforces it.
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()], // table-level CHECK
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) satisfies a < b");
assert!(r.block_on(ins("2", "1")).is_err(), "CHECK (a < b) rejects (2,1)");
assert!(r.block_on(ins("3", "3")).is_err(), "CHECK (a < b) rejects (3,3)");
}
#[test]
fn multiple_table_level_checks_all_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
ColumnSpec::new("c", Type::Int),
],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string(), "b < c".to_string()],
false,
Some("create table T (a int, b int, c int, check (a < b), check (b < c))".to_string()),
))
.expect("create");
let ins = |a: &str, b: &str, c: &str| {
db.insert(
"T".to_string(),
None,
vec![
Value::Number(a.to_string()),
Value::Number(b.to_string()),
Value::Number(c.to_string()),
],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2", "3")).expect("(1,2,3) satisfies both checks");
assert!(r.block_on(ins("2", "1", "3")).is_err(), "first CHECK (a < b) enforced");
assert!(r.block_on(ins("1", "3", "2")).is_err(), "second CHECK (b < c) enforced");
}
#[test]
fn dropping_a_table_clears_its_table_check_metadata() {
// The CHECK metadata table is keyed by (table_name, seq). If a drop
// left orphan rows behind, re-creating the same table with a CHECK
// would collide on that primary key and fail. A clean create→drop→
// create round-trip proves the drop path clears the metadata.
let (_p, db, _d) = open(false);
let r = rt();
let make = || {
db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
)
};
r.block_on(make()).expect("first create");
r.block_on(db.drop_table("T".to_string(), Some("drop table T".to_string())))
.expect("drop");
r.block_on(make()).expect("re-create must not collide on orphaned CHECK metadata");
// The re-created CHECK is enforced (and there is exactly one of it).
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) valid");
assert!(r.block_on(ins("2", "1")).is_err(), "CHECK enforced after re-create");
}
#[test]
fn table_level_check_survives_a_rebuild_triggering_column_add() {
// Cross-cutting probe (ADR-0013 rebuild primitive × 4a.3 metadata):
// adding a constrained column to a table that carries a table-level
// CHECK rebuilds the table via `schema_to_ddl`. The CHECK must
// survive both in the engine (enforced) AND in the metadata table
// (so a *later* rebuild_from_text still re-emits it) — otherwise the
// constraint is silently lost the next time the table is rebuilt.
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
// A UNIQUE column forces the rebuild path (ADR-0029 §6).
let mut c = ColumnSpec::new("c", Type::Int);
c.unique = true;
r.block_on(db.add_column("T".to_string(), c, Some("add column T: c(int) unique".to_string())))
.expect("add column via rebuild");
let ins = |a: &str, b: &str, c: &str| {
db.insert(
"T".to_string(),
Some(vec!["a".to_string(), "b".to_string(), "c".to_string()]),
vec![
Value::Number(a.to_string()),
Value::Number(b.to_string()),
Value::Number(c.to_string()),
],
Some("insert".to_string()),
)
};
// Engine still enforces the CHECK right after the rebuild.
r.block_on(ins("1", "2", "10")).expect("(1,2) valid after column add");
assert!(r.block_on(ins("2", "1", "20")).is_err(), "CHECK survived the column-add rebuild");
// And the metadata survived too: a fresh rebuild from project.yaml
// re-emits the CHECK (it would be lost if the rebuild primitive had
// dropped the table_checks rows without repopulating them).
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
assert!(
r.block_on(ins("9", "8", "30")).is_err(),
"CHECK still present after a later rebuild_from_text — metadata was preserved"
);
}
#[test]
fn table_level_check_survives_rebuild() {
// The part-D proof for 4a.3: the engine reports no CHECK, so the
// constraint can only be reconstructed from the metadata table via
// project.yaml. After a rebuild it must still be enforced.
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) still valid after rebuild");
assert!(
r.block_on(ins("5", "4")).is_err(),
"table-level CHECK survived rebuild via the metadata table"
);
}
#[test]
fn if_not_exists_noop_is_journalled() {
// A successful no-op is still a submission and belongs in the
@@ -381,6 +582,7 @@ fn if_not_exists_noop_is_journalled() {
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (id int)".to_string()),
))
@@ -392,6 +594,7 @@ fn if_not_exists_noop_is_journalled() {
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
true,
Some(noop.to_string()),
))
@@ -411,6 +614,7 @@ fn plain_create_errors_when_table_exists() {
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (id int)".to_string()),
))
@@ -421,6 +625,7 @@ fn plain_create_errors_when_table_exists() {
specs(),
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false, // no IF NOT EXISTS
Some("create table T (id int)".to_string()),
));
@@ -436,6 +641,7 @@ fn sql_create_table_is_one_undo_step() {
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (id int)".to_string()),
))
@@ -487,6 +693,7 @@ fn serial_pk_first_column_autoincrements_after_rebuild() {
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (id serial primary key, name text)".to_string()),
))
@@ -519,6 +726,7 @@ fn serial_pk_non_first_column_autoincrements_after_rebuild() {
],
vec!["id".to_string()],
vec![], // no composite UNIQUE
vec![], // no table CHECK
false,
Some("create table T (name text, id serial primary key)".to_string()),
))
@@ -535,3 +743,57 @@ fn serial_pk_non_first_column_autoincrements_after_rebuild() {
"serial keeps autoincrement after a rebuild even as a non-first column"
);
}
#[test]
fn dropping_a_column_a_table_check_references_fails_cleanly() {
// Cross-cutting safety probe: a simple-mode `drop column` of a column
// that a table-level CHECK references rebuilds the table via
// `schema_to_ddl`, which re-emits `CHECK (a < b)` for a temp table
// that no longer has `a` — the engine rejects it. This must fail
// *cleanly* (the rebuild transaction rolls back), leaving the table
// fully intact, never half-migrated. Up-front detection (parsing the
// referenced columns out of the raw CHECK text so the refusal is
// deliberate) is 4e work; the friendly wording itself is H1. Today's
// clean engine-level rejection is the safe interim (user-confirmed).
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![], // no composite UNIQUE
vec!["a < b".to_string()],
false,
Some("create table T (a int, b int, check (a < b))".to_string()),
))
.expect("create");
let dropped = r.block_on(db.drop_column(
"T".to_string(),
"a".to_string(),
false,
Some("drop column T: a".to_string()),
));
assert!(dropped.is_err(), "dropping a column a CHECK references is rejected");
// The table is intact: both columns survive (rollback) ...
let desc = r
.block_on(db.describe_table("T".to_string(), None))
.expect("describe still works");
assert_eq!(
desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
vec!["a".to_string(), "b".to_string()],
"the failed drop rolled back — no half-migrated table"
);
// ... and the CHECK is still enforced.
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
Some(vec!["a".to_string(), "b".to_string()]),
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("(1,2) valid — table survived intact");
assert!(r.block_on(ins("2", "1")).is_err(), "CHECK still enforced after the failed drop");
}