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
+17 -6
View File
@@ -71,10 +71,17 @@ CHECK). Builds directly on the 4a `SqlCreateTable` command + grammar.
### 4.1 Grammar (`src/dsl/grammar/sql_create_table.rs`) ### 4.1 Grammar (`src/dsl/grammar/sql_create_table.rs`)
- **Column constraints** — extend `COL_CONSTRAINT_CHOICES` with: - **Column constraints** — extend `COL_CONSTRAINT_CHOICES` with:
- `DEFAULT <sql_expr>`: `Seq[ Word("default"), - `DEFAULT <value>` where `<value>` is a **literal** (number /
Subgrammar(&sql_expr::SQL_OR_EXPR) ]`. string / `null` / `true` / `false`) **or a parenthesised**
`( <sql_expr> )`*not* a bare `sql_expr`. This matches standard
SQL (a complex default must be parenthesised) **and** resolves a
real ambiguity found in testing: a bare `sql_expr` greedily
consumes a following `NOT` (as `NOT IN`/`NOT LIKE`/`NOT BETWEEN`),
breaking the common `DEFAULT 0 NOT NULL`. The parens give the
expression a clean right edge. (See §6.3.)
- `CHECK ( <sql_expr> )`: `Seq[ Word("check"), Punct('('), - `CHECK ( <sql_expr> )`: `Seq[ Word("check"), Punct('('),
Subgrammar(&sql_expr::SQL_OR_EXPR), Punct(')') ]`. Subgrammar(&sql_expr::SQL_OR_EXPR), Punct(')') ]` — already
paren-bounded, so no ambiguity.
- **Table element** — extend `ELEMENT_CHOICES` with table-level - **Table element** — extend `ELEMENT_CHOICES` with table-level
`UNIQUE ( col, … )`: `Seq[ Word("unique"), Punct('('), `UNIQUE ( col, … )`: `Seq[ Word("unique"), Punct('('),
Repeated(uniq_column, ',', 1), Punct(')') ]`. (Distinct ident role, Repeated(uniq_column, ',', 1), Punct(')') ]`. (Distinct ident role,
@@ -146,9 +153,13 @@ execution; `finalize_persistence` writes yaml/CSV/journal. No new
unknown-column `[ERR]`. If they do, the grammar must suppress unknown-column `[ERR]`. If they do, the grammar must suppress
schema-existence checks in the CREATE-TABLE CHECK context (as the schema-existence checks in the CREATE-TABLE CHECK context (as the
simple-mode path effectively does). Resolve during step 1. simple-mode path effectively does). Resolve during step 1.
2. **DEFAULT expression boundary** — confirm the `sql_expr` match is 2. **DEFAULT grammar — resolved during implementation (§4.1).** A bare
maximal enough that the raw-text slice for `DEFAULT <expr>` ends `DEFAULT <sql_expr>` greedily ate a following `NOT` (start of
cleanly before the next element. Covered by builder tests. `NOT IN`/`LIKE`/`BETWEEN`), so `DEFAULT 0 NOT NULL` failed to parse
(caught by a builder test). Fixed by restricting a bare default to a
**literal**, with complex defaults **parenthesised** (`DEFAULT
(expr)`) — standard SQL, and the parens bound the expression. The
raw-text capture keeps the parens so it re-emits as valid SQL.
## 7. Devil's Advocate review of this plan ## 7. Devil's Advocate review of this plan
+6 -5
View File
@@ -215,11 +215,12 @@ handoff-14 cleanup; 449 after B2/C2.)
the same completion / highlighting / hints as the DSL (ADR-0001's the same completion / highlighting / hints as the DSL (ADR-0001's
`sqlparser-rs` reservation is superseded). Implemented so far: full `sqlparser-rs` reservation is superseded). Implemented so far: full
`SELECT` (ADR-0032), `INSERT` / `UPDATE` / `DELETE` (ADR-0033), and `SELECT` (ADR-0032), `INSERT` / `UPDATE` / `DELETE` (ADR-0033), and
`CREATE TABLE` (ADR-0035 sub-phase 4a, 2026-05-25 — columns + types + `CREATE TABLE` (ADR-0035, 2026-05-25 — executed structurally: columns
`NOT NULL` / `UNIQUE` / `PRIMARY KEY` + `IF NOT EXISTS`, executed + types + `NOT NULL`/`UNIQUE`/`PRIMARY KEY` + `IF NOT EXISTS` (4a),
structurally). Remaining DDL — `CREATE TABLE` constraints (4a.2), then per-column `DEFAULT`/`CHECK` (raw `sql_expr` text) and composite
FK (4b), `DROP TABLE` (4c), indexes (4d), `ALTER TABLE` (4e4h) — is `UNIQUE(a,b)` (4a.2)). Remaining DDL — table-level/multi-column
phased per ADR-0035 §13.)* `CHECK` (4a.3), FK (4b), `DROP TABLE` (4c), indexes (4d),
`ALTER TABLE` (4e4h) — is phased per ADR-0035 §13.)*
- [ ] **Q2** Non-standard syntax rejected with a clear message - [ ] **Q2** Non-standard syntax rejected with a clear message
pointing at the supported subset. pointing at the supported subset.
*(Design done — ADR-0030 §8: out-of-subset statements are *(Design done — ADR-0030 §8: out-of-subset statements are
+75 -15
View File
@@ -465,6 +465,7 @@ enum Request {
name: String, name: String,
columns: Vec<ColumnSpec>, columns: Vec<ColumnSpec>,
primary_key: Vec<String>, primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
if_not_exists: bool, if_not_exists: bool,
source: Option<String>, source: Option<String>,
reply: oneshot::Sender<Result<CreateOutcome, DbError>>, reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
@@ -833,6 +834,7 @@ impl Database {
name: String, name: String,
columns: Vec<ColumnSpec>, columns: Vec<ColumnSpec>,
primary_key: Vec<String>, primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
if_not_exists: bool, if_not_exists: bool,
source: Option<String>, source: Option<String>,
) -> Result<CreateOutcome, DbError> { ) -> Result<CreateOutcome, DbError> {
@@ -841,6 +843,7 @@ impl Database {
name, name,
columns, columns,
primary_key, primary_key,
unique_constraints,
if_not_exists, if_not_exists,
source, source,
reply, reply,
@@ -1687,12 +1690,14 @@ fn handle_request(
&name, &name,
&columns, &columns,
&primary_key, &primary_key,
&[],
)); ));
} }
Request::SqlCreateTable { Request::SqlCreateTable {
name, name,
columns, columns,
primary_key, primary_key,
unique_constraints,
if_not_exists, if_not_exists,
source, source,
reply, reply,
@@ -1720,6 +1725,7 @@ fn handle_request(
&name, &name,
&columns, &columns,
&primary_key, &primary_key,
&unique_constraints,
) )
.map(CreateOutcome::Created) .map(CreateOutcome::Created)
}); });
@@ -2317,6 +2323,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
name: name.clone(), name: name.clone(),
primary_key: read.primary_key.clone(), primary_key: read.primary_key.clone(),
columns, columns,
unique_constraints: read.unique_constraints.clone(),
}); });
} }
@@ -2493,7 +2500,14 @@ fn column_constraints_sql(spec: &ColumnSpec) -> Result<String, DbError> {
if spec.unique { if spec.unique {
sql.push_str(" 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(" DEFAULT ");
sql.push_str(&literal); sql.push_str(&literal);
} }
@@ -2554,6 +2568,7 @@ fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> Read
.collect(), .collect(),
primary_key: primary_key.to_vec(), primary_key: primary_key.to_vec(),
foreign_keys: Vec::new(), foreign_keys: Vec::new(),
unique_constraints: Vec::new(),
} }
} }
@@ -2598,6 +2613,7 @@ fn do_create_table(
name: &str, name: &str,
columns: &[ColumnSpec], columns: &[ColumnSpec],
primary_key: &[String], primary_key: &[String],
unique_constraints: &[Vec<String>],
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
if columns.is_empty() { if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar // 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 // by the DDL clause and the metadata insert below. The
// minimal schema gives `compile_expr` the column types. // minimal schema gives `compile_expr` the column types.
let check_schema = read_schema_for_specs(columns, primary_key); 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 let check_sqls: Vec<Option<String>> = columns
.iter() .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(); .collect();
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len()); let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
@@ -2669,6 +2692,16 @@ fn do_create_table(
ddl.push(')'); 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;"); ddl.push_str(") STRICT;");
debug!(ddl = %ddl, "create_table"); debug!(ddl = %ddl, "create_table");
@@ -4685,6 +4718,10 @@ struct ReadSchema {
columns: Vec<ReadColumn>, columns: Vec<ReadColumn>,
primary_key: Vec<String>, primary_key: Vec<String>,
foreign_keys: Vec<ReadForeignKey>, 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)] #[derive(Debug, Clone)]
@@ -4767,13 +4804,12 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.collect(); .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 // pragma_index_list returns one row per index; we filter to
// unique indexes whose origin is "u" (a UNIQUE constraint, // unique indexes whose origin is "u" (a UNIQUE constraint, as
// as opposed to "pk" or "c"). For each, pragma_index_info // opposed to "pk" or "c"). Single-column → the column's `unique`
// gives the constituent column(s); we only mark single- // flag; multi-column → a composite `unique_constraints` entry.
// column unique here. Compound UNIQUE is out of scope. let (unique_columns, unique_constraints) = read_unique_constraints(conn, table)?;
let unique_columns = read_unique_columns(conn, table)?;
for col in &mut columns { for col in &mut columns {
if unique_columns.contains(&col.name) { if unique_columns.contains(&col.name) {
col.unique = true; col.unique = true;
@@ -4810,6 +4846,7 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
columns, columns,
primary_key, primary_key,
foreign_keys, 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 /// `"u"` (a UNIQUE constraint, not a PK-implied or CHECK
/// auto-index) and which cover exactly one column. Compound /// auto-index) and which cover exactly one column. Compound
/// UNIQUE is deferred to a future ADR (out of scope for ADR-0018). /// 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, conn: &Connection,
table: &str, table: &str,
) -> Result<std::collections::HashSet<String>, DbError> { ) -> Result<(std::collections::HashSet<String>, Vec<Vec<String>>), DbError> {
let mut out: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut single: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut composite: Vec<Vec<String>> = Vec::new();
let mut idx_stmt = conn let mut idx_stmt = conn
.prepare( .prepare(
"SELECT name, \"unique\", origin \ "SELECT name, \"unique\", origin \
@@ -4916,18 +4960,22 @@ fn read_unique_columns(
continue; continue;
} }
let mut info_stmt = conn 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)?; .map_err(DbError::from_rusqlite)?;
let cols: Vec<String> = info_stmt let cols: Vec<String> = info_stmt
.query_map([&idx_name], |row| row.get::<_, String>(0)) .query_map([&idx_name], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)? .map_err(DbError::from_rusqlite)?
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map_err(DbError::from_rusqlite)?; .map_err(DbError::from_rusqlite)?;
if cols.len() == 1 { match cols.len() {
out.insert(cols.into_iter().next().expect("len 1")); 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 /// 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(", "))); 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 { for fk in &schema.foreign_keys {
clauses.push(format!( clauses.push(format!(
"FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \ "FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \
@@ -7447,6 +7503,7 @@ fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema])
columns, columns,
primary_key: table.primary_key.clone(), primary_key: table.primary_key.clone(),
foreign_keys, foreign_keys,
unique_constraints: table.unique_constraints.clone(),
} }
} }
@@ -10365,6 +10422,8 @@ mod tests {
unique, unique,
default, default,
check: None, check: None,
default_sql: None,
check_sql: None,
} }
} }
@@ -11146,6 +11205,7 @@ mod tests {
], ],
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
foreign_keys: vec![], foreign_keys: vec![],
unique_constraints: Vec::new(),
}; };
let ddl = schema_to_ddl("T", &schema); let ddl = schema_to_ddl("T", &schema);
assert!( assert!(
+17 -2
View File
@@ -30,11 +30,20 @@ pub struct ColumnSpec {
/// `UNIQUE` — non-`NULL` values must be distinct (ADR-0029). /// `UNIQUE` — non-`NULL` values must be distinct (ADR-0029).
pub unique: bool, pub unique: bool,
/// `DEFAULT <literal>` — the value used when an `insert` /// `DEFAULT <literal>` — the value used when an `insert`
/// omits this column (ADR-0029). /// omits this column (ADR-0029). Simple-mode form.
pub default: Option<Value>, pub default: Option<Value>,
/// `CHECK (<expr>)` — every row must satisfy this boolean /// `CHECK (<expr>)` — every row must satisfy this boolean
/// expression (ADR-0029). /// expression (ADR-0029). Simple-mode form (a typed `Expr`).
pub check: Option<Expr>, pub check: Option<Expr>,
/// Advanced-mode raw-SQL `DEFAULT` (ADR-0035 §4a.2): the
/// expression text captured from a SQL `CREATE TABLE`, since
/// `sql_expr` yields no `Expr`. When `Some`, it takes precedence
/// over `default` in DDL emission. `None` in simple mode.
pub default_sql: Option<String>,
/// Advanced-mode raw-SQL `CHECK` (ADR-0035 §4a.2): the inner
/// expression text (without the `CHECK ( … )` wrapper). When
/// `Some`, it takes precedence over `check`. `None` in simple mode.
pub check_sql: Option<String>,
} }
impl ColumnSpec { impl ColumnSpec {
@@ -49,6 +58,8 @@ impl ColumnSpec {
unique: false, unique: false,
default: None, default: None,
check: None, check: None,
default_sql: None,
check_sql: None,
} }
} }
} }
@@ -133,6 +144,10 @@ pub enum Command {
name: String, name: String,
columns: Vec<ColumnSpec>, columns: Vec<ColumnSpec>,
primary_key: Vec<String>, primary_key: Vec<String>,
/// Composite (multi-column) `UNIQUE (a, b)` table constraints
/// (ADR-0035 §4a.2). Single-column table-level `UNIQUE` is
/// folded into the column's `unique` flag instead.
unique_constraints: Vec<Vec<String>>,
if_not_exists: bool, if_not_exists: bool,
}, },
/// Add a column to an existing table. The column carries /// Add a column to an existing table. The column carries
+138 -16
View File
@@ -1288,17 +1288,27 @@ pub static CREATE: CommandNode = CommandNode {
help_id: Some("ddl.create"), help_id: Some("ddl.create"),
usage_ids: &["parse.usage.create_table"],}; 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 /// 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 /// structurally — extracts the same `ColumnSpec`/`primary_key` the
/// simple-mode builder produces so the worker reuses `do_create_table`. /// simple-mode builder produces so the worker reuses `do_create_table`.
/// ///
/// 4a surface: columns + types (the §3 alias map incl. `double /// Surface: columns and types (the §3 alias map incl. `double
/// precision`) + `NOT NULL` / `UNIQUE` / column- and table-level /// precision`), `NOT NULL` / `UNIQUE` / column- and table-level
/// `PRIMARY KEY` + `IF NOT EXISTS`. `DEFAULT` / `CHECK` / /// `PRIMARY KEY`, and `IF NOT EXISTS` (4a); per-column `DEFAULT` and
/// table-level `UNIQUE` are absent from the grammar (4a.2), so they /// `CHECK` (raw `sql_expr` text captured by byte span — `sql_expr`
/// never reach this builder. /// builds no AST) and composite `UNIQUE (a, b)` (4a.2). Table-level
fn build_sql_create_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> { /// 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")?; let name = require_ident(path, "table_name")?;
// `if` only appears in the `IF NOT EXISTS` prefix (the `not` of // `if` only appears in the `IF NOT EXISTS` prefix (the `not` of
// `NOT NULL` never carries an `if`), so its presence is the flag. // `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 columns: Vec<ColumnSpec> = Vec::new();
let mut primary_key: Vec<String> = 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 pending_name: Option<String> = None;
let mut items = path.items.iter().peekable(); let mut items = path.items.iter().peekable();
while let Some(item) = items.next() { 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", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())], args: vec![("detail", "unknown type".to_string())],
})?; })?;
let col_name = pending_name.take().ok_or_else(|| ValidationError { let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
message_key: "parse.error_wrapper",
args: vec![("detail", "column type without a name".to_string())],
})?;
columns.push(ColumnSpec::new(col_name, ty)); columns.push(ColumnSpec::new(col_name, ty));
} }
// `double precision` — the two-word alias maps to `real`. // `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(); items.next();
} }
let col_name = pending_name.take().ok_or_else(|| ValidationError { let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
message_key: "parse.error_wrapper",
args: vec![("detail", "column type without a name".to_string())],
})?;
columns.push(ColumnSpec::new(col_name, Type::Real)); columns.push(ColumnSpec::new(col_name, Type::Real));
} }
// A table-level `PRIMARY KEY (col, …)` column reference. // 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") => { 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; 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, name,
columns, columns,
primary_key, primary_key,
unique_constraints,
if_not_exists, 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 { pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
entry: Word::keyword("create"), entry: Word::keyword("create"),
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
+170 -14
View File
@@ -24,7 +24,7 @@
//! `sql_insert::SQL_INSERT_SHAPE`, which starts at `INTO`). //! `sql_insert::SQL_INSERT_SHAPE`, which starts at `INTO`).
use crate::dsl::grammar::sql_select::reject_internal_table; 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; use crate::dsl::types::Type;
static COMMA: Node = Node::Punct(','); static COMMA: Node = Node::Punct(',');
@@ -107,13 +107,47 @@ static PRIMARY_KEY_NODES: &[Node] = &[
Node::Word(Word::keyword("primary")), Node::Word(Word::keyword("primary")),
Node::Word(Word::keyword("key")), Node::Word(Word::keyword("key")),
]; ];
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY`. `DEFAULT` / `CHECK` are // `DEFAULT <value>` / `CHECK (<expr>)` reuse the full ADR-0031
// deliberately absent (4a.2): typing them is an ordinary parse error // `sql_expr` surface (the same fragment `WHERE`/projections use). The
// until the constraint slice lands. // 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] = &[ static COL_CONSTRAINT_CHOICES: &[Node] = &[
Node::Seq(NOT_NULL_NODES), Node::Seq(NOT_NULL_NODES),
Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("unique")),
Node::Seq(PRIMARY_KEY_NODES), Node::Seq(PRIMARY_KEY_NODES),
Node::Seq(DEFAULT_NODES),
Node::Seq(CHECK_NODES),
]; ];
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES); const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
/// Zero-or-more column constraints after the type (`min: 0`). /// 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); 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 // One element of the column list: a table-level `PRIMARY KEY (…)` or a
// column definition. `TABLE_PK` is tried first — it starts with the // column definition. `TABLE_PK` is tried first — it starts with the
// keyword `primary`, which disambiguates it from a column name. (A // keyword `primary`, which disambiguates it from a column name. (A
// column literally named `primary` is therefore unavailable, the same // column literally named `primary` is therefore unavailable, the same
// trade real SQL makes with its reserved words.) // 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); const ELEMENT: Node = Node::Choice(ELEMENT_CHOICES);
static COLUMN_LIST_NODES: &[Node] = &[ static COLUMN_LIST_NODES: &[Node] = &[
@@ -364,14 +427,30 @@ mod tests {
} }
#[test] #[test]
fn deferred_constraints_are_not_accepted_in_4a() { fn column_default_and_check_accepted() {
// DEFAULT / CHECK / table-level UNIQUE belong to the 4a.2 // 4a.2: DEFAULT / CHECK reuse the full sql_expr surface.
// constraint slice; their shapes are absent here, so they do good("table t (id int, n int default 0)");
// not walk (surfacing as a parse error with the usage good("table t (id int, name text default 'x')");
// skeleton, which lists the supported surface). good("table t (id int check (id > 0))");
bad("table t (id int default 0)"); good("table t (id int check (id > 0 and id < 100))");
bad("table t (id int check (id > 0))"); good("table t (price real default 0.0 check (price >= 0.0))");
bad("table t (a int, b int, unique (a, b))"); }
#[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)] #[cfg(test)]
mod builder_tests { 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::parser::{parse_command, parse_command_in_mode};
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::mode::Mode; use crate::mode::Mode;
@@ -396,6 +475,7 @@ mod builder_tests {
columns, columns,
primary_key, primary_key,
if_not_exists, if_not_exists,
..
} => ( } => (
name, name,
columns.into_iter().map(|c| (c.name, c.ty)).collect(), 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" "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);
}
} }
+6
View File
@@ -140,6 +140,12 @@ pub struct TableSchema {
pub name: String, pub name: String,
pub primary_key: Vec<String>, pub primary_key: Vec<String>,
pub columns: Vec<ColumnSchema>, pub columns: Vec<ColumnSchema>,
/// Composite (multi-column) `UNIQUE` constraints (ADR-0035
/// §4a.2). Single-column UNIQUE is carried on the column's
/// `ColumnSchema::unique` flag instead. Empty for project files
/// written before composite UNIQUE existed — the YAML field is
/// optional on read.
pub unique_constraints: Vec<Vec<String>>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
+25
View File
@@ -90,6 +90,21 @@ fn write_table(out: &mut String, table: &TableSchema) {
for col in &table.columns { for col in &table.columns {
write_column(out, col); write_column(out, col);
} }
// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) —
// emitted only when present so unconstrained tables stay compact.
if !table.unique_constraints.is_empty() {
let _ = writeln!(out, " unique_constraints:");
for cols in &table.unique_constraints {
write!(out, " - [").unwrap();
for (i, c) in cols.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(&quote_if_needed(c));
}
let _ = writeln!(out, "]");
}
}
} }
/// Always render `s` as a double-quoted YAML string — used /// Always render `s` as a double-quoted YAML string — used
@@ -249,6 +264,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
name: t.name, name: t.name,
primary_key: t.primary_key, primary_key: t.primary_key,
columns, columns,
unique_constraints: t.unique_constraints,
}); });
} }
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len()); let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
@@ -357,6 +373,10 @@ struct RawTable {
name: String, name: String,
primary_key: Vec<String>, primary_key: Vec<String>,
columns: Vec<RawColumn>, columns: Vec<RawColumn>,
/// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2).
/// Optional on read — older project files omit it.
#[serde(default)]
unique_constraints: Vec<Vec<String>>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -418,6 +438,7 @@ mod tests {
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None }, ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None }, ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None },
], ],
unique_constraints: Vec::new(),
}, },
TableSchema { TableSchema {
name: "Orders".to_string(), name: "Orders".to_string(),
@@ -426,6 +447,7 @@ mod tests {
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None }, ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
], ],
unique_constraints: Vec::new(),
}, },
], ],
relationships: vec![RelationshipSchema { relationships: vec![RelationshipSchema {
@@ -494,6 +516,7 @@ mod tests {
default: None, default: None,
check: None, check: None,
}], }],
unique_constraints: Vec::new(),
}], }],
relationships: vec![], relationships: vec![],
indexes: vec![], indexes: vec![],
@@ -551,6 +574,7 @@ mod tests {
check: Some("\"stock\" >= 0".to_string()), check: Some("\"stock\" >= 0".to_string()),
}, },
], ],
unique_constraints: Vec::new(),
}], }],
relationships: vec![], relationships: vec![],
indexes: vec![], indexes: vec![],
@@ -637,6 +661,7 @@ relationships:
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
], ],
unique_constraints: Vec::new(),
}], }],
relationships: vec![], relationships: vec![],
indexes: vec![], indexes: vec![],
+4 -1
View File
@@ -1924,9 +1924,10 @@ async fn execute_command_typed(
name, name,
columns, columns,
primary_key, primary_key,
unique_constraints,
if_not_exists, if_not_exists,
} => database } => database
.sql_create_table(name, columns, primary_key, if_not_exists, src) .sql_create_table(name, columns, primary_key, unique_constraints, if_not_exists, src)
.await .await
.map(|outcome| match outcome { .map(|outcome| match outcome {
CreateOutcome::Created(d) => CommandOutcome::Schema(Some(d)), CreateOutcome::Created(d) => CommandOutcome::Schema(Some(d)),
@@ -1954,6 +1955,8 @@ async fn execute_command_typed(
unique, unique,
default, default,
check, check,
default_sql: None,
check_sql: None,
}, },
src, src,
) )
+169
View File
@@ -51,6 +51,7 @@ fn created_table_appears_with_playground_types() {
ColumnSpec::new("name", Type::Text), ColumnSpec::new("name", Type::Text),
], ],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table Widget (id int primary key, name text)".to_string()), Some("create table Widget (id int primary key, name text)".to_string()),
)) ))
@@ -87,6 +88,7 @@ fn integer_primary_key_is_plain_int() {
"T".to_string(), "T".to_string(),
vec![ColumnSpec::new("id", Type::Int)], vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (id integer primary key)".to_string()), Some("create table T (id integer primary key)".to_string()),
)) ))
@@ -111,6 +113,7 @@ fn serial_pk_autoincrements_in_multi_column_table() {
ColumnSpec::new("name", Type::Text), ColumnSpec::new("name", Type::Text),
], ],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (id serial primary key, name text)".to_string()), Some("create table T (id serial primary key, name text)".to_string()),
)) ))
@@ -153,6 +156,7 @@ fn if_not_exists_is_a_noop_when_table_exists() {
"T".to_string(), "T".to_string(),
specs(), specs(),
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (id int)".to_string()), Some("create table T (id int)".to_string()),
)) ))
@@ -163,6 +167,7 @@ fn if_not_exists_is_a_noop_when_table_exists() {
"T".to_string(), "T".to_string(),
specs(), specs(),
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
true, // IF NOT EXISTS true, // IF NOT EXISTS
Some("create table if not exists T (id int)".to_string()), Some("create table if not exists T (id int)".to_string()),
)) ))
@@ -188,6 +193,7 @@ fn table_without_primary_key_is_allowed() {
"Notes".to_string(), "Notes".to_string(),
vec![ColumnSpec::new("body", Type::Text)], vec![ColumnSpec::new("body", Type::Text)],
vec![], // no primary key vec![], // no primary key
vec![], // no composite UNIQUE
false, false,
Some("create table Notes (body text)".to_string()), Some("create table Notes (body text)".to_string()),
)) ))
@@ -207,6 +213,162 @@ fn table_without_primary_key_is_allowed() {
assert_eq!(data.rows.len(), 1); assert_eq!(data.rows.len(), 1);
} }
/// A column carrying a raw-SQL `CHECK` (ADR-0035 §4a.2).
fn col_check(name: &str, ty: Type, check_sql: &str) -> ColumnSpec {
let mut c = ColumnSpec::new(name, ty);
c.check_sql = Some(check_sql.to_string());
c
}
/// A column carrying a raw-SQL `DEFAULT` (ADR-0035 §4a.2).
fn col_default(name: &str, ty: Type, default_sql: &str) -> ColumnSpec {
let mut c = ColumnSpec::new(name, ty);
c.default_sql = Some(default_sql.to_string());
c
}
#[test]
fn check_constraint_is_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("id", Type::Serial), col_check("price", Type::Real, "price >= 0")],
vec!["id".to_string()],
vec![],
false,
Some("create table T (id serial primary key, price real check (price >= 0))".to_string()),
))
.expect("create");
// A satisfying row inserts; a violating one is rejected by the CHECK.
r.block_on(db.insert(
"T".to_string(),
Some(vec!["price".to_string()]),
vec![Value::Number("10".to_string())],
Some("insert".to_string()),
))
.expect("price 10 satisfies the check");
let bad = r.block_on(db.insert(
"T".to_string(),
Some(vec!["price".to_string()]),
vec![Value::Number("-5".to_string())],
Some("insert".to_string()),
));
assert!(bad.is_err(), "CHECK (price >= 0) rejects -5");
}
#[test]
fn default_is_applied_when_column_omitted() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("label", Type::Text),
col_default("n", Type::Int, "7"),
],
vec!["id".to_string()],
vec![],
false,
Some("create table T (id serial primary key, label text, n int default 7)".to_string()),
))
.expect("create");
// Insert only `label`; `id` auto-fills and `n` takes its default.
r.block_on(db.insert(
"T".to_string(),
Some(vec!["label".to_string()]),
vec![Value::Text("x".to_string())],
Some("insert".to_string()),
))
.expect("insert");
let data = r
.block_on(db.query_data("T".to_string(), None, None, None))
.expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied");
}
#[test]
fn composite_unique_is_enforced() {
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![],
vec![vec!["a".to_string(), "b".to_string()]],
false,
Some("create table T (a int, b int, unique (a, b))".to_string()),
))
.expect("create");
let ins = |a: &str, b: &str| {
db.insert(
"T".to_string(),
None,
vec![Value::Number(a.to_string()), Value::Number(b.to_string())],
Some("insert".to_string()),
)
};
r.block_on(ins("1", "2")).expect("first (1,2)");
assert!(r.block_on(ins("1", "2")).is_err(), "UNIQUE(a,b) rejects duplicate (1,2)");
r.block_on(ins("1", "3")).expect("distinct (1,3) is allowed");
}
#[test]
fn check_default_and_composite_unique_survive_rebuild() {
// The part-D round-trip: CHECK (metadata), DEFAULT (PRAGMA), and
// composite UNIQUE (TableSchema + PRAGMA index_list origin 'u')
// must all be reconstructed from project.yaml on rebuild.
let (p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
col_check("price", Type::Real, "price >= 0"),
col_default("n", Type::Int, "7"),
],
vec![],
vec![vec!["a".to_string(), "b".to_string()]],
false,
Some(
"create table T (a int, b int, price real check (price >= 0), \
n int default 7, unique (a, b))"
.to_string(),
),
))
.expect("create");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None))
.expect("rebuild");
let ins = |a: &str, b: &str, price: &str| {
db.insert(
"T".to_string(),
Some(vec!["a".to_string(), "b".to_string(), "price".to_string()]),
vec![
Value::Number(a.to_string()),
Value::Number(b.to_string()),
Value::Number(price.to_string()),
],
Some("insert".to_string()),
)
};
// CHECK survived: a negative price is rejected.
assert!(r.block_on(ins("1", "1", "-1")).is_err(), "CHECK survived rebuild");
// A valid row inserts; DEFAULT n=7 survived.
r.block_on(ins("1", "1", "5")).expect("valid row");
let data = r
.block_on(db.query_data("T".to_string(), None, None, None))
.expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild");
// Composite UNIQUE survived: (1,1) again is rejected.
assert!(r.block_on(ins("1", "1", "5")).is_err(), "composite UNIQUE survived rebuild");
}
#[test] #[test]
fn if_not_exists_noop_is_journalled() { fn if_not_exists_noop_is_journalled() {
// A successful no-op is still a submission and belongs in the // A successful no-op is still a submission and belongs in the
@@ -218,6 +380,7 @@ fn if_not_exists_noop_is_journalled() {
"T".to_string(), "T".to_string(),
vec![ColumnSpec::new("id", Type::Int)], vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (id int)".to_string()), Some("create table T (id int)".to_string()),
)) ))
@@ -228,6 +391,7 @@ fn if_not_exists_noop_is_journalled() {
"T".to_string(), "T".to_string(),
vec![ColumnSpec::new("id", Type::Int)], vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
true, true,
Some(noop.to_string()), Some(noop.to_string()),
)) ))
@@ -246,6 +410,7 @@ fn plain_create_errors_when_table_exists() {
"T".to_string(), "T".to_string(),
specs(), specs(),
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (id int)".to_string()), Some("create table T (id int)".to_string()),
)) ))
@@ -255,6 +420,7 @@ fn plain_create_errors_when_table_exists() {
"T".to_string(), "T".to_string(),
specs(), specs(),
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, // no IF NOT EXISTS false, // no IF NOT EXISTS
Some("create table T (id int)".to_string()), Some("create table T (id int)".to_string()),
)); ));
@@ -269,6 +435,7 @@ fn sql_create_table_is_one_undo_step() {
"T".to_string(), "T".to_string(),
vec![ColumnSpec::new("id", Type::Int)], vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (id int)".to_string()), Some("create table T (id int)".to_string()),
)) ))
@@ -319,6 +486,7 @@ fn serial_pk_first_column_autoincrements_after_rebuild() {
ColumnSpec::new("name", Type::Text), ColumnSpec::new("name", Type::Text),
], ],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (id serial primary key, name text)".to_string()), Some("create table T (id serial primary key, name text)".to_string()),
)) ))
@@ -350,6 +518,7 @@ fn serial_pk_non_first_column_autoincrements_after_rebuild() {
ColumnSpec::new("id", Type::Serial), ColumnSpec::new("id", Type::Serial),
], ],
vec!["id".to_string()], vec!["id".to_string()],
vec![], // no composite UNIQUE
false, false,
Some("create table T (name text, id serial primary key)".to_string()), Some("create table T (name text, id serial primary key)".to_string()),
)) ))