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
+138 -16
View File
@@ -1288,17 +1288,27 @@ pub static CREATE: CommandNode = CommandNode {
help_id: Some("ddl.create"),
usage_ids: &["parse.usage.create_table"],};
/// The friendly error for a column type without a preceding name —
/// a structural impossibility given the grammar, defended anyway.
fn sql_col_type_without_name() -> ValidationError {
ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "column type without a name".to_string())],
}
}
/// Build a `Command::SqlCreateTable` from the advanced-mode SQL
/// `CREATE TABLE` shape (ADR-0035 §1, sub-phase 4a). Executes
/// `CREATE TABLE` shape (ADR-0035 §1, sub-phases 4a + 4a.2). Executes
/// structurally — extracts the same `ColumnSpec`/`primary_key` the
/// simple-mode builder produces so the worker reuses `do_create_table`.
///
/// 4a surface: columns + types (the §3 alias map incl. `double
/// precision`) + `NOT NULL` / `UNIQUE` / column- and table-level
/// `PRIMARY KEY` + `IF NOT EXISTS`. `DEFAULT` / `CHECK` /
/// table-level `UNIQUE` are absent from the grammar (4a.2), so they
/// never reach this builder.
fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
/// Surface: columns and types (the §3 alias map incl. `double
/// precision`), `NOT NULL` / `UNIQUE` / column- and table-level
/// `PRIMARY KEY`, and `IF NOT EXISTS` (4a); per-column `DEFAULT` and
/// `CHECK` (raw `sql_expr` text captured by byte span — `sql_expr`
/// builds no AST) and composite `UNIQUE (a, b)` (4a.2). Table-level
/// multi-column `CHECK` and FK are absent from the grammar (4a.3 / 4b).
fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
let name = require_ident(path, "table_name")?;
// `if` only appears in the `IF NOT EXISTS` prefix (the `not` of
// `NOT NULL` never carries an `if`), so its presence is the flag.
@@ -1309,6 +1319,7 @@ fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command,
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 pending_name: Option<String> = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
@@ -1323,10 +1334,7 @@ fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command,
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let col_name = pending_name.take().ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "column type without a name".to_string())],
})?;
let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
columns.push(ColumnSpec::new(col_name, ty));
}
// `double precision` — the two-word alias maps to `real`.
@@ -1338,10 +1346,7 @@ fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command,
) {
items.next();
}
let col_name = pending_name.take().ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "column type without a name".to_string())],
})?;
let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
columns.push(ColumnSpec::new(col_name, Type::Real));
}
// A table-level `PRIMARY KEY (col, …)` column reference.
@@ -1361,8 +1366,38 @@ fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command,
}
}
}
// `unique` — table-level `UNIQUE (cols)` when followed by
// `(`, else a column-level constraint on the last column.
MatchedKind::Word("unique") => {
if let Some(last) = columns.last_mut() {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next(); // consume '('
let mut cols: Vec<String> = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => {
cols.push(it.text.clone());
items.next();
}
MatchedKind::Punct(',') => {
items.next();
}
MatchedKind::Punct(')') => {
items.next();
break;
}
_ => break,
}
}
// Single-column table-level UNIQUE folds into the
// column's flag (round-trips via the single-column
// path); composite (or a name not among the
// columns) becomes a constraint.
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols),
None => {}
}
} else if let Some(last) = columns.last_mut() {
last.unique = true;
}
}
@@ -1385,6 +1420,26 @@ fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command,
}
}
}
// `default <expr>` — capture the expression's raw SQL text
// by byte span (`sql_expr` builds no AST). The match is
// maximal, so the expression runs until a depth-0 element
// boundary (`,` / `)`) or the next constraint keyword.
MatchedKind::Word("default") => {
if let Some((s, e)) = capture_expr_span(&mut items)
&& let Some(last) = columns.last_mut()
{
last.default_sql = Some(source[s..e].trim().to_string());
}
}
// `check ( <expr> )` — capture the inner expression text
// (without the wrapping parens) by matching paren depth.
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());
}
}
_ => {}
}
}
@@ -1405,10 +1460,77 @@ fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command,
name,
columns,
primary_key,
unique_constraints,
if_not_exists,
})
}
/// Capture the byte span of a `DEFAULT <expr>` expression from the
/// matched-item stream (ADR-0035 §4a.2). Consumes the expression's
/// terminals (tracking paren depth) and stops *without consuming* the
/// next depth-0 element boundary (`,` / `)`) or constraint keyword
/// (`not` / `unique` / `primary` / `check`) — those terminals were
/// matched by the following constraint/element, not by the expression.
/// Returns `(start, end)` byte offsets, or `None` if no expression
/// terminal followed.
fn capture_expr_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
let mut depth = 0usize;
let mut start: Option<usize> = None;
let mut end = 0usize;
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Punct(',' | ')') if depth == 0 => break,
MatchedKind::Word("not" | "unique" | "primary" | "check") if depth == 0 => break,
_ => {
match &it.kind {
MatchedKind::Punct('(') => depth += 1,
MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
_ => {}
}
start.get_or_insert(it.span.0);
end = it.span.1;
items.next();
}
}
}
start.map(|s| (s, end))
}
/// Capture the byte span of the contents of a parenthesised group
/// (`CHECK ( <expr> )`) from the matched-item stream — the next item
/// must be the opening `(`. Consumes through the matching `)` (tracking
/// nested parens) and returns the `(start, end)` offsets of the text
/// *between* the parens, or `None` if no `(` follows.
fn capture_parenthesised_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
if !matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
return None;
}
let open = items.next()?; // '('
let inner_start = open.span.1;
let mut depth = 1usize;
let mut inner_end = inner_start;
for it in items.by_ref() {
match &it.kind {
MatchedKind::Punct('(') => depth += 1,
MatchedKind::Punct(')') => {
depth -= 1;
if depth == 0 {
inner_end = it.span.0;
break;
}
}
_ => {}
}
}
Some((inner_start, inner_end))
}
pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),