Files
rdbms-playground/src/dsl/grammar/ddl.rs
T
claude@clouddev1 12395a9a6c create table: column constraints — NOT NULL / UNIQUE / DEFAULT grammar (ADR-0029)
`create table … with pk` now parses the column-constraint
suffix; combined with the commit-1 db layer, a constrained
table works end to end.

- A shared constraint-suffix grammar fragment — `not null`,
  `unique`, `default <literal>` — sits after each column's
  `(type)` group; `build_create_table` walks the matched path
  per column and folds the constraints into `ColumnSpec`.
- §9 redundancy check: every `with pk` column is a primary-key
  column, so `not null` (any) and `unique` (single-column PK)
  are rejected with a friendly error
  (`parse.custom.constraint_redundant_on_pk`).
- `project.yaml` round-trip: `ColumnSchema` gains `not_null` /
  `default`; the YAML reader/writer and `build_read_schema`
  carry them, so `rebuild` / `export` / `import` preserve
  constraints.
- ADR-0029 §2.1's example corrected — `create table` columns
  are all PK columns, so its suffix is for `default` / `check`;
  `docs/simple-mode-limitations.md` records that non-PK
  columns at create time need advanced mode.

CHECK is deferred to the next commit. 1184 tests pass (+7);
clippy clean.
2026-05-19 14:41:29 +00:00

1081 lines
36 KiB
Rust

