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
+92
View File
@@ -466,6 +466,7 @@ enum Request {
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
check_constraints: Vec<String>,
if_not_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
@@ -829,12 +830,14 @@ impl Database {
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
/// structurally; returns whether the table was created or skipped
/// (the `IF NOT EXISTS` no-op, ADR-0035 §4).
#[allow(clippy::too_many_arguments)]
pub async fn sql_create_table(
&self,
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
check_constraints: Vec<String>,
if_not_exists: bool,
source: Option<String>,
) -> Result<CreateOutcome, DbError> {
@@ -844,6 +847,7 @@ impl Database {
columns,
primary_key,
unique_constraints,
check_constraints,
if_not_exists,
source,
reply,
@@ -1411,6 +1415,12 @@ const REL_TABLE: &str = "__rdbms_playground_relationships";
/// `created_at`. Created on first connect and only ever
/// written by us; the user never touches it directly.
const META_PROJECT_TABLE: &str = "__rdbms_playground_meta";
/// Table-level `CHECK (<expr>)` constraints (ADR-0035 §4a.3). The
/// engine exposes no PRAGMA for CHECK constraints, so — unlike UNIQUE /
/// PK / FK, which are read back from PRAGMA — a table-level CHECK has no
/// engine-readable home and this table is its source of truth. One row
/// per CHECK, ordered by `seq` (declaration order).
const CHECK_TABLE: &str = "__rdbms_playground_table_checks";
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(&format!(
@@ -1432,6 +1442,12 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
on_update TEXT NOT NULL,\n\
PRIMARY KEY (child_table, child_column)\n\
) STRICT;\n\
CREATE TABLE IF NOT EXISTS {CHECK_TABLE} (\n\
table_name TEXT NOT NULL,\n\
seq INTEGER NOT NULL,\n\
check_expr TEXT NOT NULL,\n\
PRIMARY KEY (table_name, seq)\n\
) STRICT;\n\
CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\
key TEXT NOT NULL PRIMARY KEY,\n\
value TEXT NOT NULL\n\
@@ -1691,6 +1707,7 @@ fn handle_request(
&columns,
&primary_key,
&[],
&[],
));
}
Request::SqlCreateTable {
@@ -1698,6 +1715,7 @@ fn handle_request(
columns,
primary_key,
unique_constraints,
check_constraints,
if_not_exists,
source,
reply,
@@ -1726,6 +1744,7 @@ fn handle_request(
&columns,
&primary_key,
&unique_constraints,
&check_constraints,
)
.map(CreateOutcome::Created)
});
@@ -2324,6 +2343,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
primary_key: read.primary_key.clone(),
columns,
unique_constraints: read.unique_constraints.clone(),
check_constraints: read.check_constraints.clone(),
});
}
@@ -2569,6 +2589,7 @@ fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> Read
primary_key: primary_key.to_vec(),
foreign_keys: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
}
}
@@ -2606,6 +2627,7 @@ pub enum CreateOutcome {
Skipped(TableDescription),
}
#[allow(clippy::too_many_arguments)]
fn do_create_table(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -2614,6 +2636,7 @@ fn do_create_table(
columns: &[ColumnSpec],
primary_key: &[String],
unique_constraints: &[Vec<String>],
check_constraints: &[String],
) -> Result<TableDescription, DbError> {
if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar
@@ -2702,6 +2725,17 @@ fn do_create_table(
ddl.push(')');
}
// Table-level CHECK constraints (ADR-0035 §4a.3), emitted verbatim
// from the captured raw SQL text. Must stay identical to the
// `schema_to_ddl` rebuild path (the §6.1 two-generators rule).
// The engine has no PRAGMA to report these back, so they are also
// recorded in `CHECK_TABLE` (below) as their source of truth.
for expr in check_constraints {
ddl.push_str(", CHECK (");
ddl.push_str(expr);
ddl.push(')');
}
ddl.push_str(") STRICT;");
debug!(ddl = %ddl, "create_table");
@@ -2715,6 +2749,18 @@ fn do_create_table(
for (col, check_sql) in columns.iter().zip(&check_sqls) {
insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
}
// Record table-level CHECKs in their metadata table (the engine
// reports no CHECK constraints, ADR-0035 §4a.3). `seq` preserves
// declaration order so read-back / rebuild re-emit them in order.
for (seq, expr) in check_constraints.iter().enumerate() {
tx.execute(
&format!(
"INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr) VALUES (?1, ?2, ?3);"
),
rusqlite::params![name, seq as i64, expr],
)
.map_err(DbError::from_rusqlite)?;
}
let description = do_describe_table(conn, name)?;
let changes = Changes {
schema_dirty: true,
@@ -2764,6 +2810,12 @@ fn do_drop_table(
[name],
)
.map_err(DbError::from_rusqlite)?;
// Table-level CHECK metadata goes with the table (ADR-0035 §4a.3).
tx.execute(
&format!("DELETE FROM {CHECK_TABLE} WHERE table_name = ?1;"),
[name],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: Vec::new(),
@@ -4722,6 +4774,11 @@ struct ReadSchema {
/// read from the UNIQUE-constraint indexes (`origin = 'u'`).
/// Single-column UNIQUE rides on `ReadColumn::unique` instead.
unique_constraints: Vec<Vec<String>>,
/// Table-level CHECK constraints as raw SQL text, in declaration
/// order (ADR-0035 §4a.3). The engine reports no CHECK constraints,
/// so these are read from `__rdbms_playground_table_checks` rather
/// than PRAGMA, and echoed verbatim by `schema_to_ddl` on rebuild.
check_constraints: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -4842,14 +4899,40 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
foreign_keys.push(row.map_err(DbError::from_rusqlite)?);
}
// Table-level CHECK constraints (ADR-0035 §4a.3) come from their
// metadata table, not PRAGMA — the engine reports no CHECKs.
let check_constraints = read_table_checks(conn, table)?;
Ok(ReadSchema {
columns,
primary_key,
foreign_keys,
unique_constraints,
check_constraints,
})
}
/// Read a table's table-level CHECK constraints (ADR-0035 §4a.3) from
/// `CHECK_TABLE`, in declaration order (`seq`). The engine exposes no
/// PRAGMA for CHECK constraints, so this metadata table is their only
/// source of truth.
fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<String>, DbError> {
let mut stmt = conn
.prepare(&format!(
"SELECT check_expr FROM {CHECK_TABLE} \
WHERE table_name = ?1 ORDER BY seq;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([table], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
/// Read the user-created indexes on `table` (ADR-0025).
///
/// `pragma_index_list` reports every index; we keep only those
@@ -5042,6 +5125,13 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
clauses.push(format!("UNIQUE ({})", idents.join(", ")));
}
// Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim
// from the raw SQL stored in the metadata table, emitted identically
// to `do_create_table` (the §6.1 two-generators rule).
for expr in &schema.check_constraints {
clauses.push(format!("CHECK ({expr})"));
}
for fk in &schema.foreign_keys {
clauses.push(format!(
"FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \
@@ -7504,6 +7594,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
primary_key: table.primary_key.clone(),
foreign_keys,
unique_constraints: table.unique_constraints.clone(),
check_constraints: table.check_constraints.clone(),
}
}
@@ -11206,6 +11297,7 @@ mod tests {
primary_key: vec!["id".to_string()],
foreign_keys: vec![],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let ddl = schema_to_ddl("T", &schema);
assert!(
+5
View File
@@ -148,6 +148,11 @@ pub enum Command {
/// (ADR-0035 §4a.2). Single-column table-level `UNIQUE` is
/// folded into the column's `unique` flag instead.
unique_constraints: Vec<Vec<String>>,
/// Table-level `CHECK (<expr>)` constraints, in declaration
/// order, as raw SQL text (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 dedicated metadata table.
check_constraints: Vec<String>,
if_not_exists: bool,
},
/// Add a column to an existing table. The column carries
+38 -5
View File
@@ -1320,7 +1320,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
let mut columns: Vec<ColumnSpec> = Vec::new();
let mut primary_key: Vec<String> = Vec::new();
let mut unique_constraints: Vec<Vec<String>> = Vec::new();
let mut check_constraints: Vec<String> = Vec::new();
let mut pending_name: Option<String> = None;
// Distinguish a table-level `CHECK (…)` from a column-level one
// (ADR-0035 §4a.3): both are spelled `check (`, and `Word` matches
// carry no role, so position is the only signal. `column_open` is
// `true` while a column definition is accepting constraints in the
// current element; a `check` seen while it is `false` is table-level.
// `depth` tracks the parens that reach this loop (the outer column
// list, type length-args `(10, 2)`, and table-`PRIMARY KEY (a, b)` —
// the `check`/`default`/table-`unique` arms consume their own parens
// internally, so they never perturb it). An element separator is a
// comma at the column-list interior, `depth == 1`.
let mut column_open = false;
let mut depth = 0usize;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
@@ -1336,6 +1349,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
})?;
let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
columns.push(ColumnSpec::new(col_name, ty));
column_open = true;
}
// `double precision` — the two-word alias maps to `real`.
// The grammar guarantees `precision` follows `double`.
@@ -1348,6 +1362,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
}
let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
columns.push(ColumnSpec::new(col_name, Type::Real));
column_open = true;
}
// A table-level `PRIMARY KEY (col, …)` column reference.
MatchedKind::Ident { role: "pk_column", .. } => {
@@ -1432,14 +1447,31 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
}
}
// `check ( <expr> )` — capture the inner expression text
// (without the wrapping parens) by matching paren depth.
// (without the wrapping parens) by matching paren depth, then
// route by element position: a CHECK inside an open column
// definition is column-level (4a.2); one seen at element
// start (no column open) is a table-level CHECK (4a.3).
MatchedKind::Word("check") => {
if let Some((s, e)) = capture_parenthesised_span(&mut items)
&& let Some(last) = columns.last_mut()
{
last.check_sql = Some(source[s..e].trim().to_string());
if let Some((s, e)) = capture_parenthesised_span(&mut items) {
let text = source[s..e].trim().to_string();
if column_open {
if let Some(last) = columns.last_mut() {
last.check_sql = Some(text);
}
} else {
check_constraints.push(text);
}
}
}
// Track paren depth for element-boundary detection. The
// column/`check`/`default`/table-`unique` arms consume their
// own parens, so the only parens reaching here are the outer
// column list, type length-args, and table-`PRIMARY KEY (…)`.
MatchedKind::Punct('(') => depth += 1,
MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
// A comma at the column-list interior ends the current
// element — the next element starts fresh (no column open).
MatchedKind::Punct(',') if depth == 1 => column_open = false,
_ => {}
}
}
@@ -1461,6 +1493,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
columns,
primary_key,
unique_constraints,
check_constraints,
if_not_exists,
})
}
+122 -10
View File
@@ -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());
}
}
+7
View File
@@ -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)]
+67
View File
@@ -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![],
+10 -1
View File
@@ -1925,9 +1925,18 @@ async fn execute_command_typed(
columns,
primary_key,
unique_constraints,
check_constraints,
if_not_exists,
} => database
.sql_create_table(name, columns, primary_key, unique_constraints, if_not_exists, src)
.sql_create_table(
name,
columns,
primary_key,
unique_constraints,
check_constraints,
if_not_exists,
src,
)
.await
.map(|outcome| match outcome {
CreateOutcome::Created(d) => CommandOutcome::Schema(Some(d)),