//! 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::{ AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint, }; use crate::dsl::value::Value; use crate::dsl::grammar::{ CommandNode, HighlightClass, 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::{MatchedItem, 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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'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 ` // ================================================================= const DROP_TABLE_NODES: &[Node] = &[ Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING, ]; const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES); // Advanced-mode SQL `DROP TABLE [IF EXISTS] [;]` (ADR-0035 §4, // sub-phase 4c). Same table-only target as the simple `drop table`, // plus the optional `IF EXISTS` no-op-with-note. The leading concrete // `table` keyword (not the Optional) keeps the element/dispatch // matching honest. static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))]; const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES)); static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[ Node::Word(Word::keyword("table")), SQL_DROP_IF_EXISTS_OPT, TABLE_NAME_EXISTING, Node::Optional(&Node::Punct(';')), ]; const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES); // Advanced-mode SQL `DROP INDEX [IF EXISTS] [;]` (ADR-0035 §4, // sub-phase 4d). Name-only — SQL has no positional `on T (cols)` drop // form (that stays the simple `drop index on …`, which falls back to // the simple `drop` node). Leads on the concrete `index` keyword; the // `IF EXISTS` opt is mid-`Seq` (trap-safe, like SQL_DROP_TABLE). // `INDEX_NAME_EXISTING` has `validator: None`, so `IF EXISTS ` // still parses and reaches the skip path. static SQL_DROP_INDEX_SHAPE_NODES: &[Node] = &[ Node::Word(Word::keyword("index")), SQL_DROP_IF_EXISTS_OPT, INDEX_NAME_EXISTING, Node::Optional(&Node::Punct(';')), ]; const SQL_DROP_INDEX_SHAPE: Node = Node::Seq(SQL_DROP_INDEX_SHAPE_NODES); // ================================================================= // drop_column — `drop column [from] [table] : ` // ================================================================= // `--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 // `.` 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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 ( | on (, …))` // ================================================================= 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, DROP_CONSTRAINT]; const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES); // ================================================================= // add_column — `add column [to] [table] : ( )` // ================================================================= 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(')'), // ADR-0029: the constraint suffix — shared with `create // table`'s column spec. COLUMN_CONSTRAINT_SUFFIX, ]; const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES); // ================================================================= // add_relationship — `add 1:n relationship [as ] // from . to . // [on delete ] [on update ] // [--create-fk]` // ================================================================= // `writes_table: true` on each endpoint's table ident so the // `.` slot narrows to that table's columns (handoff-13 // §2.2 follow-up — mirrors DR_PARENT / DR_CHILD). // A single FK-endpoint column ident (narrows to the endpoint // table's columns via the table ident's `writes_table: true`). const AR_PARENT_COL: Node = Node::Ident { source: IdentSource::Columns, role: "parent_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, }; // Compound endpoint: `( a, b, … )` — a comma-separated column list // in parens (ADR-0043). Same role as the single form, so the // builder collects either shape uniformly. const AR_PARENT_COL_LIST: Node = Node::Repeated { inner: &AR_PARENT_COL, separator: Some(&Node::Punct(',')), min: 1, }; const AR_PARENT_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_PARENT_COL_LIST, Node::Punct(')')]; const AR_PARENT_COLS_PAREN: Node = Node::Seq(AR_PARENT_COLS_PAREN_NODES); // `from P.(a, b)` (compound) or `from P.col` (single) — Choice on // the first post-`.` token (`(` vs an ident), so order is safe. const AR_PARENT_COLS_CHOICES: &[Node] = &[AR_PARENT_COLS_PAREN, AR_PARENT_COL]; const AR_PARENT_COLS: Node = Node::Choice(AR_PARENT_COLS_CHOICES); 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: false, }, Node::Punct('.'), AR_PARENT_COLS, ]; const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES); const AR_CHILD_COL: Node = Node::Ident { source: IdentSource::Columns, role: "child_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, }; const AR_CHILD_COL_LIST: Node = Node::Repeated { inner: &AR_CHILD_COL, separator: Some(&Node::Punct(',')), min: 1, }; const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')]; const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES); const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL]; const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES); 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: false, }, Node::Punct('.'), AR_CHILD_COLS, ]; 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 ] on (, …)` // ================================================================= 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, ADD_CONSTRAINT]; const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES); // ================================================================= // rename_column — `rename column [in] [table] : to ` // ================================================================= 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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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] : // ( ) [--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 { 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 { 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, _source: &str) -> Result { // 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("constraint") => build_drop_constraint(path, _source), 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, _source: &str) -> Result { // 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::() .map_err(|_| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown type".to_string())], })?; let (not_null, unique, default, check) = collect_column_constraints(path)?; Ok(Command::AddColumn { table: require_ident(path, "table_name")?, column: require_ident(path, "column_name")?, ty, not_null, unique, default, check, }) } Some("1") => build_add_relationship(path, _source), 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"), }), Some("constraint") => build_add_constraint(path, _source), _ => Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown add subcommand".to_string())], }), } } fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result { // Collect all referential-clause actions in matched order // and validate at-most-2 + not-repeated. The `on ` sequence shows up as a run of Word // matches in the path between `to ` 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 = None; let mut on_update: Option = 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"))); // Collect every matched `parent_column` / `child_column` ident, in // order — one each for the single-column `from P.col to C.col` // form, or the full lists for the parenthesized compound form // `from P.(a, b) to C.(x, y)` (ADR-0043). let parent_columns = collect_idents(path, "parent_column"); let child_columns = collect_idents(path, "child_column"); if parent_columns.is_empty() || child_columns.is_empty() { return Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "a relationship needs both endpoints".to_string())], }); } Ok(Command::AddRelationship { name: ident(path, "relationship_name").map(str::to_string), parent_table: require_ident(path, "parent_table")?, parent_columns, child_table: require_ident(path, "child_table")?, child_columns, 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, _source: &str) -> Result { 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, _source: &str) -> Result { let ty_text = require_ident(path, "type")?; let ty = ty_text .parse::() .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, }) } /// Build an `add constraint to .` command /// (ADR-0029 §2.2). The `` reuses the §2.1 /// `COLUMN_CONSTRAINT` Choice, so exactly one of the four /// constraint kinds is matched; `collect_column_constraints` /// recovers it. The §9 redundancy and §5 dry-run checks are /// execution-time (the parser has no schema) and live in the /// database worker. fn build_add_constraint(path: &MatchedPath, _source: &str) -> Result { let (not_null, unique, default, check) = collect_column_constraints(path)?; let constraint = if not_null { Constraint::NotNull } else if unique { Constraint::Unique } else if let Some(value) = default { Constraint::Default(value) } else if let Some(expr) = check { Constraint::Check(expr) } else { return Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "add constraint needs a constraint".to_string())], }); }; Ok(Command::AddConstraint { table: require_ident(path, "table_name")?, column: require_ident(path, "column_name")?, constraint, }) } /// Build a `drop constraint from .` command /// (ADR-0029 §2.2). `drop` names only the kind — the /// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind /// is recovered from which keyword(s) the path matched. fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result { let words: Vec<&'static str> = path .items .iter() .filter_map(|i| match &i.kind { MatchedKind::Word(w) => Some(*w), _ => None, }) .collect(); // `not` appears only in the `not null` Seq, so its presence // alone identifies the kind. let kind = if words.contains(&"not") { ConstraintKind::NotNull } else if words.contains(&"unique") { ConstraintKind::Unique } else if words.contains(&"default") { ConstraintKind::Default } else if words.contains(&"check") { ConstraintKind::Check } else { return Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "drop constraint needs a constraint kind".to_string())], }); }; Ok(Command::DropConstraint { table: require_ident(path, "table_name")?, column: require_ident(path, "column_name")?, kind, }) } // ================================================================= // CommandNodes // ================================================================= pub static DROP: CommandNode = CommandNode { entry: Word::keyword("drop"), shape: DROP_SHAPE, ast_builder: build_drop, help_id: Some("ddl.drop"), hint_id: None, usage_ids: &[ "parse.usage.drop_table", "parse.usage.drop_column", "parse.usage.drop_relationship", "parse.usage.drop_index", "parse.usage.drop_constraint", ],}; pub static ADD: CommandNode = CommandNode { entry: Word::keyword("add"), shape: ADD_SHAPE, ast_builder: build_add, help_id: Some("ddl.add"), hint_id: None, usage_ids: &[ "parse.usage.add_column", "parse.usage.add_relationship", "parse.usage.add_index", "parse.usage.add_constraint", ],}; pub static RENAME: CommandNode = CommandNode { entry: Word::keyword("rename"), shape: RENAME_COLUMN, ast_builder: build_rename_column, help_id: Some("ddl.rename"), hint_id: None, 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"), hint_id: None, usage_ids: &["parse.usage.change_column"],}; // ================================================================= // create_table — `create table [with pk [()[, ...]]]` // (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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: false, }; const COL_NAME: Node = Node::Hinted { mode: NEW_NAME_HINT, inner: &COL_NAME_IDENT, }; // ADR-0029 column-constraint suffix — `not null`, `unique`, // `default `, `check ()`. One shared fragment: // `create table` uses it here, `add column` reuses it as its // type suffix, and `add constraint` reuses the individual // `COLUMN_CONSTRAINT` Choice for its constraint slot. 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); // `check ( )` — the expression is the ADR-0026 WHERE // grammar, reached through `Subgrammar` (ADR-0029 §2.1). The // parentheses match SQL's `CHECK (…)` and give the parser an // unambiguous end for the expression. const CHECK_CONSTRAINT_NODES: &[Node] = &[ Node::Word(Word::keyword("check")), Node::Punct('('), Node::Subgrammar(&super::expr::OR_EXPR), Node::Punct(')'), ]; const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES); const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_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, }; // ================================================================= // add_constraint / drop_constraint — `add constraint // to .` / `drop constraint from .` // (ADR-0029 §2.2) // ================================================================= // Payload-free keyword nodes for `drop constraint` — naming the // kind is enough, since at most one constraint of each kind // exists per column. `not null` / `unique` reuse the §2.1 // keyword-only nodes; `default` / `check` need bare-keyword // variants here (their §2.1 forms carry a literal / expression // payload that `drop` does not take). const DROP_DEFAULT_KEYWORD: Node = Node::Word(Word::keyword("default")); const DROP_CHECK_KEYWORD: Node = Node::Word(Word::keyword("check")); const DROP_CONSTRAINT_KIND_CHOICES: &[Node] = &[ NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DROP_DEFAULT_KEYWORD, DROP_CHECK_KEYWORD, ]; const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES); // The dotted `
.` target — the same `Ident '.' // Ident` shape `add 1:n relationship` uses for its endpoints. // `writes_table: true` on the table ident (via `TABLE_NAME_ // EXISTING`) narrows the `.` slot's completion // candidates to that table's columns. const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME]; const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES); const ADD_CONSTRAINT_NODES: &[Node] = &[ Node::Word(Word::keyword("constraint")), COLUMN_CONSTRAINT, Node::Word(Word::keyword("to")), CONSTRAINT_TARGET, ]; const ADD_CONSTRAINT: Node = Node::Seq(ADD_CONSTRAINT_NODES); const DROP_CONSTRAINT_NODES: &[Node] = &[ Node::Word(Word::keyword("constraint")), DROP_CONSTRAINT_KIND, Node::Word(Word::keyword("from")), CONSTRAINT_TARGET, ]; const DROP_CONSTRAINT: Node = Node::Seq(DROP_CONSTRAINT_NODES); const COL_SPEC_NODES: &[Node] = &[ COL_NAME, Node::Punct('('), Node::Ident { source: IdentSource::Types, role: "col_type", validator: Some(TYPE_VALIDATOR), highlight_override: Some(HighlightClass::Type), writes_table: false, writes_column: false, writes_user_listed_column: false, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: 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); /// Consume a `check` constraint's `( )` from `items`, /// which must be positioned just after the `Word("check")`, /// and build the ADR-0026 expression (ADR-0029 §2.1). The /// grammar's `Seq` guarantees the surrounding `(` … `)`; /// paren depth handles a parenthesised sub-expression inside. fn consume_check_expr( items: &mut std::iter::Peekable>, ) -> Result { items.next(); // the opening `(` let mut depth = 1usize; let mut expr_items: Vec = Vec::new(); for inner in items.by_ref() { match &inner.kind { MatchedKind::Punct('(') => { depth += 1; expr_items.push(inner.clone()); } MatchedKind::Punct(')') => { depth -= 1; if depth == 0 { break; } expr_items.push(inner.clone()); } _ => expr_items.push(inner.clone()), } } super::expr::build_expr(&expr_items) } /// Collect the ADR-0029 constraint suffix from a /// single-column command's matched path (`add column`), /// returning the `(not_null, unique, default, check)` tuple. /// The scan reacts only to the constraint keywords, so /// passing the whole path is safe. (`create table`'s /// multi-column collection is inline in `build_create_table`.) fn collect_column_constraints( path: &MatchedPath, ) -> Result<(bool, bool, Option, Option), ValidationError> { let mut not_null = false; let mut unique = false; let mut default = None; let mut check = None; let mut items = path.items.iter().peekable(); while let Some(item) = items.next() { match &item.kind { MatchedKind::Word("not") => { if matches!( items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null")) ) { items.next(); not_null = true; } } MatchedKind::Word("unique") => unique = true, 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())], })?; default = Some(value); } MatchedKind::Word("check") => { check = Some(consume_check_expr(&mut items)?); } _ => {} } } Ok((not_null, unique, default, check)) } /// 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, _source: &str) -> Result { 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 = Vec::new(); let mut pending_name: Option = 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::().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 ` — 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); } } // `check ( )` (ADR-0029 §2.1). MatchedKind::Word("check") => { let expr = consume_check_expr(&mut items)?; if let Some(last) = columns.last_mut() { last.check = Some(expr); } } _ => {} } } // 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"), hint_id: None, usage_ids: &["parse.usage.create_table"],}; // ================================================================= // create_m2n — `create m:n relationship from to [as ]` // (ADR-0045 / C4). Generates an auto-named junction table with two FKs // + two 1:n relationships. A *separate* `CommandNode` under the shared // `create` entry word (the walker dispatches both); the `m` opener is a // `Literal` (not a keyword) so it never shadows an identifier, mirroring // the `1` in `add 1:n relationship`. // ================================================================= const M2N_T1: Node = Node::Ident { source: IdentSource::Tables, role: "m2n_t1", 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, }; const M2N_T2: Node = Node::Ident { source: IdentSource::Tables, role: "m2n_t2", 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, }; // Optional `as ` — a *new* table name (the junction), // so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source + // hint). The only `table_name` role in this path, so the builder reads // it directly as the junction name. const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW]; const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES)); const CREATE_M2N_NODES: &[Node] = &[ Node::Literal("m"), Node::Punct(':'), Node::Word(Word::keyword("n")), Node::Word(Word::keyword("relationship")), Node::Word(Word::keyword("from")), M2N_T1, Node::Word(Word::keyword("to")), M2N_T2, M2N_AS_NAME_OPT, ]; const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES); fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result { Ok(Command::CreateM2nRelationship { t1: require_ident(path, "m2n_t1")?, t2: require_ident(path, "m2n_t2")?, name: ident(path, "table_name").map(str::to_string), }) } pub static CREATE_M2N: CommandNode = CommandNode { entry: Word::keyword("create"), shape: CREATE_M2N_SHAPE, ast_builder: build_create_m2n, help_id: Some("ddl.create_m2n"), hint_id: None, usage_ids: &["parse.usage.create_m2n"], }; /// 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-phases 4a + 4a.2). Executes /// structurally — extracts the same `ColumnSpec`/`primary_key` the /// simple-mode builder produces so the worker reuses `do_create_table`. /// /// 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 { 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. let if_not_exists = path .items .iter() .any(|i| matches!(i.kind, MatchedKind::Word("if"))); let mut columns: Vec = Vec::new(); let mut primary_key: Vec = Vec::new(); let mut unique_constraints: Vec> = Vec::new(); let mut check_constraints: Vec = Vec::new(); let mut foreign_keys: Vec = Vec::new(); let mut pending_name: Option = None; // Distinguish a table-level `CHECK (…)` from a column-level one // (ADR-0035 §4a.3): both are spelled `check (`, and `Word` matches // carry no role, so position is the only signal. `column_open` is // `true` while a column definition is accepting constraints in the // current element; a `check` seen while it is `false` is table-level. // `depth` tracks the parens that reach this loop (the outer column // list, type length-args `(10, 2)`, and table-`PRIMARY KEY (a, b)` — // the `check`/`default`/table-`unique` arms consume their own parens // internally, so they never perturb it). An element separator is a // comma at the column-list interior, `depth == 1`. let mut column_open = false; let mut depth = 0usize; // A `CONSTRAINT ` prefix stashes the name until the following // table-level `FOREIGN KEY` consumes it (ADR-0035 §5, 4b). let mut pending_fk_name: Option = None; let mut items = path.items.iter().peekable(); while let Some(item) = items.next() { match &item.kind { // A column name stashes until its type finalises the spec. MatchedKind::Ident { role: "col_name", .. } => { pending_name = Some(item.text.clone()); } // Single-word type — resolve through the SQL alias map. MatchedKind::Ident { role: "col_type", .. } => { let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown type".to_string())], })?; let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?; columns.push(ColumnSpec::new(col_name, ty)); column_open = true; } // `double precision` — the two-word alias maps to `real`. // The grammar guarantees `precision` follows `double`. MatchedKind::Word("double") => { if matches!( items.peek().map(|i| &i.kind), Some(MatchedKind::Word("precision")) ) { items.next(); } let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?; columns.push(ColumnSpec::new(col_name, Type::Real)); column_open = true; } // A table-level `PRIMARY KEY (col, …)` column reference. MatchedKind::Ident { role: "pk_column", .. } => { primary_key.push(item.text.clone()); } // `not null` column constraint (only once a column exists; // the `IF NOT EXISTS` `not` precedes every column). 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; } } } // `unique` — table-level `UNIQUE (cols)` when followed by // `(`, else a column-level constraint on the last column. MatchedKind::Word("unique") => { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) { items.next(); // consume '(' let mut cols: Vec = 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; } } // `primary key` — either a column-level constraint (mark // the most recent column) or the table-level clause (whose // `pk_column` idents follow and are collected above). MatchedKind::Word("primary") => { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) { items.next(); // Table-level `PRIMARY KEY (…)` is followed by `(` // (then `pk_column` idents, collected above); // column-level `PRIMARY KEY` is not, and marks the // most-recent column. let table_level = matches!( items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')) ); if !table_level && let Some(last) = columns.last() { primary_key.push(last.name.clone()); } } } // `default ` — 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 ( )` — capture the inner expression text // (without the wrapping parens) by matching paren depth, then // route by element position: a CHECK inside an open column // definition is column-level (4a.2); one seen at element // start (no column open) is a table-level CHECK (4a.3). MatchedKind::Word("check") => { if let Some((s, e)) = capture_parenthesised_span(&mut items) { let text = source[s..e].trim().to_string(); if column_open { if let Some(last) = columns.last_mut() { last.check_sql = Some(text); } } else { check_constraints.push(text); } } } // `constraint ` — stash the name for the table-level // `foreign key` that follows (ADR-0035 §5, 4b). MatchedKind::Word("constraint") => { if let Some(it) = items.next() { pending_fk_name = Some(it.text.clone()); } } // Inline `references [()] [on …]` — a // column-level FK on the current column (ADR-0035 §5, 4b). // Auto-named at execution; the FK clause's own parens are // consumed in `consume_fk_reference`, so they don't perturb // the element-boundary `depth` tracker. MatchedKind::Word("references") => { // Inline FK is single-column (the column it sits on); // a compound FK uses the table-level form (ADR-0043 D4). let child_column = columns.last().map_or_else(String::new, |c| c.name.clone()); foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true)); } // Table-level `[constraint ] foreign key () // references [()] [on …]` (ADR-0035 §5, 4b). MatchedKind::Word("foreign") => { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) { items.next(); // `key` } // `( [, ]* )` — a compound // FK lists multiple child columns (ADR-0043). if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) { items.next(); } let mut child_columns = Vec::new(); while let Some(it) = items.peek() { match &it.kind { MatchedKind::Punct(')') => break, MatchedKind::Punct(',') => { items.next(); } _ => child_columns.push(items.next().expect("peeked").text.clone()), } } if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) { items.next(); } // `references …` if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { items.next(); } let fk = consume_fk_reference(&mut items, pending_fk_name.take(), child_columns, false); foreign_keys.push(fk); } // Track paren depth for element-boundary detection. The // column/`check`/`default`/table-`unique`/FK arms consume // their own parens, so the only parens reaching here are the // outer column list, type length-args, and // table-`PRIMARY KEY (…)`. MatchedKind::Punct('(') => depth += 1, MatchedKind::Punct(')') => depth = depth.saturating_sub(1), // A comma at the column-list interior ends the current // element — the next element starts fresh (no column open). MatchedKind::Punct(',') if depth == 1 => column_open = false, _ => {} } } // De-dup redundant flags off a sole primary-key column (ADR-0035 // §6.5): a single-column PK is already NOT NULL + UNIQUE, so // emitting them again would create a spurious index. Advanced mode // accepts the redundant spelling (real SQL does) rather than // rejecting it like simple mode (ADR-0029 §9). if primary_key.len() == 1 && let Some(c) = columns.iter_mut().find(|c| c.name == primary_key[0]) { c.not_null = false; c.unique = false; } Ok(Command::SqlCreateTable { name, columns, primary_key, unique_constraints, check_constraints, foreign_keys, if_not_exists, }) } /// Capture the byte span of a `DEFAULT ` 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) -> Option<(usize, usize)> where I: Iterator, { let mut depth = 0usize; let mut start: Option = 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 ( )`) 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) -> Option<(usize, usize)> where I: Iterator, { 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)) } /// Consume the tail of a foreign-key reference from the matched-item /// stream (ADR-0035 §5, sub-phase 4b): the parent table ident, an /// optional `( )`, and any `on ` /// clauses. The next item must be the parent-table ident (the /// `references` keyword was already consumed by the caller). The /// reference's own parens are consumed here, so they never reach the /// builder's element-boundary `depth` tracker. fn consume_fk_reference<'a, I>( items: &mut std::iter::Peekable, name: Option, child_columns: Vec, inline: bool, ) -> SqlForeignKey where I: Iterator, { let parent_table = items.next().map_or_else(String::new, |it| it.text.clone()); // Optional `( [, ]* )` — a // compound FK references multiple parent columns (ADR-0043). // `None` for the bare `REFERENCES ` form. let parent_columns: Option> = if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) { items.next(); // `(` let mut cols = Vec::new(); while let Some(it) = items.peek() { match &it.kind { MatchedKind::Punct(')') => break, MatchedKind::Punct(',') => { items.next(); } _ => cols.push(items.next().expect("peeked").text.clone()), } } if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) { items.next(); // `)` } Some(cols) } else { None }; // `on ` clauses, in either order, 0..2. let mut on_delete = ReferentialAction::default_action(); let mut on_update = ReferentialAction::default_action(); while matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("on"))) { items.next(); // `on` let target = items.next().map(|it| it.kind.clone()); let action = consume_referential_action(items); match target { Some(MatchedKind::Word("delete")) => on_delete = action, Some(MatchedKind::Word("update")) => on_update = action, _ => {} } } SqlForeignKey { name, child_columns, parent_table, parent_columns, on_delete, on_update, inline, } } /// Read a single referential action (`cascade` / `restrict` / /// `set null` / `no action`) from the matched-item stream — the /// two-word forms (`set null`, `no action`) consume their second word. fn consume_referential_action<'a, I>(items: &mut std::iter::Peekable) -> ReferentialAction where I: Iterator, { match items.next().map(|it| it.kind.clone()) { Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade, Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict, Some(MatchedKind::Word("set")) => { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) { items.next(); } ReferentialAction::SetNull } Some(MatchedKind::Word("no")) => { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) { items.next(); } ReferentialAction::NoAction } _ => ReferentialAction::default_action(), } } pub static SQL_CREATE_TABLE: CommandNode = CommandNode { entry: Word::keyword("create"), shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), ast_builder: build_sql_create_table, help_id: Some("ddl.sql_create_table"), hint_id: None, usage_ids: &["parse.usage.sql_create_table"], }; /// Build a `Command::SqlDropTable` from the advanced-mode SQL /// `DROP TABLE [IF EXISTS] ` shape (ADR-0035 §4, sub-phase 4c). /// `if` appears only in the `IF EXISTS` prefix, so its presence is the /// flag (mirroring `build_sql_create_table`'s `if_not_exists`). fn build_sql_drop_table(path: &MatchedPath, _source: &str) -> Result { Ok(Command::SqlDropTable { name: require_ident(path, "table_name")?, if_exists: path.contains_word("if"), }) } pub static SQL_DROP_TABLE: CommandNode = CommandNode { entry: Word::keyword("drop"), shape: SQL_DROP_TABLE_SHAPE, ast_builder: build_sql_drop_table, help_id: Some("ddl.sql_drop_table"), hint_id: None, usage_ids: &["parse.usage.sql_drop_table"], }; /// Build a `Command::SqlDropIndex` from the advanced-mode SQL /// `DROP INDEX [IF EXISTS] ` shape (ADR-0035 §4, sub-phase 4d). /// `if` appears only in the `IF EXISTS` prefix, so its presence is the /// flag (mirroring `build_sql_drop_table`). fn build_sql_drop_index(path: &MatchedPath, _source: &str) -> Result { Ok(Command::SqlDropIndex { name: require_ident(path, "index_name")?, if_exists: path.contains_word("if"), }) } pub static SQL_DROP_INDEX: CommandNode = CommandNode { entry: Word::keyword("drop"), shape: SQL_DROP_INDEX_SHAPE, ast_builder: build_sql_drop_index, help_id: Some("ddl.sql_drop_index"), hint_id: None, usage_ids: &["parse.usage.sql_drop_index"], }; // ================================================================= // SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [] ON (cols)` // (ADR-0035 §4d). Entry word `create` — `create`'s *second* advanced // node (alongside SQL_CREATE_TABLE). // ================================================================= // Leading `[UNIQUE]` prefix as a `Choice` whose every branch starts on a // concrete keyword (`unique index` | `index`) — the trap-safe form (the // §3 rule forbids a leading *Optional*, not a leading `Choice`). The // builder reads `unique` presence via `contains_word("unique")`. static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))]; const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES); static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))]; const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES); static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[ Node::Word(Word::keyword("if")), Node::Word(Word::keyword("not")), Node::Word(Word::keyword("exists")), ]; const SQL_CI_IF_NOT_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_CI_IF_NOT_EXISTS_NODES)); // The name/`on` selector. The **unnamed** (`on`-led) branch comes FIRST, // relying on `Choice` backtracking — exactly the shipped `DI_SELECTOR` // pattern (`DI_POSITIONAL` first). A bare `Optional()` would // instead greedily consume the `on` keyword (`consume_ident` does not // reject keywords), breaking the unnamed form. static SQL_CI_UNNAMED_NODES: &[Node] = &[ Node::Word(Word::keyword("on")), TABLE_NAME_EXISTING, Node::Punct('('), INDEX_COLUMN_LIST, Node::Punct(')'), ]; const SQL_CI_UNNAMED: Node = Node::Seq(SQL_CI_UNNAMED_NODES); static SQL_CI_NAMED_NODES: &[Node] = &[ INDEX_NAME_NEW, Node::Word(Word::keyword("on")), TABLE_NAME_EXISTING, Node::Punct('('), INDEX_COLUMN_LIST, Node::Punct(')'), ]; const SQL_CI_NAMED: Node = Node::Seq(SQL_CI_NAMED_NODES); static SQL_CI_SELECTOR_CHOICES: &[Node] = &[SQL_CI_UNNAMED, SQL_CI_NAMED]; const SQL_CI_SELECTOR: Node = Node::Choice(SQL_CI_SELECTOR_CHOICES); static SQL_CREATE_INDEX_SHAPE_NODES: &[Node] = &[ SQL_CI_LEAD, SQL_CI_IF_NOT_EXISTS_OPT, SQL_CI_SELECTOR, Node::Optional(&Node::Punct(';')), ]; const SQL_CREATE_INDEX_SHAPE: Node = Node::Seq(SQL_CREATE_INDEX_SHAPE_NODES); /// Build a `Command::SqlCreateIndex` from the advanced-mode SQL /// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [] ON (cols)` shape /// (ADR-0035 §4d). `unique`/`if_not_exists` are keyword-presence flags /// (`unique` only in the lead; `if` only in `IF NOT EXISTS`); the name /// is present iff the `SQL_CI_NAMED` branch matched. Columns / table /// extraction mirrors the simple `add index` builder. fn build_sql_create_index(path: &MatchedPath, _source: &str) -> Result { Ok(Command::SqlCreateIndex { name: ident(path, "index_name").map(str::to_string), table: require_ident(path, "table_name")?, columns: collect_idents(path, "column_name"), unique: path.contains_word("unique"), if_not_exists: path.contains_word("if"), }) } pub static SQL_CREATE_INDEX: CommandNode = CommandNode { entry: Word::keyword("create"), shape: SQL_CREATE_INDEX_SHAPE, ast_builder: build_sql_create_index, help_id: Some("ddl.sql_create_index"), hint_id: None, usage_ids: &["parse.usage.sql_create_index"], }; // ================================================================= // SQL `ALTER TABLE ` (ADR-0035 §4, sub-phase 4e). // `alter` is an advanced-*only* entry word (like `select`/`with`). // Actions: ADD/DROP/RENAME COLUMN — the `COLUMN` keyword is required // (reserves bare `RENAME TO` for 4h and `ADD CONSTRAINT` for 4g). // ================================================================= // The ALTER table slot carries the SQL-family `reject_internal_table` // validator (parse-time refusal; the executors guard the rest) and // `writes_table` so the DROP/RENAME column slot narrows to its columns. const AT_TABLE_NAME: Node = Node::Ident { source: IdentSource::Tables, role: "table_name", validator: Some(super::sql_select::reject_internal_table), highlight_override: None, writes_table: true, writes_column: false, writes_user_listed_column: false, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: false, }; // ADD COLUMN's constraint suffix — the SQL leaf nodes for NOT NULL / // UNIQUE / DEFAULT / CHECK only. PK and inline REFERENCES are // deliberately excluded (PK is invalid on ADD COLUMN; REFERENCES is 4g). static AT_ADD_CONSTRAINT_CHOICES: &[Node] = &[ Node::Seq(super::sql_create_table::NOT_NULL_NODES), Node::Word(Word::keyword("unique")), Node::Seq(super::sql_create_table::DEFAULT_NODES), Node::Seq(super::sql_create_table::CHECK_NODES), ]; const AT_ADD_CONSTRAINT: Node = Node::Choice(AT_ADD_CONSTRAINT_CHOICES); const AT_ADD_CONSTRAINT_SUFFIX: Node = Node::Repeated { inner: &AT_ADD_CONSTRAINT, separator: None, min: 0, }; // The walker's `Choice` selects a branch by its **leading** token and // does not backtrack into a sibling once a branch's first keyword // matched. So the action `Choice` keeps ONE branch per leading verb // (`add`/`drop`/`rename`/`alter`); the `add` and `drop` verbs then // fan out to an **inner** `Choice` whose branches each lead on a // *distinct* second keyword (column / constraint / check / unique / // foreign / primary), so no two same-led branches ever sit in one // `Choice`. // `add column [constraints]` — the column-def tail (the // leading `add` is consumed by `AT_ADD`). static AT_ADD_COLUMN_TAIL_NODES: &[Node] = &[ Node::Word(Word::keyword("column")), super::sql_create_table::COL_NAME, super::sql_create_table::SQL_TYPE, AT_ADD_CONSTRAINT_SUFFIX, ]; const AT_ADD_COLUMN_TAIL: Node = Node::Seq(AT_ADD_COLUMN_TAIL_NODES); // `drop column ` / `drop constraint ` tails (leading `drop` // consumed by `AT_DROP`). static AT_DROP_COLUMN_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("column")), COLUMN_NAME]; const AT_DROP_COLUMN_TAIL: Node = Node::Seq(AT_DROP_COLUMN_TAIL_NODES); // New-table-name slot for `RENAME TO ` (ADR-0035 §6, sub-phase 4h). // Mirrors the `CREATE TABLE` name slot: `IdentSource::NewName` (a name // being introduced, not completed from existing tables) + the same // `reject_internal_table` parse-time validator, so an `__rdbms_*` target // is refused before submit. Wrapped in `NEW_NAME_HINT` like // `NEW_COLUMN_NAME`. `writes_table: false` — nothing downstream of // `rename to ` references the schema cache. const NEW_TABLE_NAME_IDENT: Node = Node::Ident { source: IdentSource::NewName, role: "new_table_name", validator: Some(super::sql_select::reject_internal_table), 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, }; const NEW_TABLE_NAME: Node = Node::Hinted { mode: NEW_NAME_HINT, inner: &NEW_TABLE_NAME_IDENT, }; // The `rename` verb fans out (like `add`/`drop`, §6.1) to an inner // `Choice` whose two tails lead on DISTINCT second keywords: `column` // (rename column) and `to` (rename table — 4h). The walker `Choice` // selects by the leading token and never backtracks between branches, so // the distinct keywords keep them apart. static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[ Node::Word(Word::keyword("column")), COLUMN_NAME, Node::Word(Word::keyword("to")), NEW_COLUMN_NAME, ]; const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES); static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME]; const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES); static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL]; const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES); static AT_RENAME_NODES: &[Node] = &[Node::Word(Word::keyword("rename")), AT_RENAME_TAIL]; const AT_RENAME: Node = Node::Seq(AT_RENAME_NODES); // `ALTER COLUMN ` (ADR-0035 §4f + Amendment 2). The action // tail is a Choice on distinct concrete keywords — `type` / `set` / // `drop` — trap-safe. The type slot reuses SQL_TYPE (the same alias map + // `double precision` pair the CREATE TABLE / ADD COLUMN forms use). // // TYPE — PostgreSQL shorthand (§4f) // SET DATA TYPE — ISO canonical synonym (Amendment 2) // SET NOT NULL — documented extension (Amendment 2) // SET DEFAULT — ISO (Amendment 2), raw sql_expr text // DROP NOT NULL — ISO-ish (Amendment 2) // DROP DEFAULT — ISO (Amendment 2) // // `NOT NULL` reused by both SET and DROP tails (distinct sibling leads in // each). `DEFAULT ` reuses the CREATE TABLE `DEFAULT_NODES` so a // default is one syntax, captured as raw text (sql_expr builds no AST). static AT_AC_TYPE_NODES: &[Node] = &[ Node::Word(Word::keyword("type")), super::sql_create_table::SQL_TYPE, ]; const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES); static AT_AC_NOT_NULL_NODES: &[Node] = &[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))]; const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES); static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[ Node::Word(Word::keyword("data")), Node::Word(Word::keyword("type")), super::sql_create_table::SQL_TYPE, ]; const AT_AC_SET_DATA_TYPE: Node = Node::Seq(AT_AC_SET_DATA_TYPE_NODES); static AT_AC_SET_TAIL_CHOICES: &[Node] = &[ AT_AC_SET_DATA_TYPE, AT_AC_NOT_NULL, Node::Seq(super::sql_create_table::DEFAULT_NODES), ]; const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES); static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL]; const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES); static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))]; const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES); static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL]; const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES); static AT_ALTER_COLUMN_TAIL_CHOICES: &[Node] = &[AT_AC_TYPE, AT_AC_SET, AT_AC_DROP]; const AT_ALTER_COLUMN_TAIL: Node = Node::Choice(AT_ALTER_COLUMN_TAIL_CHOICES); static AT_ALTER_COLUMN_NODES: &[Node] = &[ Node::Word(Word::keyword("alter")), Node::Word(Word::keyword("column")), COLUMN_NAME, AT_ALTER_COLUMN_TAIL, ]; const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES); // --- 4g: ADD [CONSTRAINT ] table-constraint / DROP CONSTRAINT --- // // `ADD [CONSTRAINT ] (check (…) | unique (…) | foreign key (…) // references … | primary key (…))` and `DROP CONSTRAINT ` // (ADR-0035 §4g). The constraint bodies reuse the `sql_create_table` // table-element nodes; the §4g name comes from the `CONSTRAINT ` // prefix (a dedicated `constraint`-led inner branch — never a leading // `Optional`). UNIQUE/PRIMARY KEY may carry a name syntactically but the // builder refuses it (composite UNIQUE is anonymous; PK is unsupported). const CONSTRAINT_NAME: Node = Node::Ident { source: IdentSource::NewName, role: "constraint_name", 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, }; // The constraint bodies — each leads on a distinct concrete keyword. static AT_CONSTRAINT_BODY_CHOICES: &[Node] = &[ super::sql_create_table::TABLE_CHECK, super::sql_create_table::TABLE_UNIQUE, super::sql_create_table::TABLE_FK, // `primary key (…)` parses so the builder can refuse it with a // specific message rather than a generic "unexpected `primary`". super::sql_create_table::TABLE_PK, ]; const AT_CONSTRAINT_BODY: Node = Node::Choice(AT_CONSTRAINT_BODY_CHOICES); // `constraint ` — the named-constraint tail (leads on the // concrete `constraint` keyword, so it is a safe `Choice` sibling). static AT_CONSTRAINT_NAMED_NODES: &[Node] = &[ Node::Word(Word::keyword("constraint")), CONSTRAINT_NAME, AT_CONSTRAINT_BODY, ]; const AT_CONSTRAINT_NAMED: Node = Node::Seq(AT_CONSTRAINT_NAMED_NODES); // The `add` tail: a column def, a named constraint, or one of the bare // (unnamed) constraint bodies — each branch leads on a distinct keyword // (column / constraint / check / unique / foreign / primary). static AT_ADD_TAIL_CHOICES: &[Node] = &[ AT_ADD_COLUMN_TAIL, AT_CONSTRAINT_NAMED, super::sql_create_table::TABLE_CHECK, super::sql_create_table::TABLE_UNIQUE, super::sql_create_table::TABLE_FK, super::sql_create_table::TABLE_PK, ]; const AT_ADD_TAIL: Node = Node::Choice(AT_ADD_TAIL_CHOICES); static AT_ADD_NODES: &[Node] = &[Node::Word(Word::keyword("add")), AT_ADD_TAIL]; const AT_ADD: Node = Node::Seq(AT_ADD_NODES); // The `drop` tail: a column or a named constraint (distinct second // keywords `column` / `constraint`). static AT_DROP_CONSTRAINT_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("constraint")), CONSTRAINT_NAME]; const AT_DROP_CONSTRAINT_TAIL: Node = Node::Seq(AT_DROP_CONSTRAINT_TAIL_NODES); static AT_DROP_TAIL_CHOICES: &[Node] = &[AT_DROP_COLUMN_TAIL, AT_DROP_CONSTRAINT_TAIL]; const AT_DROP_TAIL: Node = Node::Choice(AT_DROP_TAIL_CHOICES); static AT_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_DROP_TAIL]; const AT_DROP: Node = Node::Seq(AT_DROP_NODES); // One branch per leading verb (`add`/`drop`/`rename`/`alter`) — distinct // concrete keywords, trap-safe. (The branch's `alter` is the action // word; the entry-word `alter` was already consumed by dispatch.) The // second-keyword fan-out happens in `AT_ADD` / `AT_DROP`'s inner Choice. static AT_ACTION_CHOICES: &[Node] = &[AT_ADD, AT_DROP, AT_RENAME, AT_ALTER_COLUMN]; const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES); static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[ Node::Word(Word::keyword("table")), AT_TABLE_NAME, AT_ACTION, Node::Optional(&Node::Punct(';')), ]; const SQL_ALTER_TABLE_SHAPE: Node = Node::Seq(SQL_ALTER_TABLE_SHAPE_NODES); /// Build the single `ColumnSpec` for an `ALTER TABLE … ADD COLUMN` /// (ADR-0035 §4e). Mirrors the SQL `CREATE TABLE` per-column extraction /// for one column: DEFAULT/CHECK are captured as **raw text** by byte /// span (`sql_expr` builds no AST — 4a.2), so the executor consumes /// `default_sql`/`check_sql`. fn build_alter_add_column_spec( path: &MatchedPath, source: &str, ) -> Result { let mut spec: Option = None; let mut pending_name: Option = 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 = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown type".to_string())], })?; let name = pending_name.take().ok_or_else(sql_col_type_without_name)?; spec = Some(ColumnSpec::new(name, ty)); } MatchedKind::Word("double") => { if matches!( items.peek().map(|i| &i.kind), Some(MatchedKind::Word("precision")) ) { items.next(); } let name = pending_name.take().ok_or_else(sql_col_type_without_name)?; spec = Some(ColumnSpec::new(name, Type::Real)); } MatchedKind::Word("not") => { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) { items.next(); if let Some(s) = spec.as_mut() { s.not_null = true; } } } MatchedKind::Word("unique") => { if let Some(s) = spec.as_mut() { s.unique = true; } } MatchedKind::Word("default") => { if let Some((start, end)) = capture_expr_span(&mut items) && let Some(s) = spec.as_mut() { s.default_sql = Some(source[start..end].trim().to_string()); } } MatchedKind::Word("check") => { if let Some((start, end)) = capture_parenthesised_span(&mut items) && let Some(s) = spec.as_mut() { s.check_sql = Some(source[start..end].trim().to_string()); } } _ => {} } } spec.ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "add column needs a name and type".to_string())], }) } /// Extract the `ALTER COLUMN TYPE ` action (ADR-0035 §4f). /// The type slot reuses SQL_TYPE, so the target-type extraction mirrors /// `build_alter_add_column_spec`'s: a `col_type` ident via /// `Type::from_sql_name` (alias map applied), or the two-word /// `double precision` → `Type::Real`. fn build_alter_column_type(path: &MatchedPath) -> Result { let column = require_ident(path, "column_name")?; let mut ty: Option = None; let mut items = path.items.iter().peekable(); while let Some(item) = items.next() { match &item.kind { MatchedKind::Ident { role: "col_type", .. } => { ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown type".to_string())], })?); } MatchedKind::Word("double") => { if matches!( items.peek().map(|i| &i.kind), Some(MatchedKind::Word("precision")) ) { items.next(); } ty = Some(Type::Real); } _ => {} } } let ty = ty.ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "alter column needs a target type".to_string())], })?; Ok(AlterTableAction::AlterColumnType { column, ty }) } /// Build an `ALTER COLUMN SET/DROP NOT NULL` / `SET/DROP DEFAULT` /// action (ADR-0035 Amendment 2). `SET DATA TYPE` is handled by the /// `type`-keyword branch, so this only sees the four constraint forms. /// The 2×2 is decided by `set` (vs drop) and `default` (vs not null); /// `SET DEFAULT`'s value is captured as raw `sql_expr` text by span, /// reusing `build_alter_add_column_spec`'s mechanism (no AST — 4a.2). fn build_alter_column_attr( path: &MatchedPath, source: &str, ) -> Result { let column = require_ident(path, "column_name")?; let is_set = path.contains_word("set"); let is_default = path.contains_word("default"); Ok(match (is_set, is_default) { (true, true) => { let mut items = path.items.iter().peekable(); let mut default_sql: Option = None; while let Some(item) = items.next() { if matches!(&item.kind, MatchedKind::Word("default")) && let Some((start, end)) = capture_expr_span(&mut items) { default_sql = Some(source[start..end].trim().to_string()); } } let default_sql = default_sql.ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "set default needs a value".to_string())], })?; AlterTableAction::SetColumnDefault { column, default_sql } } (false, true) => AlterTableAction::DropColumnDefault { column }, (true, false) => AlterTableAction::SetColumnNotNull { column }, (false, false) => AlterTableAction::DropColumnNotNull { column }, }) } /// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f/§4g). Exactly one /// action `Choice` branch matched; the builder recovers which from the /// matched words. Discrimination order matters: /// /// 1. **`type`** first — unique to ALTER COLUMN TYPE (ADD COLUMN's type /// is a `col_type` *ident*, not the literal word), and an `alter /// column …` input also contains `column`, so it must be caught /// before the column branch. /// 2. **`column`** — the column ops (add/drop/rename column), routed by /// `add`/`rename`/else-drop. Checked before the bare `add`/`drop` /// keywords so `add column … unique`/`… check` (a column constraint) /// still routes to AddColumn. /// 3. **`add`** — a table-level constraint (CHECK / UNIQUE / FK / the /// refused PRIMARY KEY). /// 4. **`rename`** — `rename to ` (table rename, 4h). Reached only /// when `column` is absent (caught by step 2), so a lone `rename` /// means the table form. The new name binds a *distinct* role /// (`new_table_name`), so it never collides with the `table_name` /// target slot. /// 5. else **`drop`** — `drop constraint `. fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result { let table = require_ident(path, "table_name")?; let action = if path.contains_word("type") { // covers `TYPE ` and the ISO synonym `SET DATA TYPE ` build_alter_column_type(path)? } else if path.contains_word("column") && !path.contains_word("add") && !path.contains_word("rename") && (path.contains_word("set") || path.contains_word("default") || (path.contains_word("not") && path.contains_word("null"))) { // ADR-0035 Amendment 2: `ALTER COLUMN SET/DROP NOT NULL` and // `SET/DROP DEFAULT`. `add column … not null/default` is excluded // by `!add`; plain `drop column` lacks the attribute markers. build_alter_column_attr(path, source)? } else if path.contains_word("column") { if path.contains_word("add") { AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?)) } else if path.contains_word("rename") { AlterTableAction::RenameColumn { old: require_ident(path, "column_name")?, new: require_ident(path, "new_column_name")?, } } else { AlterTableAction::DropColumn { column: require_ident(path, "column_name")?, } } } else if path.contains_word("add") { build_alter_add_table_constraint(path, source)? } else if path.contains_word("rename") { AlterTableAction::RenameTable { new: require_ident(path, "new_table_name")?, } } else { AlterTableAction::DropConstraint { name: require_ident(path, "constraint_name")?, } }; Ok(Command::SqlAlterTable { table, action }) } /// Build the `ADD [CONSTRAINT ] (CHECK | UNIQUE | FOREIGN KEY | …)` /// action (ADR-0035 §4g). The body is discriminated by its leading /// concrete keyword. The optional `CONSTRAINT ` prefix becomes the /// action-level `name` (used by CHECK + FK at execution; refused on /// UNIQUE). `ADD PRIMARY KEY` parses (for a clean message) but is /// refused — every playground table already has a PK. fn build_alter_add_table_constraint( path: &MatchedPath, source: &str, ) -> Result { let name = ident(path, "constraint_name").map(str::to_string); if path.contains_word("primary") { return Err(ValidationError { message_key: "parse.custom.alter_add_primary_key", args: vec![], }); } let constraint = if path.contains_word("check") { TableConstraint::Check { expr_sql: capture_table_check_sql(path, source)?, } } else if path.contains_word("unique") { if name.is_some() { return Err(ValidationError { message_key: "parse.custom.alter_named_unique", args: vec![], }); } TableConstraint::Unique { columns: collect_idents(path, "unique_column"), } } else { // FOREIGN KEY — the §4g name lives at the action level, so the // FK body itself is parsed unnamed. TableConstraint::ForeignKey(build_alter_fk(path)) }; Ok(AlterTableAction::AddTableConstraint { name, constraint: Box::new(constraint), }) } /// Capture the raw SQL text of an `ADD … CHECK ()` (ADR-0035 §4g). /// `sql_expr` is validate-only, so the expression is captured by byte /// span — the 4a.2 / 4e mechanism. fn capture_table_check_sql( path: &MatchedPath, source: &str, ) -> Result { let mut items = path.items.iter().peekable(); while let Some(item) = items.next() { if matches!(item.kind, MatchedKind::Word("check")) && let Some((start, end)) = capture_parenthesised_span(&mut items) { return Ok(source[start..end].trim().to_string()); } } Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "add check needs an expression".to_string())], }) } /// Build the `SqlForeignKey` for an `ADD [CONSTRAINT ] FOREIGN KEY /// () REFERENCES

