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:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user