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:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user