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
+170 -14
View File
@@ -24,7 +24,7 @@
//! `sql_insert::SQL_INSERT_SHAPE`, which starts at `INTO`).
use crate::dsl::grammar::sql_select::reject_internal_table;
use crate::dsl::grammar::{IdentSource, Node, ValidationError, Word};
use crate::dsl::grammar::{IdentSource, Node, ValidationError, Word, sql_expr};
use crate::dsl::types::Type;
static COMMA: Node = Node::Punct(',');
@@ -107,13 +107,47 @@ static PRIMARY_KEY_NODES: &[Node] = &[
Node::Word(Word::keyword("primary")),
Node::Word(Word::keyword("key")),
];
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY`. `DEFAULT` / `CHECK` are
// deliberately absent (4a.2): typing them is an ordinary parse error
// until the constraint slice lands.
// `DEFAULT <value>` / `CHECK (<expr>)` reuse the full ADR-0031
// `sql_expr` surface (the same fragment `WHERE`/projections use). The
// fragment is validate-only (no AST), so the builder captures the
// matched text's **raw SQL** by byte span (ADR-0035 §4a.2).
//
// A bare `DEFAULT` value is a **literal** (or a *parenthesised*
// expression) — matching standard SQL, where a complex default must be
// `DEFAULT (expr)`. This is not just spec fidelity: a bare unbounded
// `sql_expr` greedily consumes a following `NOT` (as the start of
// `NOT IN`/`NOT LIKE`/`NOT BETWEEN`), which would break the common
// `DEFAULT 0 NOT NULL`. The parens give the expression a clean end.
static DEFAULT_PAREN_EXPR_NODES: &[Node] = &[
Node::Punct('('),
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
Node::Punct(')'),
];
static DEFAULT_VALUE_CHOICES: &[Node] = &[
Node::Seq(DEFAULT_PAREN_EXPR_NODES),
Node::NumberLit { validator: None },
Node::StringLit,
Node::Word(Word::keyword("null")),
Node::Word(Word::keyword("true")),
Node::Word(Word::keyword("false")),
];
const DEFAULT_VALUE: Node = Node::Choice(DEFAULT_VALUE_CHOICES);
static DEFAULT_NODES: &[Node] = &[Node::Word(Word::keyword("default")), DEFAULT_VALUE];
static CHECK_NODES: &[Node] = &[
Node::Word(Word::keyword("check")),
Node::Punct('('),
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
Node::Punct(')'),
];
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
// `Choice` never ambiguously commits.
static COL_CONSTRAINT_CHOICES: &[Node] = &[
Node::Seq(NOT_NULL_NODES),
Node::Word(Word::keyword("unique")),
Node::Seq(PRIMARY_KEY_NODES),
Node::Seq(DEFAULT_NODES),
Node::Seq(CHECK_NODES),
];
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
/// Zero-or-more column constraints after the type (`min: 0`).
@@ -173,12 +207,41 @@ static TABLE_PK_NODES: &[Node] = &[
];
const TABLE_PK: Node = Node::Seq(TABLE_PK_NODES);
// Table-level `UNIQUE ( col, … )`. A single column normalises into
// that column's `unique` flag (round-trips via the existing
// single-column path); two or more become a composite UNIQUE
// constraint (ADR-0035 §4a.2). Distinct ident role from `pk_column`
// so the builder routes them separately.
const UNIQUE_COLUMN_REF: Node = Node::Ident {
source: IdentSource::NewName,
role: "unique_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static TABLE_UNIQUE_NODES: &[Node] = &[
Node::Word(Word::keyword("unique")),
Node::Punct('('),
Node::Repeated {
inner: &UNIQUE_COLUMN_REF,
separator: Some(&COMMA),
min: 1,
},
Node::Punct(')'),
];
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, COLUMN_DEF];
static ELEMENT_CHOICES: &[Node] = &[TABLE_PK, TABLE_UNIQUE, COLUMN_DEF];
const ELEMENT: Node = Node::Choice(ELEMENT_CHOICES);
static COLUMN_LIST_NODES: &[Node] = &[
@@ -364,14 +427,30 @@ mod tests {
}
#[test]
fn deferred_constraints_are_not_accepted_in_4a() {
// DEFAULT / CHECK / table-level UNIQUE belong to the 4a.2
// constraint slice; their shapes are absent here, so they do
// not walk (surfacing as a parse error with the usage
// skeleton, which lists the supported surface).
bad("table t (id int default 0)");
bad("table t (id int check (id > 0))");
bad("table t (a int, b int, unique (a, b))");
fn column_default_and_check_accepted() {
// 4a.2: DEFAULT / CHECK reuse the full sql_expr surface.
good("table t (id int, n int default 0)");
good("table t (id int, name text default 'x')");
good("table t (id int check (id > 0))");
good("table t (id int check (id > 0 and id < 100))");
good("table t (price real default 0.0 check (price >= 0.0))");
}
#[test]
fn table_level_unique_accepted() {
// 4a.2: composite + single-column table-level UNIQUE.
good("table t (a int, b int, unique (a, b))");
good("table t (a int, b text, unique (b))");
good("table t (id int primary key, email text, unique (email))");
}
#[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))");
bad("table t (id int, ref int references other(id))");
bad("table t (id int, foreign key (id) references other(id))");
}
}
@@ -383,7 +462,7 @@ mod tests {
#[cfg(test)]
mod builder_tests {
use crate::dsl::command::Command;
use crate::dsl::command::{ColumnSpec, Command};
use crate::dsl::parser::{parse_command, parse_command_in_mode};
use crate::dsl::types::Type;
use crate::mode::Mode;
@@ -396,6 +475,7 @@ mod builder_tests {
columns,
primary_key,
if_not_exists,
..
} => (
name,
columns.into_iter().map(|c| (c.name, c.ty)).collect(),
@@ -540,4 +620,80 @@ mod builder_tests {
"SQL CREATE TABLE must not parse in simple mode"
);
}
// --- 4a.2: CHECK / DEFAULT raw text + composite UNIQUE ---
/// Parse and return the full `SqlCreateTable` columns +
/// composite-unique constraints.
fn parse_sct(input: &str) -> (Vec<ColumnSpec>, Vec<Vec<String>>) {
match parse_command(input).expect("should parse") {
Command::SqlCreateTable {
columns,
unique_constraints,
..
} => (columns, unique_constraints),
other => panic!("expected SqlCreateTable, got {other:?}"),
}
}
fn col<'a>(cols: &'a [ColumnSpec], name: &str) -> &'a ColumnSpec {
cols.iter().find(|c| c.name == name).expect("column")
}
#[test]
fn check_captures_raw_inner_sql_text() {
let (cols, _) = parse_sct("create table t (id int check (id > 0))");
assert_eq!(col(&cols, "id").check_sql.as_deref(), Some("id > 0"));
}
#[test]
fn check_with_nested_parens_captures_balanced_text() {
let (cols, _) = parse_sct("create table t (a int, b int check ((a + b) > 0))");
assert_eq!(col(&cols, "b").check_sql.as_deref(), Some("(a + b) > 0"));
}
#[test]
fn default_captures_raw_sql_text() {
let (cols, _) =
parse_sct("create table t (id int primary key, n int default 42, s text default 'x')");
assert_eq!(col(&cols, "n").default_sql.as_deref(), Some("42"));
assert_eq!(col(&cols, "s").default_sql.as_deref(), Some("'x'"));
}
#[test]
fn default_expression_stops_before_following_constraint() {
// The boundary case: `default 0 not null` — the expr is just
// `0`; `not null` is the next constraint, not part of it.
let (cols, _) = parse_sct("create table t (id int, n int default 0 not null)");
let n = col(&cols, "n");
assert_eq!(n.default_sql.as_deref(), Some("0"));
assert!(n.not_null, "NOT NULL still recognised after the default");
}
#[test]
fn parenthesised_default_captures_expression_with_parens() {
// A complex (non-literal) default must be parenthesised
// (standard SQL); the captured text keeps the parens so it
// re-emits as valid `DEFAULT (…)`.
let (cols, _) = parse_sct("create table t (id int, n int default (1 + 2) not null)");
let n = col(&cols, "n");
assert_eq!(n.default_sql.as_deref(), Some("(1 + 2)"));
assert!(n.not_null);
}
#[test]
fn composite_unique_collected_as_constraint() {
let (cols, uniq) = parse_sct("create table t (a int, b int, unique (a, b))");
assert_eq!(uniq, vec![vec!["a".to_string(), "b".to_string()]]);
// The columns themselves are not individually unique.
assert!(!col(&cols, "a").unique && !col(&cols, "b").unique);
}
#[test]
fn single_column_table_unique_folds_into_the_column() {
let (cols, uniq) = parse_sct("create table t (a int, b text, unique (b))");
assert!(uniq.is_empty(), "single-column UNIQUE is not a composite");
assert!(col(&cols, "b").unique, "it folds into the column's flag");
assert!(!col(&cols, "a").unique);
}
}