a41400e532
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).
1215 lines
38 KiB
Rust
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:?}"),
|
|
}
|
|
}
|
|
}
|