[(

)] [ON …]` (ADR-0035 §4g). Mirrors the /// table-level FK walk in `build_sql_create_table`, reusing /// `consume_fk_reference`. The name is supplied at the action level (so /// the FK is parsed unnamed here). fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey { let mut items = path.items.iter().peekable(); // Advance to the `foreign` keyword. while items .peek() .is_some_and(|it| !matches!(it.kind, MatchedKind::Word("foreign"))) { items.next(); } items.next(); // `foreign` if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) { items.next(); } if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) { items.next(); } // `( [, ]* )` — compound FK (ADR-0043). let mut child_columns = Vec::new(); while let Some(it) = items.peek() { match &it.kind { MatchedKind::Punct(')') => break, MatchedKind::Punct(',') => { items.next(); } _ => child_columns.push(items.next().expect("peeked").text.clone()), } } if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) { items.next(); } if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { items.next(); } // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form. consume_fk_reference(&mut items, None, child_columns, false) } pub static SQL_ALTER_TABLE: CommandNode = CommandNode { entry: Word::keyword("alter"), shape: SQL_ALTER_TABLE_SHAPE, ast_builder: build_sql_alter_table, help_id: Some("ddl.sql_alter_table"), hint_id: None, usage_ids: &["parse.usage.sql_alter_table"], }; // ================================================================= // Tests — `create table` column constraints (ADR-0029 §2.1, §9) // ================================================================= #[cfg(test)] mod constraint_tests { use super::{Command, Constraint, ConstraintKind}; 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 { 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())); } #[test] fn add_column_parses_its_constraint_suffix() { match parse_command("add column to T: tier (int) not null default 0") .expect("add column should parse") { Command::AddColumn { not_null, unique, default, .. } => { assert!(not_null); assert!(!unique); assert_eq!(default, Some(Value::Number("0".to_string()))); } other => panic!("expected AddColumn, got {other:?}"), } } #[test] fn add_column_parses_a_unique_constraint() { match parse_command("add column to T: email (text) unique").expect("parse") { Command::AddColumn { unique, not_null, .. } => { assert!(unique); assert!(!not_null); } other => panic!("expected AddColumn, got {other:?}"), } } #[test] fn create_table_parses_a_check_constraint() { let cols = create_columns("create table T with pk age(int) check (age >= 0)"); assert_eq!(cols.len(), 1); assert!(cols[0].check.is_some(), "the column carries a CHECK"); } #[test] fn add_column_parses_a_check_constraint() { match parse_command("add column to T: age (int) check (age >= 0 and age < 150)") .expect("parse") { Command::AddColumn { check, .. } => { assert!(check.is_some(), "the column carries a CHECK"); } other => panic!("expected AddColumn, got {other:?}"), } } #[test] fn check_with_a_parenthesised_sub_expression_parses() { // The check's own parens plus a nested group — the // builder's paren-depth scan must pair them correctly. let cols = create_columns( "create table T with pk n(int) check ((n > 0) or (n < -10))", ); assert!(cols[0].check.is_some()); } // --- `add constraint` / `drop constraint` (ADR-0029 §2.2) --- #[test] fn add_constraint_not_null_parses() { match parse_command("add constraint not null to Users.email").expect("parse") { Command::AddConstraint { table, column, constraint, } => { assert_eq!(table, "Users"); assert_eq!(column, "email"); assert_eq!(constraint, Constraint::NotNull); } other => panic!("expected AddConstraint, got {other:?}"), } } #[test] fn add_constraint_unique_parses() { match parse_command("add constraint unique to Users.email").expect("parse") { Command::AddConstraint { constraint, .. } => { assert_eq!(constraint, Constraint::Unique); } other => panic!("expected AddConstraint, got {other:?}"), } } #[test] fn add_constraint_default_parses() { match parse_command("add constraint default 18 to Users.age").expect("parse") { Command::AddConstraint { constraint, .. } => { assert_eq!( constraint, Constraint::Default(Value::Number("18".to_string())) ); } other => panic!("expected AddConstraint, got {other:?}"), } } #[test] fn add_constraint_check_parses() { match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") { Command::AddConstraint { column, constraint, .. } => { assert_eq!(column, "age"); assert!(matches!(constraint, Constraint::Check(_))); } other => panic!("expected AddConstraint, got {other:?}"), } } #[test] fn drop_constraint_not_null_parses() { match parse_command("drop constraint not null from Users.email").expect("parse") { Command::DropConstraint { table, column, kind, } => { assert_eq!(table, "Users"); assert_eq!(column, "email"); assert_eq!(kind, ConstraintKind::NotNull); } other => panic!("expected DropConstraint, got {other:?}"), } } #[test] fn drop_constraint_each_kind_parses() { for (input, expected) in [ ("drop constraint unique from T.c", ConstraintKind::Unique), ("drop constraint default from T.c", ConstraintKind::Default), ("drop constraint check from T.c", ConstraintKind::Check), ] { match parse_command(input).expect("parse") { Command::DropConstraint { kind, .. } => assert_eq!(kind, expected), other => panic!("expected DropConstraint for {input:?}, got {other:?}"), } } } } // ================================================================= // Tests — advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c) // ================================================================= #[cfg(test)] mod sql_drop_table_tests { use crate::dsl::command::Command; use crate::dsl::parser::parse_command_in_mode; use crate::mode::Mode; fn drop_fields(input: &str) -> (String, bool) { match parse_command_in_mode(input, Mode::Advanced).expect("should parse") { Command::SqlDropTable { name, if_exists } => (name, if_exists), other => panic!("expected SqlDropTable, got {other:?}"), } } #[test] fn drop_table_parses_as_sql_drop_table_in_advanced_mode() { let (name, if_exists) = drop_fields("drop table Orders"); assert_eq!(name, "Orders"); assert!(!if_exists); } #[test] fn if_exists_sets_the_flag() { let (name, if_exists) = drop_fields("drop table if exists Orders"); assert_eq!(name, "Orders"); assert!(if_exists); // trailing semicolon tolerated assert!(drop_fields("drop table if exists Orders;").1); } #[test] fn simple_drop_table_in_simple_mode_is_the_dsl_command() { // In simple mode the SQL node is gated; `drop table T` is the // simple `DropTable` (which has no `if_exists`). match parse_command_in_mode("drop table Orders", Mode::Simple).expect("parses") { Command::DropTable { name } => assert_eq!(name, "Orders"), other => panic!("expected DropTable, got {other:?}"), } } #[test] fn other_drops_fall_back_to_the_simple_node_in_advanced_mode() { // `drop column` / `drop relationship` are not SQL DROP TABLE — // they fall through to the simple `drop` node even in advanced. assert!(matches!( parse_command_in_mode("drop column from Orders: note", Mode::Advanced).expect("parses"), Command::DropColumn { .. } )); assert!(matches!( parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced) .expect("parses"), Command::DropRelationship { .. } )); } } #[cfg(test)] mod sql_drop_index_tests { use crate::dsl::command::{Command, IndexSelector}; use crate::dsl::parser::parse_command_in_mode; use crate::mode::Mode; fn drop_index_fields(input: &str) -> (String, bool) { match parse_command_in_mode(input, Mode::Advanced).expect("should parse") { Command::SqlDropIndex { name, if_exists } => (name, if_exists), other => panic!("expected SqlDropIndex, got {other:?}"), } } #[test] fn drop_index_parses_as_sql_drop_index_in_advanced_mode() { let (name, if_exists) = drop_index_fields("drop index Orders_CustId_idx"); assert_eq!(name, "Orders_CustId_idx"); assert!(!if_exists); } #[test] fn if_exists_sets_the_flag() { let (name, if_exists) = drop_index_fields("drop index if exists ix"); assert_eq!(name, "ix"); assert!(if_exists); // trailing semicolon tolerated assert!(drop_index_fields("drop index if exists ix;").1); } #[test] fn drop_table_and_drop_index_each_dispatch_to_the_right_advanced_node() { // `drop` now has *two* advanced nodes (SQL_DROP_TABLE + // SQL_DROP_INDEX); the dispatcher must try both and pick the // shape that matches (ADR-0035 §4d — the second-advanced-node // case). assert!(matches!( parse_command_in_mode("drop table Orders", Mode::Advanced).expect("parses"), Command::SqlDropTable { .. } )); assert!(matches!( parse_command_in_mode("drop index ix", Mode::Advanced).expect("parses"), Command::SqlDropIndex { .. } )); } #[test] fn positional_drop_index_falls_back_to_the_simple_node_in_advanced_mode() { // The SQL form is name-only; `drop index on T (cols)` is the // simple positional form. The name-only SQL shape can't fully // match it (trailing `(cols)`), so it falls back to the simple // `drop` node's `DropIndex { Columns }` even in advanced mode. match parse_command_in_mode("drop index on Orders (CustId)", Mode::Advanced) .expect("parses") { Command::DropIndex { selector: IndexSelector::Columns { table, columns }, } => { assert_eq!(table, "Orders"); assert_eq!(columns, vec!["CustId".to_string()]); } other => panic!("expected positional DropIndex, got {other:?}"), } } #[test] fn named_drop_index_in_simple_mode_is_the_dsl_command() { // In simple mode the SQL node is gated; `drop index ix` is the // simple `DropIndex { Named }`. match parse_command_in_mode("drop index ix", Mode::Simple).expect("parses") { Command::DropIndex { selector: IndexSelector::Named { name }, } => assert_eq!(name, "ix"), other => panic!("expected named DropIndex, got {other:?}"), } } } #[cfg(test)] mod sql_create_index_tests { use crate::dsl::command::Command; use crate::dsl::parser::parse_command_in_mode; use crate::mode::Mode; struct Ci { name: Option, table: String, columns: Vec, unique: bool, if_not_exists: bool, } fn ci(input: &str) -> Ci { match parse_command_in_mode(input, Mode::Advanced).expect("should parse") { Command::SqlCreateIndex { name, table, columns, unique, if_not_exists, } => Ci { name, table, columns, unique, if_not_exists }, other => panic!("expected SqlCreateIndex, got {other:?}"), } } #[test] fn named_create_index_parses() { let c = ci("create index ix on Customers (email)"); assert_eq!(c.name.as_deref(), Some("ix")); assert_eq!(c.table, "Customers"); assert_eq!(c.columns, vec!["email".to_string()]); assert!(!c.unique); assert!(!c.if_not_exists); } #[test] fn unnamed_create_index_leaves_name_none() { // The unnamed form: the optional name must NOT swallow `on` // (the `DI_SELECTOR`-style on-led-first selector handles it). let c = ci("create index on Customers (email)"); assert_eq!(c.name, None); assert_eq!(c.table, "Customers"); assert_eq!(c.columns, vec!["email".to_string()]); } #[test] fn unique_sets_the_flag() { let c = ci("create unique index ux on Customers (email)"); assert!(c.unique); assert_eq!(c.name.as_deref(), Some("ux")); // unnamed unique form too let c2 = ci("create unique index on Customers (email)"); assert!(c2.unique); assert_eq!(c2.name, None); } #[test] fn if_not_exists_sets_the_flag() { let c = ci("create index if not exists ix on Customers (email)"); assert!(c.if_not_exists); assert_eq!(c.name.as_deref(), Some("ix")); // combined with unique + unnamed + trailing semicolon let c2 = ci("create unique index if not exists on Customers (email);"); assert!(c2.unique && c2.if_not_exists); assert_eq!(c2.name, None); } #[test] fn multi_column_index_parses() { let c = ci("create index on Orders (CustId, Date)"); assert_eq!(c.columns, vec!["CustId".to_string(), "Date".to_string()]); } #[test] fn create_table_and_create_index_each_dispatch_to_the_right_advanced_node() { // `create` now has *two* advanced nodes (SQL_CREATE_TABLE + // SQL_CREATE_INDEX); the dispatcher must try both (ADR-0035 §4d). assert!(matches!( parse_command_in_mode("create table T (id int primary key)", Mode::Advanced) .expect("parses"), Command::SqlCreateTable { .. } )); assert!(matches!( parse_command_in_mode("create index ix on T (id)", Mode::Advanced).expect("parses"), Command::SqlCreateIndex { .. } )); assert!(matches!( parse_command_in_mode("create unique index ux on T (id)", Mode::Advanced) .expect("parses"), Command::SqlCreateIndex { unique: true, .. } )); } #[test] fn simple_create_table_dsl_still_parses_in_advanced_mode() { // The `create table … with pk …` DSL form falls back to the // simple node even with two advanced `create` nodes present. assert!(matches!( parse_command_in_mode("create table T with pk id(serial)", Mode::Advanced) .expect("parses"), Command::CreateTable { .. } )); } } #[cfg(test)] mod sql_alter_table_tests { use crate::dsl::command::{AlterTableAction, ColumnSpec, Command, TableConstraint}; use crate::dsl::parser::parse_command_in_mode; use crate::mode::Mode; fn alter(input: &str) -> (String, AlterTableAction) { match parse_command_in_mode(input, Mode::Advanced).expect("should parse") { Command::SqlAlterTable { table, action } => (table, action), other => panic!("expected SqlAlterTable, got {other:?}"), } } fn added_spec(input: &str) -> ColumnSpec { match alter(input).1 { AlterTableAction::AddColumn(spec) => *spec, other => panic!("expected AddColumn, got {other:?}"), } } #[test] fn add_column_plain() { let (table, action) = alter("alter table T add column note text"); assert_eq!(table, "T"); match action { AlterTableAction::AddColumn(spec) => { assert_eq!(spec.name, "note"); assert_eq!(spec.ty, crate::dsl::types::Type::Text); assert!(!spec.not_null && !spec.unique); assert!(spec.default_sql.is_none() && spec.check_sql.is_none()); } other => panic!("expected AddColumn, got {other:?}"), } } #[test] fn add_column_with_not_null_and_unique() { let spec = added_spec("alter table T add column code text not null unique"); assert!(spec.not_null && spec.unique); } #[test] fn add_column_with_default_and_check_capture_raw_text() { // DEFAULT / CHECK are captured as raw SQL text (sql_expr is // validate-only) — ADR-0035 §4e. let spec = added_spec("alter table T add column qty int default 0 check (qty >= 0)"); assert_eq!(spec.default_sql.as_deref(), Some("0")); assert_eq!(spec.check_sql.as_deref(), Some("qty >= 0")); } #[test] fn add_column_accepts_sql_type_alias() { // `varchar(255)` → text, length discarded (ADR-0035 §3). let spec = added_spec("alter table T add column name varchar(255)"); assert_eq!(spec.ty, crate::dsl::types::Type::Text); } #[test] fn drop_column() { match alter("alter table T drop column note").1 { AlterTableAction::DropColumn { column } => assert_eq!(column, "note"), other => panic!("expected DropColumn, got {other:?}"), } } #[test] fn rename_column() { match alter("alter table T rename column a to b").1 { AlterTableAction::RenameColumn { old, new } => { assert_eq!(old, "a"); assert_eq!(new, "b"); } other => panic!("expected RenameColumn, got {other:?}"), } // trailing semicolon tolerated assert!(matches!( alter("alter table T rename column a to b;").1, AlterTableAction::RenameColumn { .. } )); } #[test] fn rename_table() { // ADR-0035 §6 / 4h: `rename to ` — the `rename` verb fans out // on a distinct second keyword (`to` vs `column`). let (table, action) = alter("alter table Orders rename to Purchases"); assert_eq!(table, "Orders"); match action { AlterTableAction::RenameTable { new } => assert_eq!(new, "Purchases"), other => panic!("expected RenameTable, got {other:?}"), } // trailing semicolon tolerated assert!(matches!( alter("alter table Orders rename to Purchases;").1, AlterTableAction::RenameTable { .. } )); } #[test] fn rename_table_does_not_steal_rename_column() { // The two `rename` tails coexist: `rename to` → table, // `rename column … to …` → column. Neither misroutes. assert!(matches!( alter("alter table T rename to U").1, AlterTableAction::RenameTable { .. } )); assert!(matches!( alter("alter table T rename column a to b").1, AlterTableAction::RenameColumn { .. } )); } #[test] fn rename_to_internal_target_refused_at_parse() { // The target slot carries the `reject_internal_table` validator // (mirroring CREATE TABLE), so an `__rdbms_*` target is refused // before submit — engine-neutral, not a raw engine error. assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()); } #[test] fn alter_column_type_parses() { // ADR-0035 §4f: the fourth action, discriminated by the `type` // keyword (ADD COLUMN's type is an ident, not the literal word). let (table, action) = alter("alter table T alter column qty type int"); assert_eq!(table, "T"); match action { AlterTableAction::AlterColumnType { column, ty } => { assert_eq!(column, "qty"); assert_eq!(ty, crate::dsl::types::Type::Int); } other => panic!("expected AlterColumnType, got {other:?}"), } // trailing semicolon tolerated assert!(matches!( alter("alter table T alter column qty type int;").1, AlterTableAction::AlterColumnType { .. } )); } #[test] fn alter_column_type_accepts_sql_type_alias() { // `integer` → int, `double precision` → real (ADR-0035 §3), // reusing SQL_TYPE for the type slot. match alter("alter table T alter column n type integer").1 { AlterTableAction::AlterColumnType { ty, .. } => { assert_eq!(ty, crate::dsl::types::Type::Int); } other => panic!("expected AlterColumnType, got {other:?}"), } match alter("alter table T alter column n type double precision").1 { AlterTableAction::AlterColumnType { ty, .. } => { assert_eq!(ty, crate::dsl::types::Type::Real); } other => panic!("expected AlterColumnType, got {other:?}"), } } #[test] fn four_branch_dispatch_still_routes_the_column_actions() { // The new `alter column type` branch does not steal add/drop/ // rename: each still routes to its own action. assert!(matches!( alter("alter table T add column note text").1, AlterTableAction::AddColumn(_) )); assert!(matches!( alter("alter table T drop column note").1, AlterTableAction::DropColumn { .. } )); assert!(matches!( alter("alter table T rename column a to b").1, AlterTableAction::RenameColumn { .. } )); assert!(matches!( alter("alter table T alter column a type text").1, AlterTableAction::AlterColumnType { .. } )); } // --- ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill --- #[test] fn alter_column_set_data_type_is_a_type_synonym() { // ISO `SET DATA TYPE` is the canonical form; it yields the same // AlterColumnType action as the PostgreSQL `TYPE` shorthand. match alter("alter table T alter column qty set data type int").1 { AlterTableAction::AlterColumnType { column, ty } => { assert_eq!(column, "qty"); assert_eq!(ty, crate::dsl::types::Type::Int); } other => panic!("expected AlterColumnType, got {other:?}"), } // alias map still applies through the synonym assert!(matches!( alter("alter table T alter column n set data type double precision").1, AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. } )); } #[test] fn alter_column_set_not_null_parses() { let (table, action) = alter("alter table T alter column email set not null"); assert_eq!(table, "T"); match action { AlterTableAction::SetColumnNotNull { column } => assert_eq!(column, "email"), other => panic!("expected SetColumnNotNull, got {other:?}"), } } #[test] fn alter_column_drop_not_null_parses() { match alter("alter table T alter column email drop not null").1 { AlterTableAction::DropColumnNotNull { column } => assert_eq!(column, "email"), other => panic!("expected DropColumnNotNull, got {other:?}"), } } #[test] fn alter_column_set_default_captures_raw_expr() { match alter("alter table T alter column qty set default 0").1 { AlterTableAction::SetColumnDefault { column, default_sql } => { assert_eq!(column, "qty"); assert_eq!(default_sql, "0"); } other => panic!("expected SetColumnDefault, got {other:?}"), } // a parenthesised expression default round-trips as raw text match alter("alter table T alter column qty set default (1 + 1)").1 { AlterTableAction::SetColumnDefault { default_sql, .. } => { assert_eq!(default_sql, "(1 + 1)"); } other => panic!("expected SetColumnDefault, got {other:?}"), } } #[test] fn alter_column_drop_default_parses() { match alter("alter table T alter column qty drop default").1 { AlterTableAction::DropColumnDefault { column } => assert_eq!(column, "qty"), other => panic!("expected DropColumnDefault, got {other:?}"), } } #[test] fn alter_column_gap_fill_does_not_steal_the_existing_actions() { // The new set/drop column-attribute forms must not misroute the // top-level add/drop/rename-column or the bare `type` form. assert!(matches!( alter("alter table T add column note text not null").1, AlterTableAction::AddColumn(_) )); assert!(matches!( alter("alter table T drop column note").1, AlterTableAction::DropColumn { .. } )); assert!(matches!( alter("alter table T alter column a type text").1, AlterTableAction::AlterColumnType { .. } )); assert!(matches!( alter("alter table T drop constraint c_chk").1, AlterTableAction::DropConstraint { .. } )); } #[test] fn type_discriminator_probe_column_named_type() { // PROBE (DA): the `type`-keyword discriminator keys on the literal // `type` *keyword* node, which only the ALTER COLUMN TYPE branch // carries. Verify a column whose *name* is `type` does not get // misrouted (it is an ident, not a Word). If `type` is reserved // and rejected as an ident, the parse errors — either outcome is // fine; the failure we guard against is silent misrouting to // AlterColumnType (which would then error on a missing type). let dropped = parse_command_in_mode("alter table T drop column type", Mode::Advanced); match dropped { Ok(Command::SqlAlterTable { action: AlterTableAction::DropColumn { column }, .. }) => assert_eq!(column, "type", "a column named `type` drops correctly"), Ok(other) => panic!("`drop column type` misrouted to {other:?}"), Err(_) => { /* `type` rejected as an ident — acceptable, no misroute */ } } // And the real ALTER COLUMN TYPE still routes (sanity). assert!(matches!( parse_command_in_mode("alter table T alter column c type int", Mode::Advanced), Ok(Command::SqlAlterTable { action: AlterTableAction::AlterColumnType { .. }, .. }) )); } #[test] fn add_table_check_unnamed_and_named() { // ADR-0035 §4g: table-level CHECK, unnamed and named. match alter("alter table T add check (a < b)").1 { AlterTableAction::AddTableConstraint { name, constraint } => { assert_eq!(name, None); assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")); } other => panic!("expected AddTableConstraint/Check, got {other:?}"), } match alter("alter table T add constraint a_lt_b check (a < b)").1 { AlterTableAction::AddTableConstraint { name, constraint } => { assert_eq!(name.as_deref(), Some("a_lt_b")); assert!(matches!(*constraint, TableConstraint::Check { .. })); } other => panic!("expected named AddTableConstraint/Check, got {other:?}"), } } #[test] fn add_composite_unique() { match alter("alter table T add unique (a, b)").1 { AlterTableAction::AddTableConstraint { name, constraint } => { assert_eq!(name, None); assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])); } other => panic!("expected AddTableConstraint/Unique, got {other:?}"), } } #[test] fn named_unique_is_refused() { // §4g: composite UNIQUE is anonymous in our model — naming it is // refused by the BUILDER (it parses, then the builder rejects), // so the error is the friendly message, not a parse error. let err = parse_command_in_mode( "alter table T add constraint u unique (a, b)", Mode::Advanced, ) .expect_err("a named UNIQUE constraint is refused"); assert!( err.to_string().to_lowercase().contains("unique constraint cannot be named"), "expected the builder's named-UNIQUE refusal, got: {err}" ); } #[test] fn add_primary_key_is_refused() { // §4g: adding a PK to an existing table is refused by the BUILDER // (it parses for a clean message, then the builder rejects it). let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced) .expect_err("ADD PRIMARY KEY is refused"); assert!( err.to_string().to_lowercase().contains("primary key is fixed at creation"), "expected the builder's ADD-PRIMARY-KEY refusal, got: {err}" ); } #[test] fn add_foreign_key_named_and_bare() { // `add foreign key (col) references P(id)` and the bare // `references P` form; named via the CONSTRAINT prefix. match alter("alter table C add foreign key (pid) references P(id)").1 { AlterTableAction::AddTableConstraint { name, constraint } => { assert_eq!(name, None); match *constraint { TableConstraint::ForeignKey(fk) => { assert_eq!(fk.child_columns, vec!["pid".to_string()]); assert_eq!(fk.parent_table, "P"); assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); } other => panic!("expected ForeignKey, got {other:?}"), } } other => panic!("expected AddTableConstraint/FK, got {other:?}"), } match alter("alter table C add constraint fk_p foreign key (pid) references P").1 { AlterTableAction::AddTableConstraint { name, constraint } => { assert_eq!(name.as_deref(), Some("fk_p")); match *constraint { TableConstraint::ForeignKey(fk) => { assert_eq!(fk.parent_columns, None, "bare reference resolves at execution"); } other => panic!("expected ForeignKey, got {other:?}"), } } other => panic!("expected named AddTableConstraint/FK, got {other:?}"), } } #[test] fn drop_constraint_by_name() { match alter("alter table T drop constraint a_lt_b").1 { AlterTableAction::DropConstraint { name } => assert_eq!(name, "a_lt_b"), other => panic!("expected DropConstraint, got {other:?}"), } } #[test] fn six_branch_dispatch_still_routes_column_actions() { // The two new add/drop-constraint branches do not steal the four // column actions. assert!(matches!( alter("alter table T add column note text").1, AlterTableAction::AddColumn(_) )); assert!(matches!( alter("alter table T add column code text unique").1, AlterTableAction::AddColumn(_), )); assert!(matches!( alter("alter table T drop column note").1, AlterTableAction::DropColumn { .. } )); assert!(matches!( alter("alter table T rename column a to b").1, AlterTableAction::RenameColumn { .. } )); assert!(matches!( alter("alter table T alter column a type text").1, AlterTableAction::AlterColumnType { .. } )); } #[test] fn alter_is_advanced_only() { // No simple `alter`; in simple mode it does not parse as a // command (the dispatcher emits the "this is SQL" hint). assert!(parse_command_in_mode("alter table T drop column c", Mode::Simple).is_err()); } #[test] fn internal_table_is_rejected_at_parse() { // The ALTER table slot carries `reject_internal_table`. assert!( parse_command_in_mode( "alter table __rdbms_playground_columns drop column table_name", Mode::Advanced ) .is_err() ); } }