//! DDL command nodes (ADR-0024 §migration Phase B).
//!
//! Five commands at four entry words: `drop` (drop table /
//! drop column / drop relationship), `add` (add column /
//! add 1:n relationship), `rename` (rename column), `change`
//! (change column). The chumsky-side declarations stay
//! reachable for any input the walker doesn't engage on, but
//! for these entry words the walker is authoritative.
//!
//! Each shape is laid out inline so per-use-site `role`
//! annotations carry meaning end-to-end (e.g.,
//! `parent_table` vs `child_table` for the endpoints clause).
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, IndexSelector, RelationshipSelector,
};
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
/// "Type a name [then …]" prose rather than offering schema
/// candidates (ADR-0024 §HintMode-per-node).
const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name");
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
// =================================================================
// Building blocks
// =================================================================
const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
inner: &TABLE_NAME_NEW_IDENT,
};
// `writes_table: true` so that the column-name slots that
// follow the table name in `drop column` / `rename column` /
// `change column` / `add column` can narrow their candidates to
// this table's columns (handoff-12 §2.2). The walker writes
// `current_table` / `current_table_columns` on match; the
// completion engine reads the snapshot. `drop table` has no
// downstream column slot, so the write is harmless there.
const TABLE_NAME_EXISTING: Node = Node::Ident {
source: IdentSource::Tables,
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
};
const COLUMN_NAME: Node = Node::Ident {
source: IdentSource::Columns,
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
inner: &COLUMN_NAME_NEW_IDENT,
};
const RELATIONSHIP_NAME: Node = Node::Ident {
source: IdentSource::Relationships,
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
inner: &RELATIONSHIP_NAME_NEW_IDENT,
};
const INDEX_NAME_EXISTING: Node = Node::Ident {
source: IdentSource::Indexes,
role: "index_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "index_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
inner: &INDEX_NAME_NEW_IDENT,
};
// The column list shared by `add index` / `drop index`: one or
// more existing column names, comma-separated, inside parens.
// `COLUMN_NAME` narrows to the `on <Table>` table's columns
// because that ident carries `writes_table: true`.
const INDEX_COLUMN_LIST: Node = Node::Repeated {
inner: &COLUMN_NAME,
separator: Some(&Node::Punct(',')),
min: 1,
};
// `[to]` and `[table]` connectives.
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from")));
const IN_OPT: Node = Node::Optional(&Node::Word(Word::keyword("in")));
const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// =================================================================
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// =================================================================
// drop_column — `drop column [from] [table] <T> : <col>`
// =================================================================
// `--cascade` (ADR-0025): opt-in to dropping any index that
// covers the column alongside the column itself. Without it, a
// covered column is refused with a friendly error.
const DROP_COLUMN_CASCADE_OPT: Node = Node::Optional(&Node::Flag("cascade"));
const DROP_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
FROM_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME,
DROP_COLUMN_CASCADE_OPT,
];
const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES);
// =================================================================
// drop_relationship — `drop relationship (endpoints | name)`
// =================================================================
// `writes_table: true` on each endpoint's table ident so the
// `.<col>` slot that follows narrows to that table's columns
// (handoff-13 §2.2 follow-up). The two endpoints are walked
// sequentially, so `current_table` is correctly the parent
// table while walking `parent_column` and the child table
// while walking `child_column`.
const DR_PARENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "parent_table",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "parent_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
},
];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
const DR_CHILD_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "child_table",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "child_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
},
];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
const DR_ENDPOINTS_NODES: &[Node] = &[
Node::Word(Word::keyword("from")),
DR_PARENT,
Node::Word(Word::keyword("to")),
DR_CHILD,
];
const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
const DROP_RELATIONSHIP_NODES: &[Node] = &[
Node::Word(Word::keyword("relationship")),
DR_SELECTOR,
];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
// =================================================================
// drop_index — `drop index (<name> | on <T> (<col>, …))`
// =================================================================
const DI_POSITIONAL_NODES: &[Node] = &[
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
// Positional form first — it opens with the `on` keyword, so a
// bare index name can't be mistaken for it (mirrors DR_SELECTOR).
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
// add_column — `add column [to] [table] <T> : <col> ( <type> )`
// =================================================================
const ADD_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
TO_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME_NEW,
Node::Punct('('),
TYPE_SLOT,
Node::Punct(')'),
];
const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES);
// =================================================================
// add_relationship — `add 1:n relationship [as <name>]
// from <T>.<c> to <T>.<c>
// [on delete <a>] [on update <a>]
// [--create-fk]`
// =================================================================
// `writes_table: true` on each endpoint's table ident so the
// `.<col>` slot narrows to that table's columns (handoff-13
// §2.2 follow-up — mirrors DR_PARENT / DR_CHILD).
const AR_PARENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "parent_table",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "parent_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
},
];
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
const AR_CHILD_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "child_table",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "child_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
},
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
const ADD_RELATIONSHIP_NODES: &[Node] = &[
Node::Literal("1"),
Node::Punct(':'),
Node::Word(Word::keyword("n")),
Node::Word(Word::keyword("relationship")),
AR_AS_NAME_OPT,
Node::Word(Word::keyword("from")),
AR_PARENT,
Node::Word(Word::keyword("to")),
AR_CHILD,
REFERENTIAL_CLAUSES,
AR_CREATE_FK_OPT,
];
const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
// =================================================================
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================
const AI_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
AI_AS_NAME_OPT,
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES);
// =================================================================
// add entry — `add (column|1:n relationship|index) …`
// =================================================================
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX];
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);
// =================================================================
// rename_column — `rename column [in] [table] <T> : <col> to <new>`
// =================================================================
const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "new_column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
inner: &NEW_COLUMN_NAME_IDENT,
};
const RENAME_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
IN_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME,
Node::Word(Word::keyword("to")),
NEW_COLUMN_NAME,
];
const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
// =================================================================
// change_column — `change column [in] [table] <T> : <col>
// ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None,
min: 0,
};
const CHANGE_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
IN_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME,
Node::Punct('('),
TYPE_SLOT,
Node::Punct(')'),
CHANGE_FLAG_OPT,
];
const CHANGE_COLUMN: Node = Node::Seq(CHANGE_COLUMN_NODES);
// =================================================================
// AST builders
// =================================================================
/// First ident whose role matches.
fn ident<'a>(path: &'a MatchedPath, role: &str) -> Option<&'a str> {
path.items.iter().find_map(|i| match &i.kind {
MatchedKind::Ident { role: r, .. } if *r == role => Some(i.text.as_str()),
_ => None,
})
}
fn require_ident(path: &MatchedPath, role: &'static str) -> Result<String, ValidationError> {
ident(path, role)
.map(str::to_string)
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", format!("missing {role}"))],
})
}
/// Every ident whose role matches, in matched (left-to-right)
/// order. Used by the column-list commands.
fn collect_idents(path: &MatchedPath, role: &str) -> Vec<String> {
path.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Ident { role: r, .. } if *r == role => Some(i.text.clone()),
_ => None,
})
.collect()
}
fn parse_action(words: &[&'static str]) -> ReferentialAction {
// `set null`, `no action`, `cascade`, `restrict`.
if words.contains(&"set") && words.contains(&"null") {
ReferentialAction::SetNull
} else if words.contains(&"no") && words.contains(&"action") {
ReferentialAction::NoAction
} else if words.contains(&"cascade") {
ReferentialAction::Cascade
} else if words.contains(&"restrict") {
ReferentialAction::Restrict
} else {
ReferentialAction::default_action()
}
}
fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
// Discriminate by the second word matched (the entry was
// `drop`, the next Word is `table` / `column` / `relationship`).
let sub = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.nth(1);
match sub {
Some("table") => Ok(Command::DropTable {
name: require_ident(path, "table_name")?,
}),
Some("column") => Ok(Command::DropColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
cascade: path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Flag("cascade"))),
}),
Some("index") => {
// Positional form has `on` as the third Word.
let has_on = path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Word("on")));
if has_on {
Ok(Command::DropIndex {
selector: IndexSelector::Columns {
table: require_ident(path, "table_name")?,
columns: collect_idents(path, "column_name"),
},
})
} else {
Ok(Command::DropIndex {
selector: IndexSelector::Named {
name: require_ident(path, "index_name")?,
},
})
}
}
Some("relationship") => {
// Endpoints form has `from` as the third Word.
let has_from = path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Word("from")));
if has_from {
Ok(Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: require_ident(path, "parent_table")?,
parent_column: require_ident(path, "parent_column")?,
child_table: require_ident(path, "child_table")?,
child_column: require_ident(path, "child_column")?,
},
})
} else {
Ok(Command::DropRelationship {
selector: RelationshipSelector::Named {
name: require_ident(path, "relationship_name")?,
},
})
}
}
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown drop subcommand".to_string())],
}),
}
}
fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
// Second matched Word distinguishes column vs the `1:n
// relationship` form. The `1` literal counts as a Word
// (the walker records Literal matches as MatchedKind::Word
// for AST-builder uniformity).
let second_word = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.nth(1);
match second_word {
Some("column") => {
let ty_text = require_ident(path, "type")?;
let ty = ty_text
.parse::<crate::dsl::types::Type>()
.map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
ty,
// Constraint suffix is wired in once the
// constraint grammar lands (ADR-0029).
not_null: false,
unique: false,
default: None,
check: None,
})
}
Some("1") => build_add_relationship(path),
Some("index") => Ok(Command::AddIndex {
name: ident(path, "index_name").map(str::to_string),
table: require_ident(path, "table_name")?,
columns: collect_idents(path, "column_name"),
}),
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown add subcommand".to_string())],
}),
}
}
fn build_add_relationship(path: &MatchedPath) -> Result<Command, ValidationError> {
// Collect all referential-clause actions in matched order
// and validate at-most-2 + not-repeated. The `on <delete|
// update> <action>` sequence shows up as a run of Word
// matches in the path between `to <child>` and end.
//
// Strategy: walk through Word items in order; whenever we
// see `on`, the next Word is the target, then the action
// word(s) follow until the next `on`, `--create-fk`, or
// end-of-path.
let words: Vec<&'static str> = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.collect();
let mut on_delete: Option<ReferentialAction> = None;
let mut on_update: Option<ReferentialAction> = None;
let mut i = 0;
while i < words.len() {
if words[i] == "on" && i + 1 < words.len() {
let target = words[i + 1];
// Action runs from i+2 until the next `on` or end.
let action_start = i + 2;
let mut action_end = action_start;
while action_end < words.len() && words[action_end] != "on" {
action_end += 1;
}
let action = parse_action(&words[action_start..action_end]);
let slot = match target {
"delete" => &mut on_delete,
"update" => &mut on_update,
_ => {
i = action_end;
continue;
}
};
if slot.is_some() {
return Err(ValidationError {
message_key: "parse.custom.on_action_specified_twice",
args: vec![("target", target.to_string())],
});
}
*slot = Some(action);
i = action_end;
} else {
i += 1;
}
}
let create_fk = path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));
Ok(Command::AddRelationship {
name: ident(path, "relationship_name").map(str::to_string),
parent_table: require_ident(path, "parent_table")?,
parent_column: require_ident(path, "parent_column")?,
child_table: require_ident(path, "child_table")?,
child_column: require_ident(path, "child_column")?,
on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
create_fk,
})
}
fn build_rename_column(path: &MatchedPath) -> Result<Command, ValidationError> {
Ok(Command::RenameColumn {
table: require_ident(path, "table_name")?,
old: require_ident(path, "column_name")?,
new: require_ident(path, "new_column_name")?,
})
}
fn build_change_column(path: &MatchedPath) -> Result<Command, ValidationError> {
let ty_text = require_ident(path, "type")?;
let ty = ty_text
.parse::<crate::dsl::types::Type>()
.map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
// Flags: at most one of --force-conversion / --dont-convert.
let flags: Vec<&'static str> = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Flag(n) => Some(*n),
_ => None,
})
.collect();
let mode = match flags.as_slice() {
[] => ChangeColumnMode::Default,
[one] => match *one {
"force-conversion" => ChangeColumnMode::ForceConversion,
"dont-convert" => ChangeColumnMode::DontConvert,
_ => ChangeColumnMode::Default,
},
_ => {
// Two or more flags — mutual exclusion fires
// whether they're the same flag twice or both
// mutually-exclusive flags appear. Wording mirrors
// the chumsky parser's `change_column_flags_exclusive`.
return Err(ValidationError {
message_key: "parse.custom.change_column_flags_exclusive",
args: vec![],
});
}
};
Ok(Command::ChangeColumnType {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
ty,
mode,
})
}
// =================================================================
// CommandNodes
// =================================================================
pub static DROP: CommandNode = CommandNode {
entry: Word::keyword("drop"),
shape: DROP_SHAPE,
ast_builder: build_drop,
help_id: Some("ddl.drop"),
usage_ids: &[
"parse.usage.drop_table",
"parse.usage.drop_column",
"parse.usage.drop_relationship",
"parse.usage.drop_index",
],};
pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"),
shape: ADD_SHAPE,
ast_builder: build_add,
help_id: Some("ddl.add"),
usage_ids: &[
"parse.usage.add_column",
"parse.usage.add_relationship",
"parse.usage.add_index",
],};
pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"),
shape: RENAME_COLUMN,
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
usage_ids: &["parse.usage.rename_column"],};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
shape: CHANGE_COLUMN,
ast_builder: build_change_column,
help_id: Some("ddl.change"),
usage_ids: &["parse.usage.change_column"],};
// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
// (Phase C)
// =================================================================
const COL_NAME_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "col_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
inner: &COL_NAME_IDENT,
};
// ADR-0029 column-constraint suffix — `not null`, `unique`,
// `default <literal>`. (`check (<expr>)` joins in a later
// ADR-0029 step.) One shared fragment: `create table` uses it
// here; `add column` and `add constraint` reuse it later.
const NOT_NULL_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
];
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique"));
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("default")),
super::shared::FALLBACK_VALUE_LITERAL,
];
const DEFAULT_CONSTRAINT: Node = Node::Seq(DEFAULT_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's
/// `(type)` group (ADR-0029 §2.1). `min: 0` so an
/// unconstrained column still matches.
const COLUMN_CONSTRAINT_SUFFIX: Node = Node::Repeated {
inner: &COLUMN_CONSTRAINT,
separator: None,
min: 0,
};
const COL_SPEC_NODES: &[Node] = &[
COL_NAME,
Node::Punct('('),
Node::Ident {
source: IdentSource::Types,
role: "col_type",
validator: Some(TYPE_VALIDATOR),
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
},
Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX,
];
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);
const SPEC_LIST: Node = Node::Repeated {
inner: &COL_SPEC,
separator: Some(&Node::Punct(',')),
min: 1,
};
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
const WITH_PK_NODES: &[Node] = &[
Node::Word(Word::keyword("with")),
Node::Word(Word::keyword("pk")),
SPEC_LIST_OPT,
];
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
const CREATE_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_NEW,
WITH_PK_OPT,
];
const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES);
/// The friendly error for declaring a constraint a
/// primary-key column already implies (ADR-0029 §9).
fn redundant_pk_constraint(column: &str, constraint: &str) -> ValidationError {
ValidationError {
message_key: "parse.custom.constraint_redundant_on_pk",
args: vec![
("column", column.to_string()),
("constraint", constraint.to_string()),
],
}
}
fn build_create_table(path: &MatchedPath) -> Result<Command, ValidationError> {
let name = require_ident(path, "table_name")?;
// Walk the matched items, segmenting per column: a
// `col_name` ident stashes the name, the following
// `col_type` ident finalises the spec, and the constraint
// tokens after it (ADR-0029 §2.1) attach to that spec.
let mut columns: Vec<ColumnSpec> = Vec::new();
let mut pending_name: Option<String> = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
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())],
})?;
columns.push(ColumnSpec::new(col_name, ty));
}
// `not null` — the grammar's `Seq` guarantees a
// `null` Word follows a matched `not` Word.
MatchedKind::Word("not") => {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
if let Some(last) = columns.last_mut() {
last.not_null = true;
}
}
}
MatchedKind::Word("unique") => {
if let Some(last) = columns.last_mut() {
last.unique = true;
}
}
// `default <literal>` — the `Seq` guarantees a value
// item follows a matched `default` Word.
MatchedKind::Word("default") => {
let value = items
.next()
.and_then(crate::dsl::grammar::data::item_to_value)
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "default needs a value".to_string())],
})?;
if let Some(last) = columns.last_mut() {
last.default = Some(value);
}
}
_ => {}
}
}
// No PK clause OR `with pk` alone (no specs): if `with` was
// matched, default to id(serial); otherwise reject with the
// "tables need a primary key" friendly wording.
if columns.is_empty() {
let saw_with = path
.items
.iter()
.any(|i| matches!(i.kind, MatchedKind::Word("with")));
if saw_with {
columns.push(ColumnSpec::new("id", Type::Serial));
} else {
return Err(ValidationError {
message_key: "parse.custom.create_table_needs_pk",
args: vec![],
});
}
}
// Every `with pk` column is part of the primary key
// (ADR-0029 §2.1). A PK column is already NOT NULL, and a
// single-column PK is already UNIQUE — declaring those
// explicitly is a friendly error, not a silent no-op
// (ADR-0029 §9).
let single_column_pk = columns.len() == 1;
for col in &columns {
if col.not_null {
return Err(redundant_pk_constraint(&col.name, "NOT NULL"));
}
if col.unique && single_column_pk {
return Err(redundant_pk_constraint(&col.name, "UNIQUE"));
}
}
let primary_key = columns.iter().map(|c| c.name.clone()).collect();
Ok(Command::CreateTable {
name,
columns,
primary_key,
})
}
pub static CREATE: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: CREATE_TABLE,
ast_builder: build_create_table,
help_id: Some("ddl.create"),
usage_ids: &["parse.usage.create_table"],};
// =================================================================
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
// =================================================================
#[cfg(test)]
mod constraint_tests {
use super::Command;
use crate::dsl::command::ColumnSpec;
use crate::dsl::parser::parse_command;
use crate::dsl::value::Value;
/// Parse a `create table` and return its column specs.
fn create_columns(input: &str) -> Vec<ColumnSpec> {
match parse_command(input).expect("create table should parse") {
Command::CreateTable { columns, .. } => columns,
other => panic!("expected CreateTable, got {other:?}"),
}
}
#[test]
fn create_table_parses_a_text_default() {
// `grade` is the (single) PK column; `default` is
// allowed on a PK column (ADR-0029 §9).
let cols = create_columns("create table T with pk grade(text) default 'A'");
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].default, Some(Value::Text("A".to_string())));
assert!(!cols[0].not_null && !cols[0].unique);
}
#[test]
fn create_table_parses_a_numeric_default_on_a_compound_pk_member() {
let cols = create_columns("create table T with pk a(int), b(int) default 7");
assert_eq!(cols.len(), 2);
assert_eq!(cols[1].default, Some(Value::Number("7".to_string())));
}
#[test]
fn not_null_on_a_pk_column_is_a_redundancy_error() {
// Every `create table` column is a primary-key column,
// so `not null` is always redundant there (ADR-0029 §9).
assert!(parse_command("create table T with pk id(serial) not null").is_err());
}
#[test]
fn unique_on_a_single_column_pk_is_a_redundancy_error() {
assert!(parse_command("create table T with pk code(text) unique").is_err());
}
#[test]
fn unique_on_a_compound_pk_member_is_allowed() {
// A compound PK does not make its members individually
// unique, so an explicit `unique` is meaningful there.
let cols = create_columns("create table T with pk a(int) unique, b(text)");
assert_eq!(cols.len(), 2);
assert!(cols[0].unique, "`a` carries an explicit UNIQUE");
assert!(!cols[1].unique);
}
#[test]
fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
}
}