Files
rdbms-playground/src/dsl/grammar/ddl.rs
T
claude@clouddev1 5b76315d1e feat: ADR-0035 4f — ALTER TABLE … ALTER COLUMN TYPE
Fourth AlterTableAction (AlterColumnType), runtime-decomposed to the
existing change_column_type executor with ForceConversion — which IS the
§7 advanced policy: lossy converts with a note (no force flag),
incompatible + the ADR-0017 static refusals (↔blob, same-type,
date↔datetime, non-int→serial) still refuse, while int→serial is allowed
(auto-fills nulls + UNIQUE, ADR-0018 §8). No new mode/note/persistence;
undo is the advanced safety net.

Grammar adds a fourth action branch leading on `alter`, discriminated in
the builder by the `type` keyword (unique — ADD COLUMN's type is an
ident); the type slot reuses SQL_TYPE. The internal-__rdbms_* guard was
folded into do_change_column_type (user-confirmed), closing the simple
`change column` exposure.

Tests: 7 Tier-3 e2e via run_replay + 4 Tier-1 parse (incl. a column-named-
`type` discriminator probe) + the simple-surface guard. Help/usage
refreshed; ADR-0035 §13 4f + README + requirements.md in lockstep.
2026-05-25 21:16:37 +00:00

2718 lines
101 KiB
Rust

