feat: ADR-0035 4i(a,b) — CREATE TABLE help/usage + describe table constraints; Phase 4 complete

(b) describe shows table-level constraints: TableDescription gains
unique_constraints + check_constraints (populated by do_describe_table
from read_schema), rendered in a new "Table constraints:" section —
composite UNIQUE and table-level CHECK (named + unnamed). The per-column
Constraints column already covered single-column NOT NULL/UNIQUE/PK/CHECK.

(a) CREATE TABLE help/usage skeleton refreshed for the column DEFAULT/
CHECK/REFERENCES, table-level composite UNIQUE, table CHECK, and
table-level FOREIGN KEY forms (4a.2/4a.3/4b) — engine-neutral,
vocab-audit clean.

With 4i's (c)/(d)/(e) already shipped, this completes sub-phase 4i — the
verification sweep — and therefore ADR-0035 Phase 4 (4a–4i). ADR-0035
Status, §13 4i, the ADR index, and requirements.md Q1 updated to
"Phase 4 complete".

Tests: render_structure table-level-constraints unit test +
e2e_describe_shows_table_level_constraints. Full suite 1917 passing /
0 failing / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 14:38:28 +00:00
parent c2eb8cb982
commit 22e5bf5d6a
10 changed files with 176 additions and 46 deletions
+2
View File
@@ -2596,6 +2596,8 @@ mod tests {
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
}
}
+9
View File
@@ -70,6 +70,13 @@ pub struct TableDescription {
pub inbound_relationships: Vec<RelationshipEnd>,
/// User-created indexes on this table (ADR-0025).
pub indexes: Vec<IndexInfo>,
/// Table-level composite `UNIQUE (a, b, …)` constraints (ADR-0035
/// §4a.2). Single-column UNIQUE rides on the column itself
/// (`ColumnDescription::unique`); these are the multi-column ones.
pub unique_constraints: Vec<Vec<String>>,
/// Table-level `CHECK (…)` constraints with their optional name
/// (ADR-0035 §4a.3 / §4g), in declaration order.
pub check_constraints: Vec<crate::persistence::TableCheck>,
}
/// One user-created index on a table (ADR-0025).
@@ -7390,6 +7397,8 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription,
outbound_relationships,
inbound_relationships,
indexes,
unique_constraints: schema.unique_constraints.clone(),
check_constraints: schema.check_constraints,
})
}
+6 -3
View File
@@ -261,8 +261,11 @@ help:
create: |-
create table <T> with pk [<col>(<type>), ...] — create a table
sql_create_table: |-
create table [if not exists] <T> (<col> <type> [not null] [unique] [primary key], ...
[, primary key (<col>, ...)]) — create a table (advanced SQL)
create table [if not exists] <T> (
<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ...
[, primary key (<col>, ...)] [, unique (<col>, ...)] [, check (<expr>)]
[, [constraint <name>] foreign key (<col>) references <P>[(<col>)]])
— create a table (advanced SQL)
sql_drop_table: |-
drop table [if exists] <T> — remove a table (advanced SQL)
sql_create_index: |-
@@ -475,7 +478,7 @@ parse:
# placeholders. ADR-0009's surface conventions apply.
usage:
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
sql_create_table: "create table [if not exists] <Name> (<col> <type> [not null] [unique] [primary key], ... [, primary key (<col>, ...)])"
sql_create_table: "create table [if not exists] <Name> (<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <Parent>[(<col>)]], ... [, primary key (<col>, ...)] [, unique (<col>, ...)] [, check (<expr>)] [, [constraint <name>] foreign key (<col>) references <Parent>[(<col>)]])"
sql_drop_table: "drop table [if exists] <Name>"
sql_create_index: "create [unique] index [if not exists] [<Name>] on <Table> (<col>[, ...])"
sql_drop_index: "drop index [if exists] <Name>"
+62
View File
@@ -152,6 +152,24 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
}
}
// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
// and table `CHECK (…)` constraints. Single-column UNIQUE / NOT NULL /
// PK / column-level CHECK already show in the per-column "Constraints"
// column above; this section is the table-level constraints that span
// columns or stand alone. A named CHECK shows its name.
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
out.push("Table constraints:".to_string());
for cols in &desc.unique_constraints {
out.push(format!(" unique ({})", cols.join(", ")));
}
for chk in &desc.check_constraints {
match &chk.name {
Some(name) => out.push(format!(" check {name} ({})", chk.expr)),
None => out.push(format!(" check ({})", chk.expr)),
}
}
}
out
}
@@ -730,6 +748,8 @@ mod tests {
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
assert_snapshot!(render_structure(&desc).join("\n"));
}
@@ -749,6 +769,8 @@ mod tests {
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(
@@ -774,6 +796,8 @@ mod tests {
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
// PK appears for id, NOT NULL for name, blank for nick.
@@ -796,6 +820,8 @@ mod tests {
columns: vec!["Email".to_string()],
unique: false,
}],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("Indexes:"), "got:\n{out}");
@@ -821,11 +847,43 @@ mod tests {
columns: vec!["Email".to_string()],
unique: true,
}],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("uidx_email (Email) [unique]"), "got:\n{out}");
}
#[test]
fn render_structure_shows_table_level_constraints() {
// ADR-0035 §4i (b): composite UNIQUE and table-level CHECK
// (named + unnamed) render in a "Table constraints:" section,
// distinct from the per-column "Constraints" column.
let desc = TableDescription {
name: "T".to_string(),
columns: vec![
col("a", Type::Int, true, false),
col("b", Type::Int, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()),
expr: "a <> b".to_string(),
},
],
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("Table constraints:"), "got:\n{out}");
assert!(out.contains("unique (a, b)"), "got:\n{out}");
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
}
#[test]
fn render_structure_omits_indexes_section_when_none() {
let desc = TableDescription {
@@ -834,6 +892,8 @@ mod tests {
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(!out.contains("Indexes:"), "got:\n{out}");
@@ -1026,6 +1086,8 @@ mod tests {
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
// The lowercase form of the SQLite type should appear.
+2
View File
@@ -1310,6 +1310,8 @@ mod tests {
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
app.current_table = Some(desc);
// Mirror what the App writes when a DSL command succeeds.