ADR-0024 Phase B: DDL commands without value literals

Migrate the five DDL 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 walker route now owns these end-to-end; chumsky
declarations remain unreachable for these inputs but stay
until Phase F.

Walker extensions:
- New node kinds: NumberLit (with optional content validator)
  and Literal(&str) (verbatim byte sequence with word-boundary
  lookahead — used for the `1` in `add 1:n …` so it surfaces
  as `\`1\`` in the expected-set, matching the existing
  parse_error_pedagogy contract).
- Flag (--name) terminal — Phase A stubbed; now wired to the
  walker driver with consume_flag() in lex_helpers.
- Repeated combinator with optional separator and `min` floor.
  Used by referential clauses (0..2 `on <delete|update>` runs)
  and change-column flags (0..N --force-conversion /
  --dont-convert; AST builder enforces mutual exclusion).
- Optional now propagates its inner's expectations as a
  `skipped` field on the Matched result. Seq accumulates these
  across children so the next failure's expected-set surfaces
  the full union — closes the keyword-completion regression
  (`add column ` must offer `to`, `table`, plus the table-name
  identifier slot).
- Expectation::Ident gained a `source: IdentSource` field; the
  parser-side bridge maps Tables/Columns/Relationships/Types
  to the IdentSlot::expected_label strings ("table name",
  "column name", …) so the existing completion engine's
  schema-cache lookup still resolves.
- Walker error wording now includes "after `<consumed>`,
  expected …" framing — matches the chumsky-side test
  contract for structural errors mid-shape.
