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:
+138
-16
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user