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:
@@ -146,6 +146,13 @@ pub struct TableSchema {
|
||||
/// written before composite UNIQUE existed — the YAML field is
|
||||
/// optional on read.
|
||||
pub unique_constraints: Vec<Vec<String>>,
|
||||
/// Table-level `CHECK (<expr>)` constraints, in declaration
|
||||
/// order, as raw SQL text (ADR-0035 §4a.3). The engine reports
|
||||
/// no CHECK constraints, so these are the source of truth (held
|
||||
/// in `__rdbms_playground_table_checks`) and echoed verbatim
|
||||
/// into the rebuilt DDL. Empty for project files written before
|
||||
/// table-level CHECK existed — the YAML field is optional on read.
|
||||
pub check_constraints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -105,6 +105,15 @@ fn write_table(out: &mut String, table: &TableSchema) {
|
||||
let _ = writeln!(out, "]");
|
||||
}
|
||||
}
|
||||
// Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3) —
|
||||
// double-quoted (an expression like `a < b` is not a bare scalar)
|
||||
// and emitted only when present.
|
||||
if !table.check_constraints.is_empty() {
|
||||
let _ = writeln!(out, " check_constraints:");
|
||||
for expr in &table.check_constraints {
|
||||
let _ = writeln!(out, " - {}", yaml_string(expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Always render `s` as a double-quoted YAML string — used
|
||||
@@ -265,6 +274,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
||||
primary_key: t.primary_key,
|
||||
columns,
|
||||
unique_constraints: t.unique_constraints,
|
||||
check_constraints: t.check_constraints,
|
||||
});
|
||||
}
|
||||
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
|
||||
@@ -377,6 +387,10 @@ struct RawTable {
|
||||
/// Optional on read — older project files omit it.
|
||||
#[serde(default)]
|
||||
unique_constraints: Vec<Vec<String>>,
|
||||
/// Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3).
|
||||
/// Optional on read — older project files omit it.
|
||||
#[serde(default)]
|
||||
check_constraints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -439,6 +453,7 @@ mod tests {
|
||||
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None },
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
},
|
||||
TableSchema {
|
||||
name: "Orders".to_string(),
|
||||
@@ -448,6 +463,7 @@ mod tests {
|
||||
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
},
|
||||
],
|
||||
relationships: vec![RelationshipSchema {
|
||||
@@ -517,6 +533,7 @@ mod tests {
|
||||
check: None,
|
||||
}],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
}],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
@@ -575,6 +592,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
}],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
@@ -584,6 +602,54 @@ mod tests {
|
||||
assert_eq!(parsed, snap, "constraints survive the yaml round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_level_constraints_round_trip_through_yaml() {
|
||||
// Composite UNIQUE and table-level CHECK (raw SQL text) survive
|
||||
// a serialize → parse cycle in declaration order (ADR-0035
|
||||
// §4a.2 / §4a.3).
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
tables: vec![TableSchema {
|
||||
name: "T".to_string(),
|
||||
primary_key: vec![],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
],
|
||||
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
|
||||
check_constraints: vec!["a < b".to_string(), "b < c".to_string()],
|
||||
}],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
};
|
||||
let body = serialize_schema(&snap);
|
||||
let parsed = parse_schema(&body).expect("parse schema");
|
||||
assert_eq!(
|
||||
parsed, snap,
|
||||
"table-level UNIQUE + CHECK survive the yaml round-trip in order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_constraints_optional_on_read() {
|
||||
// A project file written before table-level CHECK existed (no
|
||||
// `check_constraints:` key) parses with an empty list.
|
||||
let body = "\
|
||||
version: 1
|
||||
project:
|
||||
created_at: 2026-05-25T00:00:00Z
|
||||
tables:
|
||||
- name: T
|
||||
primary_key: [id]
|
||||
columns:
|
||||
- { name: id, type: int }
|
||||
relationships: []
|
||||
";
|
||||
let parsed = parse_schema(body).expect("parse");
|
||||
assert!(parsed.tables[0].check_constraints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_yaml_with_no_tables() {
|
||||
let body = "\
|
||||
@@ -662,6 +728,7 @@ relationships:
|
||||
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
}],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
|
||||
Reference in New Issue
Block a user