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:
@@ -236,12 +236,27 @@ static TABLE_UNIQUE_NODES: &[Node] = &[
|
||||
];
|
||||
const TABLE_UNIQUE: Node = Node::Seq(TABLE_UNIQUE_NODES);
|
||||
|
||||
// One element of the column list: a table-level `PRIMARY KEY (…)` or a
|
||||
// column definition. `TABLE_PK` is tried first — it starts with the
|
||||
// keyword `primary`, which disambiguates it from a column name. (A
|
||||
// column literally named `primary` is therefore unavailable, the same
|
||||
// trade real SQL makes with its reserved words.)
|
||||
static ELEMENT_CHOICES: &[Node] = &[TABLE_PK, TABLE_UNIQUE, COLUMN_DEF];
|
||||
// Table-level `CHECK ( <expr> )` (ADR-0035 §4a.3) — a multi-column
|
||||
// CHECK referencing several columns. Same paren-bounded shape as the
|
||||
// column-level CHECK; the builder tells them apart by position (a
|
||||
// CHECK at element start, with no column definition open, is
|
||||
// table-level). The engine reports no CHECK constraints, so a
|
||||
// table-level CHECK round-trips via a dedicated metadata table.
|
||||
static TABLE_CHECK_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("check")),
|
||||
Node::Punct('('),
|
||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
||||
Node::Punct(')'),
|
||||
];
|
||||
const TABLE_CHECK: Node = Node::Seq(TABLE_CHECK_NODES);
|
||||
|
||||
// One element of the column list: a table-level `PRIMARY KEY (…)` /
|
||||
// `UNIQUE (…)` / `CHECK (…)`, or a column definition. The table-level
|
||||
// forms are tried first — each starts with a keyword (`primary` /
|
||||
// `unique` / `check`) that disambiguates it from a column name. (A
|
||||
// column literally named `primary`/`unique`/`check` is therefore
|
||||
// unavailable, the same trade real SQL makes with its reserved words.)
|
||||
static ELEMENT_CHOICES: &[Node] = &[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, COLUMN_DEF];
|
||||
const ELEMENT: Node = Node::Choice(ELEMENT_CHOICES);
|
||||
|
||||
static COLUMN_LIST_NODES: &[Node] = &[
|
||||
@@ -445,10 +460,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_level_check_and_fk_still_rejected() {
|
||||
// Table-level (multi-column) CHECK is 4a.3 (needs a metadata
|
||||
// table); FK is 4b. Neither shape exists here yet.
|
||||
bad("table t (a int, b int, check (a < b))");
|
||||
fn table_level_check_accepted() {
|
||||
// 4a.3: a table-level (multi-column) CHECK is now admitted, in
|
||||
// any position among the elements and alongside other forms.
|
||||
good("table t (a int, b int, check (a < b))");
|
||||
good("table t (a int, b int, c int, check (a < b), check (b < c))");
|
||||
good("table t (a int, b int, primary key (a), check (a < b))");
|
||||
good("table t (a int, b int, unique (a, b), check (a <> b))");
|
||||
good("table t (price real check (price >= 0), total real, check (total >= price))");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_key_still_rejected() {
|
||||
// FK in CREATE TABLE is 4b — neither inline `REFERENCES` nor a
|
||||
// table-level `FOREIGN KEY` shape exists in the grammar yet.
|
||||
bad("table t (id int, ref int references other(id))");
|
||||
bad("table t (id int, foreign key (id) references other(id))");
|
||||
}
|
||||
@@ -696,4 +721,91 @@ mod builder_tests {
|
||||
assert!(col(&cols, "b").unique, "it folds into the column's flag");
|
||||
assert!(!col(&cols, "a").unique);
|
||||
}
|
||||
|
||||
// --- 4a.3: table-level / multi-column CHECK ---
|
||||
|
||||
/// Parse and return the columns + the table-level CHECK constraints.
|
||||
fn parse_sct_checks(input: &str) -> (Vec<ColumnSpec>, Vec<String>) {
|
||||
match parse_command(input).expect("should parse") {
|
||||
Command::SqlCreateTable {
|
||||
columns,
|
||||
check_constraints,
|
||||
..
|
||||
} => (columns, check_constraints),
|
||||
other => panic!("expected SqlCreateTable, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_level_check_captured_as_raw_text() {
|
||||
let (cols, checks) = parse_sct_checks("create table t (a int, b int, check (a < b))");
|
||||
assert_eq!(checks, vec!["a < b".to_string()]);
|
||||
// The CHECK belongs to no column — it stays table-level.
|
||||
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_table_checks_preserve_declaration_order() {
|
||||
let (_, checks) =
|
||||
parse_sct_checks("create table t (a int, b int, c int, check (a < b), check (b < c))");
|
||||
assert_eq!(checks, vec!["a < b".to_string(), "b < c".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_check_and_table_check_route_separately() {
|
||||
// A column-level CHECK (after a column's type) and a table-level
|
||||
// CHECK (its own element) in the same statement must not be
|
||||
// conflated — the load-bearing distinction of 4a.3.
|
||||
let (cols, checks) = parse_sct_checks(
|
||||
"create table t (price real check (price >= 0), total real, check (total >= price))",
|
||||
);
|
||||
assert_eq!(col(&cols, "price").check_sql.as_deref(), Some("price >= 0"));
|
||||
assert!(col(&cols, "total").check_sql.is_none());
|
||||
assert_eq!(checks, vec!["total >= price".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_check_after_length_arg_stays_column_level() {
|
||||
// The depth trap: the `,` inside `numeric(10, 2)` is at paren
|
||||
// depth 2, not an element boundary, so the following `check`
|
||||
// is still column-level. A naive "reset on any comma" would
|
||||
// misclassify it as table-level (the §4.2 probe).
|
||||
let (cols, checks) =
|
||||
parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
|
||||
assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
|
||||
assert!(checks.is_empty(), "no table-level CHECK was produced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_check_after_table_primary_key() {
|
||||
// A table-PK `(a, b)` injects its own parens/comma into the
|
||||
// item stream; the following table CHECK must still be detected.
|
||||
let (_, checks) =
|
||||
parse_sct_checks("create table t (a int, b int, primary key (a, b), check (a < b))");
|
||||
assert_eq!(checks, vec!["a < b".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_check_after_table_unique() {
|
||||
let (_, checks) =
|
||||
parse_sct_checks("create table t (a int, b int, unique (a, b), check (a <> b))");
|
||||
assert_eq!(checks, vec!["a <> b".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_check_captures_balanced_nested_parens() {
|
||||
let (_, checks) =
|
||||
parse_sct_checks("create table t (a int, b int, check ((a + b) > (a - b)))");
|
||||
assert_eq!(checks, vec!["(a + b) > (a - b)".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_check_before_a_later_column_is_table_level() {
|
||||
// A CHECK element that appears between columns (not after a
|
||||
// column's type) is table-level even though more columns follow.
|
||||
let (cols, checks) =
|
||||
parse_sct_checks("create table t (a int, check (a > 0), b int)");
|
||||
assert_eq!(checks, vec!["a > 0".to_string()]);
|
||||
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user