Files
rdbms-playground/src/dsl/parser.rs
T
claude@clouddev1 a41400e532 ADR-0024 Phase F (full) step 2: usage via CommandNode.usage_ids
Migrates parse-error usage-block rendering from the legacy
`dsl::usage::matched_entry` (which scanned a `Vec<Token>` for the
first matched Keyword) to walker-side lookup driven by each
`CommandNode`'s `usage_ids` slice.

`CommandNode.usage_id: Option<&'static str>` becomes
`usage_ids: &'static [&'static str]`. Multi-form families
(`drop`, `add`, `show`) carry every variant — `drop` lists
table/column/relationship templates; `add` lists column /
relationship; `show` lists data / table. The single-shape
commands carry their single catalog key.

App-lifecycle CommandNodes had pointed at non-existent
`parse.usage.app.*` keys (never noticed because the field was
unused); they now point at the real catalog entries
(`parse.usage.quit`, `parse.usage.help`, …).

New helpers in `dsl::grammar`:
- `usage_keys_for_input(source) -> Option<(entry_word, usage_ids)>`
  resolves the first identifier-shape token to a CommandNode and
  returns its usage_ids list. Used by `app::render_usage_block`
  and `input_render::ambient_hint`.
- `entry_words_alphabetised() -> Vec<&'static str>` replaces
  `dsl::usage::entry_keywords_alphabetised`.

`dsl::usage` is deleted. The "available commands:" fallback in
`render_usage_block` now formats entry words as `` `<word>` ``
directly (matching the `parse.token.keyword.*` catalog renders);
the per-keyword catalog wrappers will collapse in the next step
(ADR-0024 §cleanup-pass §F).

`parse_command` and `parse_tokens` slim down:
- `parse_command(input)` no longer pre-lexes — the walker scans
  source bytes directly.
- `parse_tokens` (internal-only `pub` for "future I3/I4 work")
  is removed; its body folded into `parse_command`.
- `unknown_command_error` reads the walker registry directly.

Touched modules also drop their `crate::dsl::lexer::lex` and
`crate::dsl::usage` imports: `app.rs`, `input_render.rs`,
`completion.rs`.

Tests: 852 passing, 0 failing, 1 ignored (down from 860 because
the 8 `dsl::usage::tests::*` tests are gone with the module).
2026-05-15 08:27:16 +00:00

1215 lines
38 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 crate::dsl::command::Command;
#[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.
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
if input.trim().is_empty() {
return Err(ParseError::Empty);
}
if let Some(result) = try_walker_route(input) {
return result;
}
Err(unknown_command_error(input))
}
/// 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 (ADR-0024 §migration Phase A). Returns `None`
/// when the walker doesn't engage (input doesn't start with a
/// migrated entry keyword); the router falls through to the
/// chumsky path for non-migrated commands.
fn try_walker_route(source: &str) -> Option<Result<Command, ParseError>> {
use crate::dsl::walker::{self, outcome::WalkBound};
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(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 {
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(),
Expectation::BlobLit => "blob literal".to_string(),
Expectation::Flag(name) => format!("`--{name}`"),
Expectation::BarePath => "path".to_string(),
Expectation::EndOfInput => "end of input".to_string(),
}
}
fn format_walker_error(
source: &str,
position: usize,
at_eof: bool,
expected: &[crate::dsl::walker::outcome::Expectation],
) -> 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 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, RelationshipSelector, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
use pretty_assertions::assert_eq;
fn ok(input: &str) -> Command {
parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
}
fn err(input: &str) -> ParseError {
parse_command(input).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 {
name: name.to_string(),
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(),
}
);
}
#[test]
fn drop_column_accepts_bare_identifiers() {
assert_eq!(
ok("drop column Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
}
);
assert_eq!(
ok("drop column from Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
}
);
assert_eq!(
ok("drop column table Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
}
);
}
#[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,
}
);
}
#[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,
}
);
}
}
#[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,
}
);
}
#[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,
}
);
}
#[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,
}
);
}
#[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,
}
);
}
#[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_column: parent.1.to_string(),
child_table: child.0.to_string(),
child_column: 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::Where {
column: "id".to_string(),
value: 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::Where {
column: "id".to_string(),
value: 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::Where {
column: "id".to_string(),
value: 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:?}");
}
#[test]
fn show_data_command() {
assert_eq!(
ok("show data Customers"),
Command::ShowData {
name: "Customers".to_string()
}
);
}
#[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(),
}
}
);
}
#[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:?}"),
}
}
}