- AST-builder validation errors now propagate as
  WalkOutcome::ValidationFailed (not the generic "AST builder
  failed" fallback), so `change column … --force-conversion
  --dont-convert` and repeated `on delete` clauses surface
  their friendly catalog wording verbatim.

Grammar additions:
- src/dsl/grammar/shared.rs: type-name validator (TYPE_VALIDATOR
  uses Type::from_str via parse.custom.unknown_type catalog),
  qualified_column sub-grammar, referential action keyword
  (`cascade`/`restrict`/`set null`/`no action`), repeated
  on-clauses.
- src/dsl/grammar/ddl.rs: drop/add/rename/change CommandNodes
  with inline shapes (per-use-site `role` annotations let the
  AST builder discriminate parent vs child columns, etc.).
  The four entry words each have one CommandNode whose `shape`
  is a Choice across sub-forms.

Tests:
- 14 new walker-specific tests covering all DDL forms (bare
  drop table, drop column with optional connectives, drop
  relationship by name and by endpoints, add column with type
  validator, rename column, change column with each flag form
  + mutual-exclusion check, add 1:n relationship minimal /
  full, repeated-clause-twice rejection).
- Total: 819 passed, 0 failed, 1 ignored (was 805 / 1).
- cargo clippy --all-targets -- -D warnings clean.
This commit is contained in:
claude@clouddev1
2026-05-15 06:59:27 +00:00
parent 50b3542050
commit 7e79ca865a
8 changed files with 1400 additions and 62 deletions
+587
View File
@@ -0,0 +1,587 @@
//! DDL command nodes (ADR-0024 §migration Phase B).
//!
//! Five commands at four entry words: `drop` (drop table /
//! drop column / drop relationship), `add` (add column /
//! add 1:n relationship), `rename` (rename column), `change`
//! (change column). The chumsky-side declarations stay
//! reachable for any input the walker doesn't engage on, but
//! for these entry words the walker is authoritative.
//!
//! Each shape is laid out inline so per-use-site `role`
//! annotations carry meaning end-to-end (e.g.,
//! `parent_table` vs `child_table` for the endpoints clause).
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ChangeColumnMode, Command, RelationshipSelector};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT},
};
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
// =================================================================
// Building blocks
// =================================================================
const TABLE_NAME_NEW: Node = Node::Ident {
source: IdentSource::NewName,
role: "table_name",
validator: None,
highlight_override: None,
};
const TABLE_NAME_EXISTING: Node = Node::Ident {
source: IdentSource::Tables,
role: "table_name",
validator: None,
highlight_override: None,
};
const COLUMN_NAME: Node = Node::Ident {
source: IdentSource::Columns,
role: "column_name",
validator: None,
highlight_override: None,
};
const COLUMN_NAME_NEW: Node = Node::Ident {
source: IdentSource::NewName,
role: "column_name",
validator: None,
highlight_override: None,
};
const RELATIONSHIP_NAME: Node = Node::Ident {
source: IdentSource::Relationships,
role: "relationship_name",
validator: None,
highlight_override: None,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Ident {
source: IdentSource::NewName,
role: "relationship_name",
validator: None,
highlight_override: None,
};
// `[to]` and `[table]` connectives.
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from")));
const IN_OPT: Node = Node::Optional(&Node::Word(Word::keyword("in")));
const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// =================================================================
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// =================================================================
// drop_column — `drop column [from] [table] <T> : <col>`
// =================================================================
const DROP_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
FROM_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME,
];
const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES);
// =================================================================
// drop_relationship — `drop relationship (endpoints | name)`
// =================================================================
const DR_PARENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "parent_table",
validator: None,
highlight_override: None,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "parent_column",
validator: None,
highlight_override: None,
},
];
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,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "child_column",
validator: None,
highlight_override: None,
},
];
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 entry — `drop (table|column|relationship) ...`
// =================================================================
const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
// add_column — `add column [to] [table] <T> : <col> ( <type> )`
// =================================================================
const ADD_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("column")),
TO_OPT,
TABLE_OPT,
TABLE_NAME_EXISTING,
Node::Punct(':'),
COLUMN_NAME_NEW,
Node::Punct('('),
TYPE_SLOT,
Node::Punct(')'),
];
const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES);
// =================================================================
// add_relationship — `add 1:n relationship [as <name>]
// from <T>.<c> to <T>.<c>
// [on delete <a>] [on update <a>]
// [--create-fk]`
// =================================================================
const AR_PARENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "parent_table",
validator: None,
highlight_override: None,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "parent_column",
validator: None,
highlight_override: None,
},
];
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,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "child_column",
validator: None,
highlight_override: None,
},
];
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 entry — `add (column|1:n relationship) …`
// =================================================================
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP];
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);
// =================================================================
// rename_column — `rename column [in] [table] <T> : <col> to <new>`
// =================================================================
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")),
Node::Ident {
source: IdentSource::NewName,
role: "new_column_name",
validator: None,
highlight_override: None,
},
];
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}"))],
})
}
fn parse_action(words: &[&'static str]) -> ReferentialAction {
// `set null`, `no action`, `cascade`, `restrict`.
if words.contains(&"set") && words.contains(&"null") {
ReferentialAction::SetNull
} else if words.contains(&"no") && words.contains(&"action") {
ReferentialAction::NoAction
} else if words.contains(&"cascade") {
ReferentialAction::Cascade
} else if words.contains(&"restrict") {
ReferentialAction::Restrict
} else {
ReferentialAction::default_action()
}
}
fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
// Discriminate by the second word matched (the entry was
// `drop`, the next Word is `table` / `column` / `relationship`).
let sub = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.nth(1);
match sub {
Some("table") => Ok(Command::DropTable {
name: require_ident(path, "table_name")?,
}),
Some("column") => Ok(Command::DropColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
}),
Some("relationship") => {
// Endpoints form has `from` as the third Word.
let has_from = path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Word("from")));
if has_from {
Ok(Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: require_ident(path, "parent_table")?,
parent_column: require_ident(path, "parent_column")?,
child_table: require_ident(path, "child_table")?,
child_column: require_ident(path, "child_column")?,
},
})
} else {
Ok(Command::DropRelationship {
selector: RelationshipSelector::Named {
name: require_ident(path, "relationship_name")?,
},
})
}
}
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown drop subcommand".to_string())],
}),
}
}
fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
// Second matched Word distinguishes column vs the `1:n
// relationship` form. The `1` literal counts as a Word
// (the walker records Literal matches as MatchedKind::Word
// for AST-builder uniformity).
let second_word = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.nth(1);
match second_word {
Some("column") => {
let ty_text = require_ident(path, "type")?;
let ty = ty_text
.parse::<crate::dsl::types::Type>()
.map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
ty,
})
}
Some("1") => build_add_relationship(path),
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown add subcommand".to_string())],
}),
}
}
fn build_add_relationship(path: &MatchedPath) -> Result<Command, ValidationError> {
// Collect all referential-clause actions in matched order
// and validate at-most-2 + not-repeated. The `on <delete|
// update> <action>` sequence shows up as a run of Word
// matches in the path between `to <child>` and end.
//
// Strategy: walk through Word items in order; whenever we
// see `on`, the next Word is the target, then the action
// word(s) follow until the next `on`, `--create-fk`, or
// end-of-path.
let words: Vec<&'static str> = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.collect();
let mut on_delete: Option<ReferentialAction> = None;
let mut on_update: Option<ReferentialAction> = None;
let mut i = 0;
while i < words.len() {
if words[i] == "on" && i + 1 < words.len() {
let target = words[i + 1];
// Action runs from i+2 until the next `on` or end.
let action_start = i + 2;
let mut action_end = action_start;
while action_end < words.len() && words[action_end] != "on" {
action_end += 1;
}
let action = parse_action(&words[action_start..action_end]);
let slot = match target {
"delete" => &mut on_delete,
"update" => &mut on_update,
_ => {
i = action_end;
continue;
}
};
if slot.is_some() {
return Err(ValidationError {
message_key: "parse.custom.on_action_specified_twice",
args: vec![("target", target.to_string())],
});
}
*slot = Some(action);
i = action_end;
} else {
i += 1;
}
}
let create_fk = path
.items
.iter()
.any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));
Ok(Command::AddRelationship {
name: ident(path, "relationship_name").map(str::to_string),
parent_table: require_ident(path, "parent_table")?,
parent_column: require_ident(path, "parent_column")?,
child_table: require_ident(path, "child_table")?,
child_column: require_ident(path, "child_column")?,
on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
create_fk,
})
}
fn build_rename_column(path: &MatchedPath) -> Result<Command, ValidationError> {
Ok(Command::RenameColumn {
table: require_ident(path, "table_name")?,
old: require_ident(path, "column_name")?,
new: require_ident(path, "new_column_name")?,
})
}
fn build_change_column(path: &MatchedPath) -> Result<Command, ValidationError> {
let ty_text = require_ident(path, "type")?;
let ty = ty_text
.parse::<crate::dsl::types::Type>()
.map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
// Flags: at most one of --force-conversion / --dont-convert.
let flags: Vec<&'static str> = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Flag(n) => Some(*n),
_ => None,
})
.collect();
let mode = match flags.as_slice() {
[] => ChangeColumnMode::Default,
[one] => match *one {
"force-conversion" => ChangeColumnMode::ForceConversion,
"dont-convert" => ChangeColumnMode::DontConvert,
_ => ChangeColumnMode::Default,
},
_ => {
// Two or more flags — mutual exclusion fires
// whether they're the same flag twice or both
// mutually-exclusive flags appear. Wording mirrors
// the chumsky parser's `change_column_flags_exclusive`.
return Err(ValidationError {
message_key: "parse.custom.change_column_flags_exclusive",
args: vec![],
});
}
};
Ok(Command::ChangeColumnType {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
ty,
mode,
})
}
// =================================================================
// CommandNodes
// =================================================================
pub static DROP: CommandNode = CommandNode {
entry: Word::keyword("drop"),
shape: DROP_SHAPE,
ast_builder: build_drop,
help_id: Some("ddl.drop"),
usage_id: Some("parse.usage.drop"),
hint_mode: None,
};
pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"),
shape: ADD_SHAPE,
ast_builder: build_add,
help_id: Some("ddl.add"),
usage_id: Some("parse.usage.add"),
hint_mode: None,
};
pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"),
shape: RENAME_COLUMN,
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
usage_id: Some("parse.usage.rename_column"),
hint_mode: None,
};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
shape: CHANGE_COLUMN,
ast_builder: build_change_column,
help_id: Some("ddl.change"),
usage_id: Some("parse.usage.change_column"),
hint_mode: None,
};
// `TABLE_NAME_NEW` is currently unused (Phase C will bring
// it back when `create table` migrates). Keeping the
// declaration here keeps the per-source-of-truth convention
// consistent.
#[allow(dead_code)]
const _UNUSED: Node = TABLE_NAME_NEW;