feat: ADR-0035 4a.2 — per-column CHECK/DEFAULT + composite UNIQUE

Advanced-mode SQL CREATE TABLE gains the constraints that need no new
internal table (the 4a.2 slice):

- Grammar (sql_create_table.rs): column-level DEFAULT/CHECK and
  table-level UNIQUE(cols). DEFAULT is a literal or a *parenthesised*
  expression (standard SQL) — a bare sql_expr greedily eats a following
  NOT (NOT IN/LIKE/BETWEEN), breaking `DEFAULT 0 NOT NULL`; the parens
  bound it. CHECK is paren-bounded already.
- Builder (ddl.rs): captures CHECK/DEFAULT raw SQL text by byte span
  (sql_expr builds no AST) via capture_parenthesised_span /
  capture_expr_span; routes single-column table UNIQUE into the
  column's flag and composite UNIQUE into unique_constraints.
- Command/worker: ColumnSpec gains check_sql/default_sql (raw, preferred
  over the typed Expr/Value); Command::SqlCreateTable + Request +
  do_create_table gain unique_constraints; do_create_table emits raw
  CHECK/DEFAULT and composite UNIQUE clauses.
- Round-trip (part D): ReadSchema/TableSchema gain unique_constraints;
  read_schema detects composite UNIQUE via PRAGMA index_list origin 'u'
  (single-column still folds to the column flag); schema_to_ddl emits
  them; YAML RawTable/write_table round-trips (optional-on-read).
  CHECK round-trips via __rdbms_playground_columns.check_expr, DEFAULT
  via PRAGMA table_info — no new metadata table.

