Files
rdbms-playground/src/dsl/parser.rs
T
claude@clouddev1 a12facc784 feat(seed): set override clause + column-fill (ADR-0048 Phase 2)
Build the two SD2 surfaces Phase 1 deferred:

- `set` override clause (D2): comma-separated per-column pins —
  `= 'v'` (fixed), `in ('a','b')` (pick-list), `as <generator>`
  (named), `between x and y` (range; numeric and quoted dates).
  Type-aware via the typed `current_column_value` slot; an override
  drops its column from the generic-fill advisory (D13). Folded from
  the flat matched path (build_seed_overrides) and applied to the
  per-column plan (apply_seed_overrides).
- `<table>.<column>` column-fill (D1 form 2): an UPDATE over existing
  rows. Refuses PK/autogen targets, empty-table no-op, FK-samples the
  parent, collision-free for UNIQUE/identifier targets, one undo step;
  `set` may only adjust the filled column.

Supporting work: KNOWN_GENERATORS vocabulary + generator_for_name
(src/seed/vocabulary.rs, D9); a range Generator + range_bounds_reason;
IdentSource::Generators and HighlightClass::Function; completion of the
generator vocabulary after `as` and the set/.col column slots; the
typing-time validity indicator for an unknown generator; help,
parse-error pedagogy rows, and the D13 advisory's Phase-2/3 wording.

A bounded override (fixed value / too-short pick-list) on a
single-column-UNIQUE target is a friendly error rather than a silent
uniqueness cap (post-implementation /runda finding, user-chosen).

Dates in the range form are quoted (no date-literal token exists);
ADR-0048 D2 amended accordingly. Both modes (D5); reproducible (D4).
2026-06-12 09:44:30 +00:00

1621 lines
54 KiB
Rust