//! DDL command nodes (ADR-0024 §migration Phase B).
//!
//! Five commands at four entry words: `drop` (drop table /
//! drop column / drop relationship), `add` (add column /
//! add 1:n relationship), `rename` (rename column), `change`
//! (change column). The chumsky-side declarations stay
//! reachable for any input the walker doesn't engage on, but
//! for these entry words the walker is authoritative.
//!
//! Each shape is laid out inline so per-use-site `role`
//! annotations carry meaning end-to-end (e.g.,
//! `parent_table` vs `child_table` for the endpoints clause).
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
/// "Type a name [then …]" prose rather than offering schema
/// candidates (ADR-0024 §HintMode-per-node).
const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name");
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::{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>` table's columns
// because that ident carries `writes_table: true`.
const INDEX_COLUMN_LIST: Node = Node::Repeated {
inner: &COLUMN_NAME,
separator: Some(&Node::Punct(',')),
min: 1,
};
// `[to]` and `[table]` connectives.
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from")));
const IN_OPT: Node = Node::Optional(&Node::Word(Word::keyword("in")));
const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// =================================================================
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (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] <name> [;]` (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 <absent>`
// 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] <T> : <col>`
// =================================================================
// `--cascade` (ADR-0025): opt-in to dropping any index that
// covers the column alongside the column itself. Without it, a
// covered column is refused with a friendly error.
const DROP_COLUMN_CASCADE_OPT: Node = Node::Optional(&Node::Flag("cascade"));
const DROP_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
FROM_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME,
DROP_COLUMN_CASCADE_OPT,
];
const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES);
// =================================================================
// drop_relationship — `drop relationship (endpoints | name)`
// =================================================================
// `writes_table: true` on each endpoint's table ident so the
// `.<col>` slot that follows narrows to that table's columns
// (handoff-13 §2.2 follow-up). The two endpoints are walked
// sequentially, so `current_table` is correctly the parent
// table while walking `parent_column` and the child table
// while walking `child_column`.
const DR_PARENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "parent_table",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
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 (<name> | on <T> (<col>, …))`
// =================================================================
const DI_POSITIONAL_NODES: &[Node] = &[
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
// Positional form first — it opens with the `on` keyword, so a
// bare index name can't be mistaken for it (mirrors DR_SELECTOR).
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] =
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
// add_column — `add column [to] [table] <T> : <col> ( <type> )`
// =================================================================
const ADD_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
TO_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME_NEW,
Node::Punct('('),
TYPE_SLOT,
Node::Punct(')'),
// 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 <name>]
// from <T>.<c> to <T>.<c>
// [on delete <a>] [on update <a>]
// [--create-fk]`
// =================================================================
// `writes_table: true` on each endpoint's table ident so the
// `.<col>` slot narrows to that table's columns (handoff-13
// §2.2 follow-up — mirrors DR_PARENT / DR_CHILD).
const AR_PARENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "parent_table",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
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 AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
const AR_CHILD_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "child_table",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
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 AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
const ADD_RELATIONSHIP_NODES: &[Node] = &[
Node::Literal("1"),
Node::Punct(':'),
Node::Word(Word::keyword("n")),
Node::Word(Word::keyword("relationship")),
AR_AS_NAME_OPT,
Node::Word(Word::keyword("from")),
AR_PARENT,
Node::Word(Word::keyword("to")),
AR_CHILD,
REFERENTIAL_CLAUSES,
AR_CREATE_FK_OPT,
];
const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
// =================================================================
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================
const AI_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
AI_AS_NAME_OPT,
Node::Word(Word::keyword("on")),
TABLE_NAME_EXISTING,
Node::Punct('('),
INDEX_COLUMN_LIST,
Node::Punct(')'),
];
const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES);
// =================================================================
// add entry — `add (column|1:n relationship|index) …`
// =================================================================
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX, ADD_CONSTRAINT];
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);
// =================================================================
// rename_column — `rename column [in] [table] <T> : <col> to <new>`
// =================================================================
const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "new_column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
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] <T> : <col>
// ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None,
min: 0,
};
const CHANGE_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
IN_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME,
Node::Punct('('),
TYPE_SLOT,
Node::Punct(')'),
CHANGE_FLAG_OPT,
];
const CHANGE_COLUMN: Node = Node::Seq(CHANGE_COLUMN_NODES);
// =================================================================
// AST builders
// =================================================================
/// First ident whose role matches.
fn ident<'a>(path: &'a MatchedPath, role: &str) -> Option<&'a str> {
path.items.iter().find_map(|i| match &i.kind {
MatchedKind::Ident { role: r, .. } if *r == role => Some(i.text.as_str()),
_ => None,
})
}
fn require_ident(path: &MatchedPath, role: &'static str) -> Result<String, ValidationError> {
ident(path, role)
.map(str::to_string)
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", format!("missing {role}"))],
})
}
/// Every ident whose role matches, in matched (left-to-right)
/// order. Used by the column-list commands.
fn collect_idents(path: &MatchedPath, role: &str) -> Vec<String> {
path.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Ident { role: r, .. } if *r == role => Some(i.text.clone()),
_ => None,
})
.collect()
}
fn parse_action(words: &[&'static str]) -> ReferentialAction {
// `set null`, `no action`, `cascade`, `restrict`.
if words.contains(&"set") && words.contains(&"null") {
ReferentialAction::SetNull
} else if words.contains(&"no") && words.contains(&"action") {
ReferentialAction::NoAction
} else if words.contains(&"cascade") {
ReferentialAction::Cascade
} else if words.contains(&"restrict") {
ReferentialAction::Restrict
} else {
ReferentialAction::default_action()
}
}
fn build_drop(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
// Discriminate by the second word matched (the entry was
// `drop`, the next Word is `table` / `column` / `relationship`).
let sub = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.nth(1);
match sub {
Some("table") => Ok(Command::DropTable {
name: require_ident(path, "table_name")?,
}),
Some("column") => Ok(Command::DropColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
cascade: path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Flag("cascade"))),
}),
Some("index") => {
// Positional form has `on` as the third Word.
let has_on = path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Word("on")));
if has_on {
Ok(Command::DropIndex {
selector: IndexSelector::Columns {
table: require_ident(path, "table_name")?,
columns: collect_idents(path, "column_name"),
},
})
} else {
Ok(Command::DropIndex {
selector: IndexSelector::Named {
name: require_ident(path, "index_name")?,
},
})
}
}
Some("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<Command, ValidationError> {
// Second matched Word distinguishes column vs the `1:n
// relationship` form. The `1` literal counts as a Word
// (the walker records Literal matches as MatchedKind::Word
// for AST-builder uniformity).
let second_word = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.nth(1);
match second_word {
Some("column") => {
let ty_text = require_ident(path, "type")?;
let ty = ty_text
.parse::<crate::dsl::types::Type>()
.map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
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<Command, ValidationError> {
// Collect all referential-clause actions in matched order
// and validate at-most-2 + not-repeated. The `on <delete|
// update> <action>` sequence shows up as a run of Word
// matches in the path between `to <child>` and end.
//
// Strategy: walk through Word items in order; whenever we
// see `on`, the next Word is the target, then the action
// word(s) follow until the next `on`, `--create-fk`, or
// end-of-path.
let words: Vec<&'static str> = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.collect();
let mut on_delete: Option<ReferentialAction> = None;
let mut on_update: Option<ReferentialAction> = None;
let mut i = 0;
while i < words.len() {
if words[i] == "on" && i + 1 < words.len() {
let target = words[i + 1];
// Action runs from i+2 until the next `on` or end.
let action_start = i + 2;
let mut action_end = action_start;
while action_end < words.len() && words[action_end] != "on" {
action_end += 1;
}
let action = parse_action(&words[action_start..action_end]);
let slot = match target {
"delete" => &mut on_delete,
"update" => &mut on_update,
_ => {
i = action_end;
continue;
}
};
if slot.is_some() {
return Err(ValidationError {
message_key: "parse.custom.on_action_specified_twice",
args: vec![("target", target.to_string())],
});
}
*slot = Some(action);
i = action_end;
} else {
i += 1;
}
}
let create_fk = path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));
Ok(Command::AddRelationship {
name: ident(path, "relationship_name").map(str::to_string),
parent_table: require_ident(path, "parent_table")?,
parent_column: require_ident(path, "parent_column")?,
child_table: require_ident(path, "child_table")?,
child_column: require_ident(path, "child_column")?,
on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
create_fk,
})
}
fn build_rename_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::RenameColumn {
table: require_ident(path, "table_name")?,
old: require_ident(path, "column_name")?,
new: require_ident(path, "new_column_name")?,
})
}
fn build_change_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let ty_text = require_ident(path, "type")?;
let ty = ty_text
.parse::<crate::dsl::types::Type>()
.map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
// Flags: at most one of --force-conversion / --dont-convert.
let flags: Vec<&'static str> = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Flag(n) => Some(*n),
_ => None,
})
.collect();
let mode = match flags.as_slice() {
[] => ChangeColumnMode::Default,
[one] => match *one {
"force-conversion" => ChangeColumnMode::ForceConversion,
"dont-convert" => ChangeColumnMode::DontConvert,
_ => ChangeColumnMode::Default,
},
_ => {
// Two or more flags — mutual exclusion fires
// whether they're the same flag twice or both
// mutually-exclusive flags appear. Wording mirrors
// the chumsky parser's `change_column_flags_exclusive`.
return Err(ValidationError {
message_key: "parse.custom.change_column_flags_exclusive",
args: vec![],
});
}
};
Ok(Command::ChangeColumnType {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
ty,
mode,
})
}
/// Build an `add constraint <constraint> to <T>.<col>` command
/// (ADR-0029 §2.2). The `<constraint>` 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<Command, ValidationError> {
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 <kind> from <T>.<col>` 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<Command, ValidationError> {
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"),
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"),
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"),
usage_ids: &["parse.usage.rename_column"],};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
shape: CHANGE_COLUMN,
ast_builder: build_change_column,
help_id: Some("ddl.change"),
usage_ids: &["parse.usage.change_column"],};
// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
// (Phase C)
// =================================================================
const COL_NAME_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "col_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
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 <literal>`, `check (<expr>)`. 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 ( <expr> )` — 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 <constraint>
// to <T>.<col>` / `drop constraint <kind> from <T>.<col>`
// (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 `<Table>.<column>` 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 `.<column>` 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: None,
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 `( <expr> )` 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<std::slice::Iter<'_, MatchedItem>>,
) -> Result<Expr, ValidationError> {
items.next(); // the opening `(`
let mut depth = 1usize;
let mut expr_items: Vec<MatchedItem> = 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<Value>, Option<Expr>), 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<Command, ValidationError> {
let name = require_ident(path, "table_name")?;
// Walk the matched items, segmenting per column: a
// `col_name` ident stashes the name, the following
// `col_type` ident finalises the spec, and the constraint
// tokens after it (ADR-0029 §2.1) attach to that spec.
let mut columns: Vec<ColumnSpec> = Vec::new();
let mut pending_name: Option<String> = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let col_name = pending_name.take().ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "column type without a name".to_string())],
})?;
columns.push(ColumnSpec::new(col_name, ty));
}
// `not null` — the grammar's `Seq` guarantees a
// `null` Word follows a matched `not` Word.
MatchedKind::Word("not") => {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
if let Some(last) = columns.last_mut() {
last.not_null = true;
}
}
}
MatchedKind::Word("unique") => {
if let Some(last) = columns.last_mut() {
last.unique = true;
}
}
// `default <literal>` — the `Seq` guarantees a value
// item follows a matched `default` Word.
MatchedKind::Word("default") => {
let value = items
.next()
.and_then(crate::dsl::grammar::data::item_to_value)
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "default needs a value".to_string())],
})?;
if let Some(last) = columns.last_mut() {
last.default = Some(value);
}
}
// `check ( <expr> )` (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"),
usage_ids: &["parse.usage.create_table"],};
/// The friendly error for a column type without a preceding name —
/// a structural impossibility given the grammar, defended anyway.
fn sql_col_type_without_name() -> ValidationError {
ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "column type without a name".to_string())],
}
}
/// Build a `Command::SqlCreateTable` from the advanced-mode SQL
/// `CREATE TABLE` shape (ADR-0035 §1, sub-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<Command, ValidationError> {
let name = require_ident(path, "table_name")?;
// `if` only appears in the `IF NOT EXISTS` prefix (the `not` of
// `NOT NULL` never carries an `if`), so its presence is the flag.
let if_not_exists = path
.items
.iter()
.any(|i| matches!(i.kind, MatchedKind::Word("if")));
let mut columns: Vec<ColumnSpec> = Vec::new();
let mut primary_key: Vec<String> = Vec::new();
let mut unique_constraints: Vec<Vec<String>> = Vec::new();
let mut check_constraints: Vec<String> = Vec::new();
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
let mut pending_name: Option<String> = 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 <name>` prefix stashes the name until the following
// table-level `FOREIGN KEY` consumes it (ADR-0035 §5, 4b).
let mut pending_fk_name: Option<String> = 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<String> = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => {
cols.push(it.text.clone());
items.next();
}
MatchedKind::Punct(',') => {
items.next();
}
MatchedKind::Punct(')') => {
items.next();
break;
}
_ => break,
}
}
// Single-column table-level UNIQUE folds into the
// column's flag (round-trips via the single-column
// path); composite (or a name not among the
// columns) becomes a constraint.
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols),
None => {}
}
} else if let Some(last) = columns.last_mut() {
last.unique = true;
}
}
// `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 <expr>` — capture the expression's raw SQL text
// by byte span (`sql_expr` builds no AST). The match is
// maximal, so the expression runs until a depth-0 element
// boundary (`,` / `)`) or the next constraint keyword.
MatchedKind::Word("default") => {
if let Some((s, e)) = capture_expr_span(&mut items)
&& let Some(last) = columns.last_mut()
{
last.default_sql = Some(source[s..e].trim().to_string());
}
}
// `check ( <expr> )` — capture the inner expression text
// (without the wrapping parens) by matching paren depth, 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 <name>` — 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 <parent> [(<col>)] [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") => {
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, child_column));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
items.next(); // `key`
}
// `( <child column> )`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next();
}
let child_column = items.next().map_or_else(String::new, |it| it.text.clone());
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
// `references <parent> …`
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_column);
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 <expr>` expression from the
/// matched-item stream (ADR-0035 §4a.2). Consumes the expression's
/// terminals (tracking paren depth) and stops *without consuming* the
/// next depth-0 element boundary (`,` / `)`) or constraint keyword
/// (`not` / `unique` / `primary` / `check`) — those terminals were
/// matched by the following constraint/element, not by the expression.
/// Returns `(start, end)` byte offsets, or `None` if no expression
/// terminal followed.
fn capture_expr_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
let mut depth = 0usize;
let mut start: Option<usize> = None;
let mut end = 0usize;
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Punct(',' | ')') if depth == 0 => break,
MatchedKind::Word("not" | "unique" | "primary" | "check") if depth == 0 => break,
_ => {
match &it.kind {
MatchedKind::Punct('(') => depth += 1,
MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
_ => {}
}
start.get_or_insert(it.span.0);
end = it.span.1;
items.next();
}
}
}
start.map(|s| (s, end))
}
/// Capture the byte span of the contents of a parenthesised group
/// (`CHECK ( <expr> )`) from the matched-item stream — the next item
/// must be the opening `(`. Consumes through the matching `)` (tracking
/// nested parens) and returns the `(start, end)` offsets of the text
/// *between* the parens, or `None` if no `(` follows.
fn capture_parenthesised_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
if !matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
return None;
}
let open = items.next()?; // '('
let inner_start = open.span.1;
let mut depth = 1usize;
let mut inner_end = inner_start;
for it in items.by_ref() {
match &it.kind {
MatchedKind::Punct('(') => depth += 1,
MatchedKind::Punct(')') => {
depth -= 1;
if depth == 0 {
inner_end = it.span.0;
break;
}
}
_ => {}
}
}
Some((inner_start, inner_end))
}
/// 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 `( <parent col> )`, and any `on <delete|update> <action>`
/// 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<I>,
name: Option<String>,
child_column: String,
) -> SqlForeignKey
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
let parent_table = items.next().map_or_else(String::new, |it| it.text.clone());
// Optional `( <parent column> )`.
let mut parent_column = None;
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next(); // `(`
if let Some(it) = items.next() {
parent_column = Some(it.text.clone());
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next(); // `)`
}
}
// `on <delete|update> <action>` 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_column,
parent_table,
parent_column,
on_delete,
on_update,
}
}
/// 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<I>) -> ReferentialAction
where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
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"),
usage_ids: &["parse.usage.sql_create_table"],
};
/// Build a `Command::SqlDropTable` from the advanced-mode SQL
/// `DROP TABLE [IF EXISTS] <name>` 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<Command, ValidationError> {
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"),
usage_ids: &["parse.usage.sql_drop_table"],
};
/// Build a `Command::SqlDropIndex` from the advanced-mode SQL
/// `DROP INDEX [IF EXISTS] <name>` 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<Command, ValidationError> {
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"),
usage_ids: &["parse.usage.sql_drop_index"],
};
// =================================================================
// SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (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(<name>)` 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] [<name>] ON <T> (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<Command, ValidationError> {
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"),
usage_ids: &["parse.usage.sql_create_index"],
};
// =================================================================
// SQL `ALTER TABLE <T> <action>` (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,
};
static AT_ADD_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("add")),
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: Node = Node::Seq(AT_ADD_COLUMN_NODES);
static AT_DROP_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("drop")),
Node::Word(Word::keyword("column")),
COLUMN_NAME,
];
const AT_DROP_COLUMN: Node = Node::Seq(AT_DROP_COLUMN_NODES);
static AT_RENAME_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("rename")),
Node::Word(Word::keyword("column")),
COLUMN_NAME,
Node::Word(Word::keyword("to")),
NEW_COLUMN_NAME,
];
const AT_RENAME_COLUMN: Node = Node::Seq(AT_RENAME_COLUMN_NODES);
// `ALTER COLUMN <col> TYPE <type>` (ADR-0035 §4f). The type slot reuses
// SQL_TYPE (the same alias map + `double precision` pair the CREATE
// TABLE / ADD COLUMN forms use). The builder keys on the `type` keyword
// — unique to this action (ADD COLUMN's type is a `col_type` ident).
static AT_ALTER_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("alter")),
Node::Word(Word::keyword("column")),
COLUMN_NAME,
Node::Word(Word::keyword("type")),
super::sql_create_table::SQL_TYPE,
];
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);
// Each action branch leads on a concrete keyword (`add`/`drop`/`rename`/
// `alter`) — trap-safe. (The branch's `alter` is the action word; the
// entry-word `alter` was already consumed by dispatch.)
static AT_ACTION_CHOICES: &[Node] =
&[AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_COLUMN, 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<ColumnSpec, ValidationError> {
let mut spec: Option<ColumnSpec> = None;
let mut pending_name: Option<String> = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
let ty = 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 <col> TYPE <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<AlterTableAction, ValidationError> {
let column = require_ident(path, "column_name")?;
let mut ty: Option<Type> = 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 `Command::SqlAlterTable` (ADR-0035 §4e/§4f). The action is the
/// leading concrete keyword (`add`/`drop`/`rename`/`alter` — exactly one
/// matches per the action `Choice`). The `type` keyword is checked
/// **first**: it is unique to ALTER COLUMN TYPE (ADD COLUMN's type is a
/// `col_type` *ident*, not the literal word), and an `alter column …`
/// input contains none of add/drop/rename, so without this it would fall
/// through to the DropColumn arm.
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?;
let action = if path.contains_word("type") {
build_alter_column_type(path)?
} else 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")?,
}
};
Ok(Command::SqlAlterTable { table, action })
}
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"),
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<ColumnSpec> {
match parse_command(input).expect("create table should parse") {
Command::CreateTable { columns, .. } => columns,
other => panic!("expected CreateTable, got {other:?}"),
}
}
#[test]
fn create_table_parses_a_text_default() {
// `grade` is the (single) PK column; `default` is
// allowed on a PK column (ADR-0029 §9).
let cols = create_columns("create table T with pk grade(text) default 'A'");
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].default, Some(Value::Text("A".to_string())));
assert!(!cols[0].not_null && !cols[0].unique);
}
#[test]
fn create_table_parses_a_numeric_default_on_a_compound_pk_member() {
let cols = create_columns("create table T with pk a(int), b(int) default 7");
assert_eq!(cols.len(), 2);
assert_eq!(cols[1].default, Some(Value::Number("7".to_string())));
}
#[test]
fn not_null_on_a_pk_column_is_a_redundancy_error() {
// Every `create table` column is a primary-key column,
// so `not null` is always redundant there (ADR-0029 §9).
assert!(parse_command("create table T with pk id(serial) not null").is_err());
}
#[test]
fn unique_on_a_single_column_pk_is_a_redundancy_error() {
assert!(parse_command("create table T with pk code(text) unique").is_err());
}
#[test]
fn unique_on_a_compound_pk_member_is_allowed() {
// A compound PK does not make its members individually
// unique, so an explicit `unique` is meaningful there.
let cols = create_columns("create table T with pk a(int) unique, b(text)");
assert_eq!(cols.len(), 2);
assert!(cols[0].unique, "`a` carries an explicit UNIQUE");
assert!(!cols[1].unique);
}
#[test]
fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
}
#[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<String>,
table: String,
columns: Vec<String>,
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};
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 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 { .. }
));
}
#[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 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()
);
}
}