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;
+25 -3
View File
@@ -23,6 +23,8 @@
//! value slots in Phase D.
pub mod app;
pub mod ddl;
pub mod shared;
use crate::dsl::command::Command;
use crate::dsl::walker::context::WalkContext;
@@ -125,6 +127,10 @@ impl Word {
/// on mismatch.
pub type IdentValidator = fn(matched: &str) -> Result<(), ValidationError>;
/// Content-level validator for a `NumberLit` slot. Same shape
/// as `IdentValidator`; surfaces as `ValidationFailed` on Err.
pub type NumberValidator = fn(matched: &str) -> Result<(), ValidationError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub message_key: &'static str,
@@ -160,13 +166,25 @@ pub enum Node {
#[allow(dead_code)]
highlight_override: Option<HighlightClass>,
},
#[allow(dead_code)]
NumberLit,
/// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce
/// per-type integer/decimal rules).
NumberLit {
validator: Option<NumberValidator>,
},
/// A literal byte sequence at this position — matches
/// bytes verbatim (whitespace-skipped) with a lookahead so
/// `1` doesn't half-match `12` and `n` doesn't half-match
/// `name`. Used by Phase B's `add 1:n …` for the literal
/// `1`. Surfaces in the expected-set as `` `<literal>` ``,
/// matching chumsky's labelled-token rendering.
Literal(&'static str),
#[allow(dead_code)]
StringLit,
#[allow(dead_code)]
BlobLit,
#[allow(dead_code)]
/// A `--name` flag. Walker matches the flag shape and
/// asserts the name matches the expected literal.
Flag(&'static str),
/// A non-whitespace run consumed verbatim from source. Per
/// ADR-0024's path-bearing-commands UX change, paths with
@@ -231,6 +249,10 @@ pub static REGISTRY: &[&CommandNode] = &[
&app::IMPORT,
&app::MODE,
&app::MESSAGES,
&ddl::DROP,
&ddl::ADD,
&ddl::RENAME,
&ddl::CHANGE,
];
/// Look up a `CommandNode` by entry word, case-insensitively.
+120
View File
@@ -0,0 +1,120 @@
//! Shared sub-grammars for the DDL/DML migrations
//! (ADR-0024 §architecture, §sub-grammars).
//!
//! Phase B uses these for relationship endpoints and referential
//! actions; Phase D extends with `where_clause`,
//! `column_value_list`, and the typed value slots.
use crate::dsl::grammar::{IdentSource, IdentValidator, Node, ValidationError, Word};
use crate::dsl::types::Type;
use std::str::FromStr;
// --- Type-name validator ------------------------------------------
/// Reject any identifier that isn't a known user-facing type name.
///
/// Mirrors the chumsky-side `Type::from_str` + `UnknownType`
/// flow — surfaces the same `parse.custom.unknown_type` catalog
/// wording with `{found}` and `{expected}` args.
pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
if Type::from_str(value).is_ok() {
Ok(())
} else {
let expected = Type::all()
.iter()
.map(|t| t.keyword())
.collect::<Vec<_>>()
.join(", ");
Err(ValidationError {
message_key: "parse.custom.unknown_type",
args: vec![
("found", value.to_string()),
("expected", expected),
],
})
}
}
pub const TYPE_VALIDATOR: IdentValidator = validate_type_name;
// --- Type-slot leaf -----------------------------------------------
/// `Ident` slot for a column type. Validation runs after a
/// successful identifier-shape match.
pub const TYPE_SLOT: Node = Node::Ident {
source: IdentSource::Types,
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: None,
};
// --- Qualified column reference (`<Table>.<Column>`) --------------
const QUALIFIED_COLUMN_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "table_name",
validator: None,
highlight_override: None,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "column_name",
validator: None,
highlight_override: None,
},
];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
// --- Relationship-endpoint clauses (`from <T>.<c> to <T>.<c>`) ----
const RELATIONSHIP_ENDPOINTS_NODES: &[Node] = &[
Node::Word(Word::keyword("from")),
QUALIFIED_COLUMN,
Node::Word(Word::keyword("to")),
QUALIFIED_COLUMN,
];
pub const RELATIONSHIP_ENDPOINTS: Node = Node::Seq(RELATIONSHIP_ENDPOINTS_NODES);
// --- Referential action (`cascade`, `restrict`, `set null`,
// `no action`) -----------------------------------------------
const ACTION_SET_NULL: &[Node] = &[
Node::Word(Word::keyword("set")),
Node::Word(Word::keyword("null")),
];
const ACTION_NO_ACTION: &[Node] = &[
Node::Word(Word::keyword("no")),
Node::Word(Word::keyword("action")),
];
const ACTION_CHOICES: &[Node] = &[
Node::Word(Word::keyword("cascade")),
Node::Word(Word::keyword("restrict")),
Node::Seq(ACTION_SET_NULL),
Node::Seq(ACTION_NO_ACTION),
];
pub const ACTION_KEYWORD: Node = Node::Choice(ACTION_CHOICES);
// --- A single `on <delete|update> <action>` clause ----------------
const ON_TARGET_CHOICES: &[Node] = &[
Node::Word(Word::keyword("delete")),
Node::Word(Word::keyword("update")),
];
const ON_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("on")),
Node::Choice(ON_TARGET_CHOICES),
ACTION_KEYWORD,
];
pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
/// Validation of "specified twice" + max=2 lives in the
/// command's AST builder.
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated {
inner: &ON_CLAUSE,
separator: None,
min: 0,
};
+37 -8
View File
@@ -138,10 +138,11 @@ fn try_walker_route(source: &str) -> Option<Result<Command, ParseError>> {
let mut ctx = walker::context::WalkContext::new();
let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx);
let result = result?;
Some(walker_outcome_to_parse_result(result, command))
Some(walker_outcome_to_parse_result(source, result, command))
}
fn walker_outcome_to_parse_result(
source: &str,
result: crate::dsl::walker::outcome::WalkResult,
command: Option<Command>,
) -> Result<Command, ParseError> {
@@ -157,13 +158,13 @@ fn walker_outcome_to_parse_result(
expected: Vec::new(),
}),
WalkOutcome::Incomplete { position, expected } => Err(ParseError::Invalid {
message: format_walker_error(true, &expected, None),
message: format_walker_error(source, position, true, &expected),
position,
at_eof: true,
expected: expected.iter().map(format_expectation).collect(),
}),
WalkOutcome::Mismatch { position, expected } => Err(ParseError::Invalid {
message: format_walker_error(false, &expected, Some(position)),
message: format_walker_error(source, position, false, &expected),
position,
at_eof: false,
expected: expected.iter().map(format_expectation).collect(),
@@ -190,10 +191,21 @@ fn walker_outcome_to_parse_result(
}
fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
use crate::dsl::grammar::IdentSource;
use crate::dsl::walker::outcome::Expectation;
match e {
Expectation::Word(w) => format!("`{w}`"),
Expectation::Ident { role } => (*role).to_string(),
Expectation::Word(w) | Expectation::Literal(w) => format!("`{w}`"),
Expectation::Ident { source, .. } => match source {
// Match `IdentSlot::expected_label` outputs so the
// completion engine's round-trip (via
// `IdentSlot::from_expected_label`) still resolves
// the schema-cache lookup for these slots.
IdentSource::Tables => "table name".to_string(),
IdentSource::Columns => "column name".to_string(),
IdentSource::Relationships => "relationship name".to_string(),
IdentSource::Types => "type".to_string(),
IdentSource::NewName | IdentSource::Free => "identifier".to_string(),
},
Expectation::Punct(c) => format!("`{c}`"),
Expectation::NumberLit => "number".to_string(),
Expectation::StringLit => "string literal".to_string(),
@@ -205,22 +217,39 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
}
fn format_walker_error(
source: &str,
position: usize,
at_eof: bool,
expected: &[crate::dsl::walker::outcome::Expectation],
_position: Option<usize>,
) -> String {
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
let joined = oxford_join(&parts);
// Mirror the chumsky-side wording: "after `<consumed>`,
// expected …" when the parser already consumed something
// before the failure point. The `<consumed>` text trims
// trailing whitespace and is rendered between backticks.
let consumed = source[..position.min(source.len())].trim_end();
let prefix = if consumed.is_empty() {
String::new()
} else {
format!("after `{consumed}`, ")
};
if at_eof {
if joined.is_empty() {
crate::t!("parse.empty")
} else {
} else if prefix.is_empty() {
format!("expected {joined}")
} else {
format!("{prefix}expected {joined}")
}
} else if joined.is_empty() {
"unexpected input".to_string()
} else {
} else if prefix.is_empty() {
format!("expected {joined}")
} else {
format!("{prefix}expected {joined}")
}
}
+290 -37
View File
@@ -23,7 +23,9 @@
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{consume_bare_path, consume_ident, skip_whitespace};
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
};
@@ -32,6 +34,13 @@ use crate::dsl::walker::outcome::{
pub enum NodeWalkResult {
Matched {
end: usize,
/// Expectations contributed by Optional children that
/// skipped (matched zero terminals). Walker callers
/// merge these into the next failure's expected set so
/// completion sees the full "what could have appeared
/// here" union, not just the strictly-required next
/// terminal.
skipped: Vec<Expectation>,
},
/// Did not engage at this position. Caller decides whether
/// this is benign (Optional, Choice fallthrough) or a hard
@@ -52,6 +61,13 @@ pub enum NodeWalkResult {
},
}
const fn matched(end: usize) -> NodeWalkResult {
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
#[derive(Debug, Clone)]
pub enum FailureKind {
Mismatch { expected: Vec<Expectation> },
@@ -76,22 +92,25 @@ pub fn walk_node(
validator,
highlight_override: _,
} => walk_ident(source, pos, *src, role, *validator, path, per_byte),
Node::NumberLit
| Node::StringLit
| Node::BlobLit
| Node::Flag(_)
| Node::Repeated { .. }
| Node::DynamicSubgrammar(_) => {
// Phase A: not exercised by app-lifecycle commands.
// Reaching this branch means a Phase B+ grammar got
// declared without the walker support landing yet —
// surface as a hard failure so the test suite catches
// it loudly instead of silently mis-parsing.
Node::NumberLit { validator } => walk_number_lit(source, pos, *validator, path, per_byte),
Node::Literal(literal) => walk_literal(source, pos, literal, path, per_byte),
Node::StringLit | Node::BlobLit | Node::DynamicSubgrammar(_) => {
// Phase A-B: not exercised yet. Reaching this branch
// means a Phase D+ grammar got declared without the
// walker support landing — surface as a hard failure
// so tests catch it loudly rather than silently
// mis-parsing.
NodeWalkResult::Failed {
position: pos,
kind: FailureKind::Mismatch { expected: vec![] },
}
}
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
Node::Repeated {
inner,
separator,
min,
} => walk_repeated(source, pos, inner, *separator, *min, ctx, path, per_byte),
Node::BarePath => walk_bare_path(source, pos, path, per_byte),
Node::Choice(children) => walk_choice(source, pos, children, ctx, path, per_byte),
Node::Seq(children) => walk_seq(source, pos, children, ctx, path, per_byte),
@@ -127,7 +146,7 @@ fn walk_word(
end,
class: HighlightClass::Keyword,
});
NodeWalkResult::Matched { end }
NodeWalkResult::Matched { end, skipped: Vec::new() }
} else {
NodeWalkResult::NoMatch {
position,
@@ -155,9 +174,7 @@ fn walk_punct(
end: position + 1,
class: HighlightClass::Punct,
});
NodeWalkResult::Matched {
end: position + 1,
}
matched(position + 1)
} else {
NodeWalkResult::NoMatch {
position,
@@ -169,7 +186,7 @@ fn walk_punct(
fn walk_ident(
source: &str,
position: usize,
_src: crate::dsl::grammar::IdentSource,
src: crate::dsl::grammar::IdentSource,
role: &'static str,
validator: Option<crate::dsl::grammar::IdentValidator>,
path: &mut MatchedPath,
@@ -178,7 +195,7 @@ fn walk_ident(
let Some((start, end)) = consume_ident(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Ident { role }],
expected: vec![Expectation::Ident { role, source: src }],
};
};
let text = source[start..end].to_string();
@@ -200,7 +217,197 @@ fn walk_ident(
end,
class: HighlightClass::Identifier,
});
NodeWalkResult::Matched { end }
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_literal(
source: &str,
position: usize,
literal: &'static str,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let bytes = source.as_bytes();
let lit_bytes = literal.as_bytes();
if position + lit_bytes.len() > bytes.len() {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Literal(literal)],
};
}
if &bytes[position..position + lit_bytes.len()] != lit_bytes {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Literal(literal)],
};
}
// Lookahead: if the literal is a single digit / alphabetic
// run, the next byte must not extend it (so `1` doesn't
// half-match `12`).
let end = position + lit_bytes.len();
let last = lit_bytes[lit_bytes.len() - 1];
let last_is_word = last.is_ascii_alphanumeric() || last == b'_';
if last_is_word && end < bytes.len() {
let next = bytes[end];
if next.is_ascii_alphanumeric() || next == b'_' {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Literal(literal)],
};
}
}
// Highlight class follows the literal's shape: digits get
// Number; letters get Keyword; mixed defaults to Keyword.
let class = if lit_bytes.iter().all(|b| b.is_ascii_digit()) {
HighlightClass::Number
} else {
HighlightClass::Keyword
};
path.push(MatchedItem {
kind: MatchedKind::Word(literal),
text: literal.to_string(),
span: (position, end),
});
per_byte.push(ByteClass {
start: position,
end,
class,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_number_lit(
source: &str,
position: usize,
validator: Option<crate::dsl::grammar::NumberValidator>,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let Some((start, end)) = consume_number_literal(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::NumberLit],
};
};
let text = source[start..end].to_string();
if let Some(v) = validator
&& let Err(err) = v(&text)
{
return NodeWalkResult::Failed {
position: start,
kind: FailureKind::Validation(err),
};
}
path.push(MatchedItem {
kind: MatchedKind::NumberLit,
text,
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Number,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_flag(
source: &str,
position: usize,
name: &'static str,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let Some((start, end)) = consume_flag(source, position) else {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Flag(name)],
};
};
// `consume_flag` guarantees `start..end` covers `--<body>`.
let body = &source[start + 2..end];
if body != name {
return NodeWalkResult::NoMatch {
position,
expected: vec![Expectation::Flag(name)],
};
}
path.push(MatchedItem {
kind: MatchedKind::Flag(name),
text: source[start..end].to_string(),
span: (start, end),
});
per_byte.push(ByteClass {
start,
end,
class: HighlightClass::Flag,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
#[allow(clippy::too_many_arguments)]
fn walk_repeated(
source: &str,
position: usize,
inner: &Node,
separator: Option<&Node>,
min: usize,
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
) -> NodeWalkResult {
let mut cur = position;
let mut count = 0_usize;
let mut last_expected: Option<Vec<Expectation>> = None;
loop {
let saved_path_len = path.items.len();
let saved_byte_len = per_byte.len();
let result = if count == 0 {
walk_node(source, cur, inner, ctx, path, per_byte)
} else if let Some(sep) = separator {
let sep_saved_path = path.items.len();
let sep_saved_byte = per_byte.len();
match walk_node(source, cur, sep, ctx, path, per_byte) {
NodeWalkResult::Matched { end, .. } => {
walk_node(source, end, inner, ctx, path, per_byte)
}
NodeWalkResult::NoMatch { .. } => {
path.items.truncate(sep_saved_path);
per_byte.truncate(sep_saved_byte);
break;
}
other => return other,
}
} else {
walk_node(source, cur, inner, ctx, path, per_byte)
};
match result {
NodeWalkResult::Matched { end, .. } => {
cur = end;
count += 1;
}
NodeWalkResult::NoMatch { expected, .. } => {
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
last_expected = Some(expected);
break;
}
other => return other,
}
}
if count < min {
return NodeWalkResult::NoMatch {
position: cur,
expected: last_expected.unwrap_or_default(),
};
}
// The "could continue with another inner" expectations
// become this Repeated's `skipped` set so the caller's
// expected-set surfaces them at completion time.
NodeWalkResult::Matched {
end: cur,
skipped: last_expected.unwrap_or_default(),
}
}
fn walk_bare_path(
@@ -226,7 +433,7 @@ fn walk_bare_path(
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched { end }
NodeWalkResult::Matched { end, skipped: Vec::new() }
}
fn walk_choice(
@@ -242,13 +449,12 @@ fn walk_choice(
let saved_path_len = path.items.len();
let saved_byte_len = per_byte.len();
match walk_node(source, position, child, ctx, path, per_byte) {
NodeWalkResult::Matched { end } => return NodeWalkResult::Matched { end },
m @ NodeWalkResult::Matched { .. } => return m,
NodeWalkResult::NoMatch { expected, .. } => {
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
merge_expected(&mut all_expected, expected);
}
// Once a choice branch commits, propagate its outcome.
other => return other,
}
}
@@ -268,28 +474,67 @@ fn walk_seq(
) -> NodeWalkResult {
let mut cur = position;
let mut idx = 0;
// Carries expectations from skipped-Optional children so
// that a NoMatch on a later child reports the union of "you
// could have typed any of these" — making the completion
// engine see optional connectives that haven't been typed.
let mut pending_skipped: Vec<Expectation> = Vec::new();
for child in children {
match walk_node(source, cur, child, ctx, path, per_byte) {
NodeWalkResult::Matched { end } => {
NodeWalkResult::Matched { end, skipped } => {
if end == cur {
// Child matched zero terminals (Optional skipped,
// empty Repeated, empty Seq). Accumulate its
// would-be expectations into pending.
for e in skipped {
if !pending_skipped.contains(&e) {
pending_skipped.push(e);
}
}
} else {
// Child consumed terminals — the "missing optional"
// window closed; reset the pending list.
pending_skipped.clear();
pending_skipped.extend(skipped);
}
cur = end;
idx += 1;
}
NodeWalkResult::NoMatch { position, expected } => {
NodeWalkResult::NoMatch {
position,
mut expected,
} => {
// Merge pending skipped-optional expectations with this
// child's expected set.
for e in std::mem::take(&mut pending_skipped) {
if !expected.contains(&e) {
expected.push(e);
}
}
if idx == 0 {
// Seq didn't even start.
return NodeWalkResult::NoMatch { position, expected };
}
// Mid-shape: did we run out of input or hit a
// wrong token?
let post_ws = skip_whitespace(source, position);
let kind = if post_ws >= source.len() {
return NodeWalkResult::Incomplete { position: post_ws, expected };
} else {
FailureKind::Mismatch { expected }
if post_ws >= source.len() {
return NodeWalkResult::Incomplete {
position: post_ws,
expected,
};
return NodeWalkResult::Failed { position: post_ws, kind };
}
NodeWalkResult::Incomplete { position, expected } => {
return NodeWalkResult::Failed {
position: post_ws,
kind: FailureKind::Mismatch { expected },
};
}
NodeWalkResult::Incomplete {
position,
mut expected,
} => {
for e in std::mem::take(&mut pending_skipped) {
if !expected.contains(&e) {
expected.push(e);
}
}
return NodeWalkResult::Incomplete { position, expected };
}
NodeWalkResult::Failed { position, kind } => {
@@ -297,7 +542,10 @@ fn walk_seq(
}
}
}
NodeWalkResult::Matched { end: cur }
NodeWalkResult::Matched {
end: cur,
skipped: pending_skipped,
}
}
fn walk_optional(
@@ -311,11 +559,16 @@ fn walk_optional(
let saved_path_len = path.items.len();
let saved_byte_len = per_byte.len();
match walk_node(source, position, child, ctx, path, per_byte) {
NodeWalkResult::Matched { end } => NodeWalkResult::Matched { end },
NodeWalkResult::NoMatch { .. } => {
m @ NodeWalkResult::Matched { .. } => m,
NodeWalkResult::NoMatch { expected, .. } => {
// Skip the optional but carry the inner's expectations
// so the caller's expected-set sees them.
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
NodeWalkResult::Matched { end: position }
NodeWalkResult::Matched {
end: position,
skipped: expected,
}
}
other => other,
}
+89
View File
@@ -97,3 +97,92 @@ pub fn match_punct(source: &str, position: usize, ch: char) -> Option<usize> {
None
}
}
/// Number literal: optional leading `-` (when adjacent to a digit),
/// then 1+ digits, optional `.` + 1+ digits.
///
/// Mirrors `dsl::lexer::lex_number`. Used by Phase B's `add 1:n
/// relationship` form (where the literal `1` lexes as a Number)
/// and by Phase D's value-literal slots.
pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usize)> {
let bytes = source.as_bytes();
if start >= bytes.len() {
return None;
}
let mut i = start;
let leading_minus = bytes[i] == b'-'
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit();
if leading_minus {
i += 1;
}
if i >= bytes.len() || !bytes[i].is_ascii_digit() {
return None;
}
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'.' {
let after = i + 1;
if after < bytes.len() && bytes[after].is_ascii_digit() {
i = after;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
}
}
Some((start, i))
}
/// Flag token: `--name` where name is alphanumeric / `-` / `_`,
/// at least one character. Returns the span (including `--`) on
/// match. The caller checks `name` against an expected value.
pub fn consume_flag(source: &str, start: usize) -> Option<(usize, usize)> {
let bytes = source.as_bytes();
if start + 2 > bytes.len() || &bytes[start..start + 2] != b"--" {
return None;
}
let mut i = start + 2;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
i += 1;
} else {
break;
}
}
if i == start + 2 {
return None;
}
Some((start, i))
}
/// Single-quoted string literal with `''` escape (mirrors
/// `dsl::lexer::lex_string`).
///
/// Returns `(start, end)` where end is past the closing quote,
/// plus the unescaped content. `None` when the literal is
/// unterminated or the position isn't at a `'`.
#[allow(dead_code)]
pub fn consume_string_literal(source: &str, start: usize) -> Option<((usize, usize), String)> {
let bytes = source.as_bytes();
if start >= bytes.len() || bytes[start] != b'\'' {
return None;
}
let mut content = String::new();
let mut i = start + 1;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
content.push('\'');
i += 2;
continue;
}
return Some(((start, i + 1), content));
}
let ch = source[i..].chars().next()?;
content.push(ch);
i += ch.len_utf8();
}
None
}
+238 -10
View File
@@ -92,7 +92,7 @@ pub fn walk(
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end } => {
NodeWalkResult::Matched { end, .. } => {
let trailing = skip_whitespace(effective_source, end);
if trailing < effective_source.len() {
WalkOutcome::Mismatch {
@@ -128,14 +128,29 @@ pub fn walk(
},
};
let cmd = if matches!(outcome, WalkOutcome::Match { .. }) {
(command_node.ast_builder)(&path).ok()
} else {
None
// Apply the AST builder. A validation error here surfaces
// as a `ValidationFailed` outcome (so the bridge can render
// the catalog wording correctly) rather than as a generic
// "AST builder failed" fallback.
let (final_outcome, cmd) = match outcome {
WalkOutcome::Match { .. } => match (command_node.ast_builder)(&path) {
Ok(c) => (outcome, Some(c)),
Err(error) => (
WalkOutcome::ValidationFailed {
position: path
.items
.last()
.map_or(kw_start, |i| i.span.0),
error,
},
None,
),
},
other => (other, None),
};
let result = WalkResult {
outcome,
outcome: final_outcome,
matched_path: path,
per_byte_class: per_byte,
};
@@ -369,13 +384,28 @@ mod tests {
fn walker_import_trailing_as_without_target_errors() {
let err = parse("import foo.zip as ").unwrap_err();
match err {
crate::dsl::ParseError::Invalid { message, expected, .. } => {
crate::dsl::ParseError::Invalid {
message, expected, ..
} => {
// Phase A: the friendly `project.import_empty_target`
// wording moves out of the parser; the walker's
// structural error names the `target` slot.
// structural error names the slot via its
// user-facing label. NewName slots render as
// "identifier" — matching `IdentSlot::expected_label`
// — so the existing completion engine's round-
// trip still works. The integration test
// (`import_with_empty_target_after_as_errors`)
// continues to pass because the rendered
// `import_usage` template line in the output
// contains both "import" and "target".
assert!(
message.contains("target") || expected.iter().any(|e| e == "target"),
"expected mention of target slot; got message={message:?}, expected={expected:?}"
message.contains("identifier")
|| expected.iter().any(|e| e == "identifier"),
"expected identifier-slot wording; got message={message:?}, expected={expected:?}"
);
assert!(
message.contains("import"),
"expected `import` in 'after `<prefix>`' framing; got: {message}"
);
}
other => panic!("expected Invalid, got {other:?}"),
@@ -429,4 +459,202 @@ mod tests {
})
);
}
// =========================================================
// Phase B — DDL commands.
// =========================================================
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ChangeColumnMode, RelationshipSelector};
use crate::dsl::types::Type;
#[test]
fn walker_parses_drop_table() {
assert_eq!(
parse("drop table Customers").unwrap(),
Command::DropTable {
name: "Customers".to_string(),
}
);
}
#[test]
fn walker_parses_drop_column_with_optional_connectives() {
let want = Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
};
assert_eq!(parse("drop column Customers: Email").unwrap(), want);
assert_eq!(parse("drop column from Customers: Email").unwrap(), want);
assert_eq!(parse("drop column from table Customers: Email").unwrap(), want);
assert_eq!(parse("drop column table Customers: Email").unwrap(), want);
}
#[test]
fn walker_parses_drop_relationship_named() {
assert_eq!(
parse("drop relationship Orders_to_Customers").unwrap(),
Command::DropRelationship {
selector: RelationshipSelector::Named {
name: "Orders_to_Customers".to_string(),
},
}
);
}
#[test]
fn walker_parses_drop_relationship_endpoints() {
assert_eq!(
parse("drop relationship from Customers.id to Orders.customer_id").unwrap(),
Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
child_table: "Orders".to_string(),
child_column: "customer_id".to_string(),
},
}
);
}
#[test]
fn walker_parses_add_column() {
assert_eq!(
parse("add column Customers: Email (text)").unwrap(),
Command::AddColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn walker_add_column_unknown_type_errors_with_friendly_wording() {
let err = parse("add column Customers: Email (varchar)").unwrap_err();
match err {
crate::dsl::ParseError::Invalid { message, .. } => {
assert!(message.contains("varchar"), "got: {message}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn walker_parses_rename_column() {
assert_eq!(
parse("rename column Customers: Email to ContactEmail").unwrap(),
Command::RenameColumn {
table: "Customers".to_string(),
old: "Email".to_string(),
new: "ContactEmail".to_string(),
}
);
}
#[test]
fn walker_parses_change_column() {
assert_eq!(
parse("change column Customers: Email (text)").unwrap(),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Email".to_string(),
ty: Type::Text,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn walker_parses_change_column_with_force_conversion_flag() {
assert_eq!(
parse("change column Customers: Email (int) --force-conversion").unwrap(),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Email".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::ForceConversion,
}
);
}
#[test]
fn walker_change_column_rejects_both_flags() {
let err = parse("change column Customers: Email (int) --force-conversion --dont-convert")
.unwrap_err();
match err {
crate::dsl::ParseError::Invalid { message, .. } => {
assert!(message.contains("mutually exclusive"), "got: {message}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn walker_parses_add_relationship_minimal() {
assert_eq!(
parse("add 1:n relationship from Customers.id to Orders.customer_id").unwrap(),
Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
child_table: "Orders".to_string(),
child_column: "customer_id".to_string(),
on_delete: ReferentialAction::default_action(),
on_update: ReferentialAction::default_action(),
create_fk: false,
}
);
}
#[test]
fn walker_parses_add_relationship_with_name_and_actions_and_flag() {
assert_eq!(
parse(
"add 1:n relationship as cust_orders from Customers.id to Orders.customer_id \
on delete cascade on update set null --create-fk"
)
.unwrap(),
Command::AddRelationship {
name: Some("cust_orders".to_string()),
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
child_table: "Orders".to_string(),
child_column: "customer_id".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::SetNull,
create_fk: true,
}
);
}
#[test]
fn walker_add_relationship_repeated_clause_errors() {
let err = parse(
"add 1:n relationship from Customers.id to Orders.customer_id \
on delete cascade on delete restrict",
)
.unwrap_err();
match err {
crate::dsl::ParseError::Invalid { message, .. } => {
assert!(
message.contains("delete") && message.contains("twice"),
"got: {message}"
);
}
other => panic!("expected Invalid, got {other:?}"),
}
}
// ---- Routing fall-through still works for non-DDL ----
#[test]
fn walker_does_not_engage_for_show_data() {
// `show` isn't migrated yet (Phase D); router falls
// through to chumsky.
assert!(matches!(
parse("show data Customers").unwrap(),
Command::ShowData { .. }
));
}
}
+14 -4
View File
@@ -11,7 +11,7 @@
//! tests; completion + highlighting still flow through the
//! chumsky path until Phase D / F.
use crate::dsl::grammar::{HighlightClass, ValidationError};
use crate::dsl::grammar::{HighlightClass, IdentSource, ValidationError};
/// How far into the input the walker should consume.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,10 +31,20 @@ pub enum WalkBound {
/// only what the router needs to render a parse error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expectation {
/// The walker expected one of these literal keywords.
/// The walker expected this literal keyword.
Word(&'static str),
/// The walker expected an identifier of the given role.
Ident { role: &'static str },
/// The walker expected this verbatim literal byte sequence
/// (used today for the `1` in `add 1:n …`).
Literal(&'static str),
/// The walker expected an identifier slot. `source` drives
/// the user-facing expected-label rendering ("table name",
/// "column name", …) so the existing completion engine's
/// `IdentSlot::from_expected_label` round-trip still works.
/// `role` is the walker-internal slot tag.
Ident {
role: &'static str,
source: IdentSource,
},
/// The walker expected this exact punctuation character.
Punct(char),
/// The walker expected a number literal.