Table-level/multi-column CHECK remains 4a.3 (rejected "not yet
supported"); FK is 4b.

Tests: +7 builder (raw-text capture incl. the DEFAULT 0 NOT NULL
boundary the fix was found by; single/composite UNIQUE routing) and +4
Tier-3 (CHECK enforced, DEFAULT applied, composite UNIQUE enforced, and
all three survive a rebuild — the part-D round-trip). 1752 pass / 0 fail
/ 1 ignored; clippy clean. Plan + requirements.md updated.
This commit is contained in:
claude@clouddev1
2026-05-25 11:04:59 +00:00
parent 1c50133438
commit c0f5626787
10 changed files with 627 additions and 59 deletions
+75 -15
View File
@@ -465,6 +465,7 @@ enum Request {
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
if_not_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
@@ -833,6 +834,7 @@ impl Database {
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
if_not_exists: bool,
source: Option<String>,
) -> Result<CreateOutcome, DbError> {
@@ -841,6 +843,7 @@ impl Database {
name,
columns,
primary_key,
unique_constraints,
if_not_exists,
source,
reply,
@@ -1687,12 +1690,14 @@ fn handle_request(
&name,
&columns,
&primary_key,
&[],
));
}
Request::SqlCreateTable {
name,
columns,
primary_key,
unique_constraints,
if_not_exists,
source,
reply,
@@ -1720,6 +1725,7 @@ fn handle_request(
&name,
&columns,
&primary_key,
&unique_constraints,
)
.map(CreateOutcome::Created)
});
@@ -2317,6 +2323,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
name: name.clone(),
primary_key: read.primary_key.clone(),
columns,
unique_constraints: read.unique_constraints.clone(),
});
}
@@ -2493,7 +2500,14 @@ fn column_constraints_sql(spec: &ColumnSpec) -> Result<String, DbError> {
if spec.unique {
sql.push_str(" UNIQUE");
}
if let Some(literal) = default_sql_literal(spec)? {
// Advanced-mode raw `DEFAULT <expr>` (ADR-0035 §4a.2) takes
// precedence over a simple-mode typed default; SQLite stores the
// literal text and `PRAGMA table_info` reports it back for the
// round-trip (no metadata needed for DEFAULT).
if let Some(raw) = &spec.default_sql {
sql.push_str(" DEFAULT ");
sql.push_str(raw);
} else if let Some(literal) = default_sql_literal(spec)? {
sql.push_str(" DEFAULT ");
sql.push_str(&literal);
}
@@ -2554,6 +2568,7 @@ fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> Read
.collect(),
primary_key: primary_key.to_vec(),
foreign_keys: Vec::new(),
unique_constraints: Vec::new(),
}
}
@@ -2598,6 +2613,7 @@ fn do_create_table(
name: &str,
columns: &[ColumnSpec],
primary_key: &[String],
unique_constraints: &[Vec<String>],
) -> Result<TableDescription, DbError> {
if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar
@@ -2629,9 +2645,16 @@ fn do_create_table(
// by the DDL clause and the metadata insert below. The
// minimal schema gives `compile_expr` the column types.
let check_schema = read_schema_for_specs(columns, primary_key);
// Advanced-mode raw `CHECK` text (ADR-0035 §4a.2) wins over a
// compiled simple-mode `Expr`; both are stored verbatim in the
// column metadata and echoed by `schema_to_ddl` on rebuild.
let check_sqls: Vec<Option<String>> = columns
.iter()
.map(|c| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema)))
.map(|c| {
c.check_sql
.clone()
.or_else(|| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema)))
})
.collect();
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
@@ -2669,6 +2692,16 @@ fn do_create_table(
ddl.push(')');
}
// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2).
// Single-column UNIQUE rides on the column's inline `UNIQUE`; these
// are the multi-column table-level constraints.
for cols in unique_constraints {
let idents: Vec<String> = cols.iter().map(|n| quote_ident(n)).collect();
ddl.push_str(", UNIQUE (");
ddl.push_str(&idents.join(", "));
ddl.push(')');
}
ddl.push_str(") STRICT;");
debug!(ddl = %ddl, "create_table");
@@ -4685,6 +4718,10 @@ struct ReadSchema {
columns: Vec<ReadColumn>,
primary_key: Vec<String>,
foreign_keys: Vec<ReadForeignKey>,
/// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2),
/// read from the UNIQUE-constraint indexes (`origin = 'u'`).
/// Single-column UNIQUE rides on `ReadColumn::unique` instead.
unique_constraints: Vec<Vec<String>>,
}
#[derive(Debug, Clone)]
@@ -4767,13 +4804,12 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
.map(|c| c.name.clone())
.collect();
// Detect single-column UNIQUE constraints (ADR-0018 §4).
// Detect UNIQUE constraints (ADR-0018 §4, ADR-0035 §4a.2).
// pragma_index_list returns one row per index; we filter to
// unique indexes whose origin is "u" (a UNIQUE constraint,
// as opposed to "pk" or "c"). For each, pragma_index_info
// gives the constituent column(s); we only mark single-
// column unique here. Compound UNIQUE is out of scope.
let unique_columns = read_unique_columns(conn, table)?;
// unique indexes whose origin is "u" (a UNIQUE constraint, as
// opposed to "pk" or "c"). Single-column → the column's `unique`
// flag; multi-column → a composite `unique_constraints` entry.
let (unique_columns, unique_constraints) = read_unique_constraints(conn, table)?;
for col in &mut columns {
if unique_columns.contains(&col.name) {
col.unique = true;
@@ -4810,6 +4846,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
columns,
primary_key,
foreign_keys,
unique_constraints,
})
}
@@ -4888,11 +4925,18 @@ fn parse_action_from_sqlite(s: &str) -> ReferentialAction {
/// `"u"` (a UNIQUE constraint, not a PK-implied or CHECK
/// auto-index) and which cover exactly one column. Compound
/// UNIQUE is deferred to a future ADR (out of scope for ADR-0018).
fn read_unique_columns(
/// Read the table's `UNIQUE` constraints (ADR-0018 §4, ADR-0035
/// §4a.2) from the constraint-backing indexes (`origin = 'u'`).
/// Returns `(single_column_names, composite_constraints)`: a
/// single-column UNIQUE rides on the column's `unique` flag, while a
/// multi-column UNIQUE becomes a `Vec<String>` of its columns (in
/// index order).
fn read_unique_constraints(
conn: &Connection,
table: &str,
) -> Result<std::collections::HashSet<String>, DbError> {
let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
) -> Result<(std::collections::HashSet<String>, Vec<Vec<String>>), DbError> {
let mut single: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut composite: Vec<Vec<String>> = Vec::new();
let mut idx_stmt = conn
.prepare(
"SELECT name, \"unique\", origin \
@@ -4916,18 +4960,22 @@ fn read_unique_columns(
continue;
}
let mut info_stmt = conn
.prepare("SELECT name FROM pragma_index_info(?1);")
.prepare("SELECT name FROM pragma_index_info(?1) ORDER BY seqno;")
.map_err(DbError::from_rusqlite)?;
let cols: Vec<String> = info_stmt
.query_map([&idx_name], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?
.collect::<Result<Vec<_>, _>>()
.map_err(DbError::from_rusqlite)?;
if cols.len() == 1 {
out.insert(cols.into_iter().next().expect("len 1"));
match cols.len() {
0 => {}
1 => {
single.insert(cols.into_iter().next().expect("len 1"));
}
_ => composite.push(cols),
}
}
Ok(out)
Ok((single, composite))
}
/// Generate the CREATE TABLE DDL from a `ReadSchema`. Used during
@@ -4986,6 +5034,14 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
clauses.push(format!("PRIMARY KEY ({})", pk_idents.join(", ")));
}
// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) —
// emitted identically to `do_create_table` so a created table and
// its rebuilt form match.
for cols in &schema.unique_constraints {
let idents: Vec<String> = cols.iter().map(|n| quote_ident(n)).collect();
clauses.push(format!("UNIQUE ({})", idents.join(", ")));
}
for fk in &schema.foreign_keys {
clauses.push(format!(
"FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \
@@ -7447,6 +7503,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
columns,
primary_key: table.primary_key.clone(),
foreign_keys,
unique_constraints: table.unique_constraints.clone(),
}
}
@@ -10365,6 +10422,8 @@ mod tests {
unique,
default,
check: None,
default_sql: None,
check_sql: None,
}
}
@@ -11146,6 +11205,7 @@ mod tests {
],
primary_key: vec!["id".to_string()],
foreign_keys: vec![],
unique_constraints: Vec::new(),
};
let ddl = schema_to_ddl("T", &schema);
assert!(