//! DSL parser (ADR-0024).
//!
//! The chumsky+lexer pipeline has been retired (ADR-0024 §migration
//! Phase F minimal). `parse_command` now routes every input through
//! the unified-grammar walker in `crate::dsl::walker`. The walker
//! reads source bytes directly — there is no separate token pre-pass.
//!
//! This module remains the public entry point for parsing because
//! consumers depend on `ParseError`'s shape (the `expected`,
//! `position`, `at_eof` fields drive completion, hint rendering,
//! and the input-renderer's error overlay). It also produces the
//! synthetic "unknown command" error when the input's first
//! identifier-shape token isn't a registered entry word.
use tracing::trace;
use crate::dsl::command::Command;
use crate::mode::Mode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
Invalid {
message: String,
position: usize,
/// True when the parse failed because more input was
/// expected — i.e. a structural failure with no
/// next-token to point at. Used by the input renderer
/// (ADR-0022 §4) to distinguish "incomplete but
/// plausible" from "definite error" mid-typing.
///
/// Custom errors raised by `try_map` are conservatively
/// classified as `at_eof = true` because we cannot, at
/// this layer, tell apart "tables need at least one
/// column" (incomplete: more input would help) from
/// "--force-conversion and --dont-convert are mutually
/// exclusive" (definite: user must remove a token).
/// Erring on `true` means custom-error inputs do not
/// get a live error overlay; the parse error still
/// fires on submit. A future refinement may carry an
/// explicit `is_definite` tag through custom errors.
at_eof: bool,
/// Human-rendered names of patterns the parser was
/// looking for at the failure point: `\`create\``,
/// `identifier`, etc. Same forms `humanise()` uses
/// inside the `message` sentence, but as discrete
/// items so callers (the hint panel, ADR-0022 §6)
/// can render them in their own framing. Empty for
/// custom errors (which have no expected-set
/// framing).
expected: Vec<String>,
},
Empty,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!(
"parse.error_wrapper",
detail = message,
)),
Self::Empty => f.write_str(&crate::t!("parse.empty")),
}
}
}
impl std::error::Error for ParseError {}
impl ParseError {
#[must_use]
pub const fn position(&self) -> Option<usize> {
match self {
Self::Invalid { position, .. } => Some(*position),
Self::Empty => None,
}
}
#[must_use]
pub const fn at_eof(&self) -> bool {
match self {
Self::Invalid { at_eof, .. } => *at_eof,
Self::Empty => true,
}
}
}
/// Parse a single DSL command end-to-end.
///
/// Routes through the unified-grammar walker (ADR-0024
/// §architecture). If the walker doesn't engage (the input's
/// first identifier-shape token isn't a registered entry word),
/// produces a synthetic "unknown command" error naming every
/// valid entry keyword.
///
/// Schemaless variant: schema-aware nodes
/// (`Ident { source: Tables }` with `writes_table` enabled,
/// `DynamicSubgrammar`) fall back to schema-unaware behaviour.
/// Use `parse_command_with_schema` to enable typed value slots
/// (ADR-0024 §Phase D).
///
/// Defaults to **advanced**-mode grammar (the full surface) —
/// callers that need simple-mode gating (ADR-0030 §2) use
/// [`parse_command_in_mode`] or
/// [`parse_command_with_schema_in_mode`] instead.
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
parse_command_inner(input, None, Mode::Advanced)
}
/// Schema-aware parse entry point (ADR-0024 §Phase D).
///
/// Threads a `SchemaCache` reference through `WalkContext` so
/// the walker can populate `current_table` / `current_column`
/// from existing entities and `DynamicSubgrammar` factories
/// can unfold per-column typed value slots.
///
/// Defaults to **advanced**-mode grammar; for simple-mode
/// gating use [`parse_command_with_schema_in_mode`].
pub fn parse_command_with_schema(
input: &str,
schema: &crate::completion::SchemaCache,
) -> Result<Command, ParseError> {
parse_command_inner(input, Some(schema), Mode::Advanced)
}
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode(
input: &str,
mode: Mode,
) -> Result<Command, ParseError> {
parse_command_inner(input, None, mode)
}
/// Schema-aware, mode-aware parse.
///
/// Combines ADR-0024 §Phase D (schema-aware typed slots) with
/// ADR-0030 §2 (mode-gated SQL grammar). The execution path
/// (`App::dispatch_dsl`) and the live overlay / completion /
/// highlight call sites use this so simple-mode users do not
/// see advanced-mode SQL surfaced.
pub fn parse_command_with_schema_in_mode(
input: &str,
schema: &crate::completion::SchemaCache,
mode: Mode,
) -> Result<Command, ParseError> {
parse_command_inner(input, Some(schema), mode)
}
fn parse_command_inner(
input: &str,
schema: Option<&crate::completion::SchemaCache>,
mode: Mode,
) -> Result<Command, ParseError> {
// `trace`, not `debug`: parsing is a hot path — the live overlay /
// completion (completion.rs) re-parse per keystroke, probing
// candidates in a loop, so a per-parse `debug` line would flood. The
// executed-command story lives at `debug` in db.rs (one per submit).
trace!(
len = input.len(),
mode = ?mode,
schema_aware = schema.is_some(),
"parse: begin"
);
if input.trim().is_empty() {
trace!("parse: empty input");
return Err(ParseError::Empty);
}
let result =
try_walker_route(input, schema, mode).unwrap_or_else(|| Err(unknown_command_error(input)));
match &result {
Ok(cmd) => trace!(command = cmd.verb(), "parse: ok"),
Err(e) => trace!(error = %e, "parse: rejected"),
}
result
}
/// Synthetic ParseError for inputs whose first identifier-shape
/// token isn't a registered command entry word.
fn unknown_command_error(source: &str) -> ParseError {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let entries: Vec<String> = crate::dsl::grammar::entry_words_alphabetised()
.into_iter()
.map(|w| format!("`{w}`"))
.collect();
let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else(
|| (start, None),
|(s, e)| (s, Some(&source[s..e])),
);
let message = found_word.map_or_else(
|| format!("expected one of {joined}"),
|w| format!("expected one of {joined}, found `{w}`"),
);
ParseError::Invalid {
message,
position,
at_eof: false,
expected: entries,
}
}
/// Walker route. Returns `None` when the walker doesn't engage
/// (input doesn't start with a registered entry keyword); the
/// router falls through to the synthetic "unknown command"
/// error.
fn try_walker_route(
source: &str,
schema: Option<&crate::completion::SchemaCache>,
mode: Mode,
) -> Option<Result<Command, ParseError>> {
use crate::dsl::walker::{self, outcome::WalkBound};
let mut ctx = schema.map_or_else(walker::context::WalkContext::new, |s| {
walker::context::WalkContext::with_schema(s)
});
ctx.mode = mode;
let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx);
let result = result?;
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> {
use crate::dsl::walker::outcome::WalkOutcome;
match result.outcome {
WalkOutcome::Match { .. } => command.ok_or_else(|| ParseError::Invalid {
message: crate::t!(
"parse.error_wrapper",
detail = String::from("AST builder failed")
),
position: 0,
at_eof: false,
expected: Vec::new(),
}),
WalkOutcome::Incomplete { position, expected } => Err(ParseError::Invalid {
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(source, position, false, &expected),
position,
at_eof: false,
expected: expected.iter().map(format_expectation).collect(),
}),
WalkOutcome::ValidationFailed { position, error } => {
// Runtime catalog lookup: walker carries the catalog
// key + args at `Node::Ident` validators (e.g.,
// `mode.unknown`). The `t!` macro requires a literal
// key, so we call `friendly::translate` directly.
let arg_refs: Vec<(&str, &dyn std::fmt::Display)> = error
.args
.iter()
.map(|(k, v)| (*k, v as &dyn std::fmt::Display))
.collect();
let message = crate::friendly::translate(error.message_key, &arg_refs);
// Mirror the chumsky-side custom-error convention
// (parser.rs `into_parse_error`): treat validation
// errors as `at_eof = true` so the input renderer
// classifies them as IncompleteAtEof rather than a
// mid-input definite error. Live overlay is
// suppressed; the on-submit error still fires.
Err(ParseError::Invalid {
message,
position,
at_eof: true,
expected: Vec::new(),
})
}
}
}
fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
use crate::dsl::grammar::IdentSource;
use crate::dsl::walker::outcome::Expectation;
match e {
// ADR-0042 G1: the bare `1` that opens `add 1:n
// relationship …` is the project's only `Literal("1")`
// (grammar `ddl.rs`); on its own in an expected-set it is
// cryptic — a learner cannot know it begins a
// relationship. Render it as the named construct in error
// wording. This is render-only: completion/hints read the
// raw `Expectation::Literal("1")` directly (offering the
// literal `1` to type), so the candidate surface is
// unchanged.
Expectation::Literal("1") => "`1:n relationship`".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::Indexes => "index name".to_string(),
IdentSource::Types => "type".to_string(),
IdentSource::Generators => "generator name".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(),
Expectation::BlobLit => "blob literal".to_string(),
Expectation::Flag(name) => format!("`--{name}`"),
Expectation::BarePath => "path".to_string(),
Expectation::EndOfInput => "end of input".to_string(),
}
}
/// ADR-0042 G2: a projection start (`select |`, or the projection
/// position inside a subquery / CTE body) expects the full
/// expression first-set — 14 alternatives — plus the SELECT
/// quantifiers `distinct` and `all`. Those two quantifiers are
/// jointly expectable *only* at a projection start, so their joint
/// presence is a precise signature for collapsing the noisy list
/// into one gloss. Render-only: this fires inside
/// `format_walker_error` (the error message), not in the expected
/// set the completion/hint layer consumes.
fn is_select_projection_start(expected: &[crate::dsl::walker::outcome::Expectation]) -> bool {
use crate::dsl::walker::outcome::Expectation;
let has_word = |w: &str| {
expected
.iter()
.any(|e| matches!(e, Expectation::Word(x) if x.eq_ignore_ascii_case(w)))
};
has_word("distinct") && has_word("all")
}
/// ADR-0042 §3: detect the `… CROSS JOIN <table> on …` mistake. A
/// CROSS JOIN takes no `ON` clause; the grammar rejects the `on`,
/// but the bare structural error ("expected end of input") doesn't
/// teach why. `on` is unexpected at this position *only* when the
/// most recent join is a CROSS join — every other join flavour
/// requires `on`, so there `on` would be in the expected set, not a
/// failure. Detection: the failing token is the keyword `on`, and
/// the last `join` word in the consumed prefix is immediately
/// preceded by `cross`. Render-only; no grammar change.
fn is_cross_join_on(source: &str, position: usize) -> bool {
let rest = source[position.min(source.len())..].trim_start();
let next_is_on = {
let mut chars = rest.chars();
let starts_on = rest.len() >= 2 && rest[..2].eq_ignore_ascii_case("on");
let boundary = chars
.nth(2)
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
starts_on && boundary
};
if !next_is_on {
return false;
}
let consumed = &source[..position.min(source.len())];
let words: Vec<&str> = consumed
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.filter(|w| !w.is_empty())
.collect();
match words.iter().rposition(|w| w.eq_ignore_ascii_case("join")) {
Some(i) if i > 0 => words[i - 1].eq_ignore_ascii_case("cross"),
_ => false,
}
}
fn format_walker_error(
source: &str,
position: usize,
at_eof: bool,
expected: &[crate::dsl::walker::outcome::Expectation],
) -> String {
if is_cross_join_on(source, position) {
let consumed = source[..position.min(source.len())].trim_end();
let prefix = if consumed.is_empty() {
String::new()
} else {
format!("after `{consumed}`, ")
};
return format!("{prefix}{}", crate::t!("parse.cross_join_no_on"));
}
let joined = if is_select_projection_start(expected) {
crate::t!("parse.expect.select_projection")
} else {
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
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 if prefix.is_empty() {
format!("expected {joined}, found end of input")
} else {
format!("{prefix}expected {joined}, found end of input")
}
} else if joined.is_empty() {
"unexpected input".to_string()
} else if prefix.is_empty() {
format!("expected {joined}")
} else {
format!("{prefix}expected {joined}")
}
}
fn oxford_join(items: &[String]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} or {}", items[0], items[1]),
_ => {
let last = items.len() - 1;
let head = items[..last].join(", ");
format!("{}, or {}", head, items[last])
}
}
}
// ADR-0024 Phase F: the chumsky-side `command_parser` and its
// per-command sub-parsers (replay, export/import, mode/messages,
// the DDL family, data commands) are deleted. The unified-grammar
// walker in `crate::dsl::walker` is the sole parse path.
// `try_parse_replay_with_bare_path` and `try_parse_app_path_command`
// — the source-slice helpers that handled bare paths before the
// walker existed — are also gone; `BarePath` in the walker
// supersedes them.
// =========================================================
// Tests
// =========================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, IndexSelector, RelationshipSelector, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
use pretty_assertions::assert_eq;
// These helpers parse in **Simple mode** — the DSL surface
// (ADR-0003). The tests in this module exercise the DSL
// grammar (`insert`/`update`/`delete` Forms A/B/C, the
// `--all-rows` rail, DDL, app commands), all of which are
// canonical in Simple mode. Since sub-phase 3j made
// `insert`/`update`/`delete` shared entry words (ADR-0033 §2,
// Amendment 3), parsing these in Advanced mode would route the
// overlap to the SQL command variants; the SQL surface is
// covered by `tests/sql_*.rs` instead. No SQL-only command
// (`select`/`with`) is tested through these helpers.
fn ok(input: &str) -> Command {
parse_command_in_mode(input, Mode::Simple)
.unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
}
fn err(input: &str) -> ParseError {
parse_command_in_mode(input, Mode::Simple).expect_err("expected parse error")
}
fn err_message(input: &str) -> String {
match err(input) {
ParseError::Invalid { message, .. } => message,
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn structural_error_for_show_data_without_arg() {
// ADR-0022 stage 8c: `ident_ctx(IdentSlot::TableName)`
// labels the expected slot with "table name" so the
// error reads as the more specific "expected table
// name" rather than the generic "expected identifier".
let msg = err_message("show data");
assert!(msg.contains("after `show data`"), "{msg}");
assert!(msg.contains("expected table name"), "{msg}");
assert!(msg.contains("found end of input"), "{msg}");
}
#[test]
fn structural_error_for_change_column_with_swapped_args() {
let msg = err_message("change column Rich in Customers: Rich (text)");
assert!(msg.contains("after `change column Rich`"), "{msg}");
assert!(msg.contains("expected `:`"), "{msg}");
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec::new(name, ty)
}
#[test]
fn bare_create_table_errors_with_helpful_message() {
let e = err("create table Customers");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("with pk"),
"error should mention `with pk`:\n{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn create_table_with_pk_default_is_id_serial() {
assert_eq!(
ok("create table Customers with pk"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
#[test]
fn create_table_with_named_typed_pk() {
assert_eq!(
ok("create table Customers with pk email(text)"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn create_table_with_compound_pk() {
assert_eq!(
ok("create table OrderLines with pk order_id(int),product_id(int)"),
Command::CreateTable {
name: "OrderLines".to_string(),
columns: vec![col("order_id", Type::Int), col("product_id", Type::Int),],
primary_key: vec!["order_id".to_string(), "product_id".to_string()],
}
);
}
#[test]
fn create_table_pk_accepts_any_user_type() {
for ty in Type::all() {
let input = format!("create table T with pk col({})", ty.keyword());
let cmd = ok(&input);
if let Command::CreateTable {
columns,
primary_key,
..
} = cmd
{
assert_eq!(columns[0].ty, *ty);
assert_eq!(primary_key, vec!["col".to_string()]);
} else {
panic!("expected CreateTable for {input}");
}
}
}
#[test]
fn create_table_pk_tolerates_whitespace() {
assert_eq!(
ok("create table T with pk id ( serial )"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
assert_eq!(
ok("create table T with pk a ( int ) , b ( int )"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("a", Type::Int), col("b", Type::Int)],
primary_key: vec!["a".to_string(), "b".to_string()],
}
);
}
#[test]
fn create_table_keywords_are_case_insensitive() {
assert_eq!(
ok("CREATE TABLE Customers WITH PK email(TEXT)"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn drop_column_simple() {
assert_eq!(
ok("drop column from table Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: false,
}
);
}
#[test]
fn drop_column_accepts_bare_identifiers() {
assert_eq!(
ok("drop column Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: false,
}
);
assert_eq!(
ok("drop column from Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: false,
}
);
assert_eq!(
ok("drop column table Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: false,
}
);
}
#[test]
fn rename_column_simple() {
assert_eq!(
ok("rename column in table Customers: OldName to NewName"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "OldName".to_string(),
new: "NewName".to_string(),
}
);
}
#[test]
fn rename_column_accepts_bare_identifiers() {
assert_eq!(
ok("rename column Customers: A to B"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "A".to_string(),
new: "B".to_string(),
}
);
assert_eq!(
ok("rename column in Customers: A to B"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "A".to_string(),
new: "B".to_string(),
}
);
assert_eq!(
ok("rename column table Customers: A to B"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "A".to_string(),
new: "B".to_string(),
}
);
}
#[test]
fn change_column_simple() {
assert_eq!(
ok("change column in table Customers: Score (int)"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn change_column_accepts_bare_identifiers() {
assert_eq!(
ok("change column Customers: Score (real)"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Real,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn change_column_keywords_are_case_insensitive() {
assert_eq!(
ok("CHANGE COLUMN IN TABLE Customers: Score (TEXT)"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Text,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn change_column_with_force_conversion_flag() {
assert_eq!(
ok("change column Customers: Score (int) --force-conversion"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::ForceConversion,
}
);
}
#[test]
fn change_column_with_dont_convert_flag() {
assert_eq!(
ok("change column Customers: Score (int) --dont-convert"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::DontConvert,
}
);
}
#[test]
fn change_column_rejects_both_flags() {
let e = err("change column Customers: Score (int) --force-conversion --dont-convert");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("--force-conversion") && message.contains("--dont-convert"),
"expected both flag names in error: {message}"
);
assert!(
message.contains("mutually exclusive") || message.contains("pick one"),
"{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn change_column_rejects_both_flags_in_either_order() {
let e = err("change column T: c (int) --dont-convert --force-conversion");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("mutually exclusive"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn drop_table_simple() {
assert_eq!(
ok("drop table Customers"),
Command::DropTable {
name: "Customers".to_string()
}
);
}
#[test]
fn add_column_simple() {
assert_eq!(
ok("add column to table Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
}
);
}
#[test]
fn add_column_with_each_supported_type() {
for ty in Type::all() {
let input = format!("add column to table T: C ({})", ty.keyword());
assert_eq!(
ok(&input),
Command::AddColumn {
table: "T".to_string(),
column: "C".to_string(),
ty: *ty,
not_null: false,
unique: false,
default: None,
check: None,
}
);
}
}
#[test]
fn add_column_accepts_bare_table_name() {
assert_eq!(
ok("add column Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
}
);
}
#[test]
fn add_column_accepts_to_alone() {
assert_eq!(
ok("add column to Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
}
);
}
#[test]
fn add_column_accepts_table_alone() {
assert_eq!(
ok("add column table Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
}
);
}
#[test]
fn add_column_tolerates_whitespace_around_punctuation() {
assert_eq!(
ok("add column to table T:Name(text)"),
Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
}
);
}
#[test]
fn empty_input_is_an_explicit_empty_error() {
assert_eq!(parse_command(""), Err(ParseError::Empty));
assert_eq!(parse_command(" "), Err(ParseError::Empty));
}
#[test]
fn unknown_command_errors() {
let e = err("frobulate Customers");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn unknown_type_errors_with_alternatives_listed() {
let e = err("add column to table T: Name (varchar)");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("varchar"),
"error should mention the bad type: {message}"
);
assert!(
message.contains("expected one of"),
"error should list valid alternatives: {message}"
);
assert!(
message.contains("text") && message.contains("shortid"),
"error should name the alternatives: {message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn unknown_pk_type_errors_with_alternatives_listed() {
let e = err("create table T with pk id(varchar)");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("varchar"), "{message}");
assert!(message.contains("expected one of"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn trailing_garbage_errors() {
let e = err("create table Customers with pk and pickles");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_must_start_with_letter_or_underscore() {
let e = err("create table 1Customers with pk");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
fn rel(
name: Option<&str>,
parent: (&str, &str),
child: (&str, &str),
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
) -> Command {
Command::AddRelationship {
name: name.map(String::from),
parent_table: parent.0.to_string(),
parent_columns: vec![parent.1.to_string()],
child_table: child.0.to_string(),
child_columns: vec![child.1.to_string()],
on_delete,
on_update,
create_fk,
}
}
#[test]
fn add_relationship_minimal() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_name() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete_set_null() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete set null"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::SetNull,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_both_actions_in_either_order() {
let expected = rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::SetNull,
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e =
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn add_relationship_with_create_fk_flag() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId --create-fk"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_keywords_are_case_insensitive() {
assert_eq!(
ok("ADD 1:N RELATIONSHIP FROM Customers.Id TO Orders.CustId ON DELETE CASCADE"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_unknown_action_errors() {
let e = err("add 1:n relationship from C.id to O.cid on delete obliterate");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn insert_with_explicit_column_list() {
assert_eq!(
ok("insert into Customers (Name, Email) values ('Alice', 'a@b.com')"),
Command::Insert {
table: "Customers".to_string(),
columns: Some(vec!["Name".to_string(), "Email".to_string()]),
values: vec![
Value::Text("Alice".to_string()),
Value::Text("a@b.com".to_string()),
],
}
);
}
#[test]
fn insert_short_form_omitting_values_keyword() {
assert_eq!(
ok("insert into Customers ('Alice')"),
Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![Value::Text("Alice".to_string())],
}
);
}
#[test]
fn insert_short_form_without_column_list() {
assert_eq!(
ok("insert into Customers values ('Alice', 'a@b.com')"),
Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![
Value::Text("Alice".to_string()),
Value::Text("a@b.com".to_string()),
],
}
);
}
#[test]
fn insert_accepts_mixed_value_kinds() {
assert_eq!(
ok("insert into T values (1, 3.14, 'hi', true, null)"),
Command::Insert {
table: "T".to_string(),
columns: None,
values: vec![
Value::Number("1".to_string()),
Value::Number("3.14".to_string()),
Value::Text("hi".to_string()),
Value::Bool(true),
Value::Null,
],
}
);
}
#[test]
fn insert_supports_negative_numbers() {
assert_eq!(
ok("insert into T values (-5, -3.14)"),
Command::Insert {
table: "T".to_string(),
columns: None,
values: vec![
Value::Number("-5".to_string()),
Value::Number("-3.14".to_string()),
],
}
);
}
#[test]
fn string_literal_supports_escaped_single_quote() {
assert_eq!(
ok("insert into T values ('don''t panic')"),
Command::Insert {
table: "T".to_string(),
columns: None,
values: vec![Value::Text("don't panic".to_string())],
}
);
}
#[test]
fn update_with_where() {
assert_eq!(
ok("update Customers set Name='Alice' where id=1"),
Command::Update {
table: "Customers".to_string(),
assignments: vec![("Name".to_string(), Value::Text("Alice".to_string()))],
filter: RowFilter::eq("id", Value::Number("1".to_string())),
}
);
}
#[test]
fn update_with_multiple_assignments() {
assert_eq!(
ok("update Customers set Name='Alice', Email='a@b.com' where id=1"),
Command::Update {
table: "Customers".to_string(),
assignments: vec![
("Name".to_string(), Value::Text("Alice".to_string())),
("Email".to_string(), Value::Text("a@b.com".to_string())),
],
filter: RowFilter::eq("id", Value::Number("1".to_string())),
}
);
}
#[test]
fn update_with_all_rows_flag() {
assert_eq!(
ok("update Customers set Active=false --all-rows"),
Command::Update {
table: "Customers".to_string(),
assignments: vec![("Active".to_string(), Value::Bool(false))],
filter: RowFilter::AllRows,
}
);
}
#[test]
fn update_without_where_or_flag_errors() {
let e = err("update Customers set Active=false");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn delete_with_where() {
assert_eq!(
ok("delete from Customers where id=1"),
Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::eq("id", Value::Number("1".to_string())),
}
);
}
#[test]
fn delete_with_all_rows_flag() {
assert_eq!(
ok("delete from Customers --all-rows"),
Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::AllRows,
}
);
}
#[test]
fn delete_without_where_or_flag_errors() {
let e = err("delete from Customers");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
// =====================================================
// Sub-phase 3j — shared-entry-word dispatch (ADR-0033 §2,
// Amendment 1 / Amendment 3).
//
// `insert` / `update` / `delete` are *shared* entry words: a
// `Simple` DSL node and an `Advanced` SQL node both register
// under each. A command's identity is the outcome of the
// mode-rooted grammar path:
// - Advanced mode tries the SQL shape first and falls back to
// the DSL shape only when the SQL shape *structurally* can't
// match (e.g. the DSL-only `--all-rows` flag). A content
// rejection (a `__rdbms_*` target) on the SQL shape is
// surfaced, never masked by the DSL fallback.
// - Simple mode commits the DSL shape; it points the user at
// advanced mode ("this is SQL") only when the input is
// SQL-only (the DSL shape structurally mismatches and the SQL
// shape matches — e.g. a `returning` tail). A DSL command
// that is merely incomplete or has a bad value still commits
// the DSL node so the user sees DSL completion / DSL errors.
// The §6/§7 parity guarantees mean the two variants execute to
// identical effects for an overlapping input.
// =====================================================
#[test]
fn advanced_ambiguous_insert_routes_to_sql() {
assert!(matches!(
parse_command_in_mode("insert into Orders values (1, 2)", Mode::Advanced),
Ok(Command::SqlInsert { .. })
));
}
#[test]
fn advanced_ambiguous_update_routes_to_sql() {
assert!(matches!(
parse_command_in_mode(
"update Orders set total = 0 where id = 1",
Mode::Advanced,
),
Ok(Command::SqlUpdate { .. })
));
}
#[test]
fn advanced_ambiguous_delete_routes_to_sql() {
assert!(matches!(
parse_command_in_mode("delete from Orders where id = 1", Mode::Advanced),
Ok(Command::SqlDelete { .. })
));
}
#[test]
fn advanced_dsl_only_delete_falls_back_to_dsl() {
// `--all-rows` is DSL-only; the SQL DELETE shape can't consume
// the trailing flag, so dispatch falls back to the DSL node.
assert_eq!(
parse_command_in_mode("delete from Orders --all-rows", Mode::Advanced).unwrap(),
Command::Delete {
table: "Orders".to_string(),
filter: RowFilter::AllRows,
},
);
}
#[test]
fn simple_mode_data_commands_reject_internal_tables() {
// ADR-0030 §6 ("every table-source slot") / `/runda` finding
// B: the DSL data-command target slots reject `__rdbms_*`
// internal tables in simple mode too — matching the SQL
// grammar. Without this, simple-mode DML could read/write the
// internal metadata tables while advanced-mode SQL rejected
// them.
for input in [
"insert into __rdbms_playground_columns values (1)",
"update __rdbms_playground_columns set x = 1 where id = 1",
"delete from __rdbms_playground_columns where id = 1",
"show data __rdbms_playground_columns",
"show table __rdbms_playground_relationships",
] {
assert!(
parse_command_in_mode(input, Mode::Simple).is_err(),
"internal table must be rejected in simple mode: {input:?}",
);
}
}
#[test]
fn advanced_internal_table_insert_is_rejected_not_fallen_back() {
// The SQL insert's `reject_internal_table` rail must surface
// even though the DSL insert node lacks it: a content
// rejection commits the SQL candidate rather than falling
// through to the DSL node that would accept it.
assert!(
parse_command_in_mode(
"insert into __rdbms_playground_columns values (1)",
Mode::Advanced,
)
.is_err(),
);
}
#[test]
fn simple_dsl_delete_stays_dsl() {
assert_eq!(
parse_command_in_mode("delete from Orders where id = 1", Mode::Simple).unwrap(),
Command::Delete {
table: "Orders".to_string(),
filter: RowFilter::eq("id", Value::Number("1".to_string())),
},
);
}
#[test]
fn simple_sql_only_entry_word_points_at_advanced_mode() {
// A SQL-only *entry word* (`select`) has no DSL form, so
// simple mode emits the "this is SQL" hint at the parse level
// (ADR-0030 §2).
match parse_command_in_mode("select Name from Orders", Mode::Simple) {
Err(ParseError::Invalid { message, .. }) => assert!(
message.contains("advanced"),
"expected the this-is-SQL hint, got: {message}",
),
other => panic!("expected the this-is-SQL hint, got {other:?}"),
}
}
#[test]
fn simple_shared_word_with_sql_construct_is_a_dsl_parse_error() {
// `returning` is SQL-only, but `delete` is a *shared* entry
// word, so simple mode commits the DSL shape and surfaces a
// DSL parse error (ADR-0033 Amendment 3). The "(valid as SQL
// in advanced mode)" pointer is added at the hint layer
// (input_render), not in the parsed command/error here.
assert!(matches!(
parse_command_in_mode(
"delete from Orders where id = 1 returning *",
Mode::Simple,
),
Err(ParseError::Invalid { .. })
));
}
#[test]
fn show_data_command() {
assert_eq!(
ok("show data Customers"),
Command::ShowData {
name: "Customers".to_string(),
filter: None,
limit: None,
}
);
}
#[test]
fn drop_relationship_by_name() {
assert_eq!(
ok("drop relationship cust_orders"),
Command::DropRelationship {
selector: RelationshipSelector::Named {
name: "cust_orders".to_string()
}
}
);
}
#[test]
fn show_table_simple() {
assert_eq!(
ok("show table Customers"),
Command::ShowTable {
name: "Customers".to_string()
}
);
}
#[test]
fn drop_relationship_by_endpoints() {
assert_eq!(
ok("drop relationship from Customers.Id to Orders.CustId"),
Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: "Customers".to_string(),
parent_column: "Id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
}
}
);
}
// --- add index / drop index (ADR-0025) ---
#[test]
fn add_index_named() {
assert_eq!(
ok("add index as idx_email on Customers (Email)"),
Command::AddIndex {
name: Some("idx_email".to_string()),
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
}
);
}
#[test]
fn add_index_unnamed() {
assert_eq!(
ok("add index on Customers (Email)"),
Command::AddIndex {
name: None,
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
}
);
}
#[test]
fn add_index_composite_columns() {
assert_eq!(
ok("add index on Orders (CustId, Date)"),
Command::AddIndex {
name: None,
table: "Orders".to_string(),
columns: vec!["CustId".to_string(), "Date".to_string()],
}
);
}
#[test]
fn drop_index_by_name() {
assert_eq!(
ok("drop index idx_email"),
Command::DropIndex {
selector: IndexSelector::Named {
name: "idx_email".to_string(),
},
}
);
}
#[test]
fn drop_index_by_columns() {
assert_eq!(
ok("drop index on Customers (Email)"),
Command::DropIndex {
selector: IndexSelector::Columns {
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
},
}
);
}
#[test]
fn drop_column_cascade_flag() {
assert_eq!(
ok("drop column Customers: Email --cascade"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: true,
}
);
}
#[test]
fn identifier_allows_underscores_and_digits_after_start() {
assert_eq!(
ok("create table customer_v2 with pk"),
Command::CreateTable {
name: "customer_v2".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
// --- replay <path> ---
#[test]
fn replay_with_bare_relative_path() {
assert_eq!(
ok("replay history.log"),
Command::Replay {
path: "history.log".to_string(),
}
);
}
#[test]
fn replay_with_bare_absolute_path() {
assert_eq!(
ok("replay /tmp/seed.commands"),
Command::Replay {
path: "/tmp/seed.commands".to_string(),
}
);
}
#[test]
fn replay_with_quoted_path_supports_whitespace() {
assert_eq!(
ok("replay 'my project/seed.commands'"),
Command::Replay {
path: "my project/seed.commands".to_string(),
}
);
}
#[test]
fn replay_with_quoted_path_supports_escaped_quote() {
assert_eq!(
ok("replay 'O''Brien.commands'"),
Command::Replay {
path: "O'Brien.commands".to_string(),
}
);
}
#[test]
fn replay_keyword_is_case_insensitive() {
assert_eq!(
ok("REPLAY foo.txt"),
Command::Replay {
path: "foo.txt".to_string(),
}
);
}
#[test]
fn replay_without_path_errors() {
let e = err("replay");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn replay_with_empty_quoted_path_errors() {
// The quoted-path form of `replay` goes through chumsky.
// An empty quoted path `''` lexes as a StringLiteral with
// an empty payload, which the parser accepts as
// syntactically valid; the runtime rejects an empty path
// before any I/O. Test pinned to the runtime layer rather
// than the parser layer to match the new architecture.
// (The pre-tokenizer parser caught this at parse time via
// `path_literal`'s try_map; under the lexer split, that
// check moves down a layer.)
match parse_command("replay ''") {
Ok(Command::Replay { path }) => assert_eq!(path, ""),
other => panic!("expected Replay with empty path, got {other:?}"),
}
}
}