6ca297579e
Completes the i18n sweep started in the previous commit. All
remaining hand-rolled user-facing English strings inside
thiserror #[error(...)] attributes have been moved into the
catalog. Drops the thiserror dependency entirely.
Twelve error types migrated:
- dsl::action::UnknownAction → parse.custom.unknown_action
- dsl::parser::ParseError → parse.error_wrapper + parse.empty
- dsl::value::ValueError → value.{type_mismatch,format}
- persistence::csv_io::CsvError → persistence.csv.*
- persistence::mod::PersistenceError → persistence.{io,encode}
- persistence::yaml::YamlError → persistence.yaml.*
- persistence::migrations::MigrateError → persistence.migrate.*
- project::lock::LockError → project.lock.*
- project::naming::NamingError → project.naming.*
- project::naming::UserNameError → project.user_name.*
- project::mod::ProjectError → project.{path_not_found,...}
- project::mod::SafeDeleteError → project.safe_delete.*
- archive::ArchiveError → archive.*
- cli::ArgsError → cli.*
- db::DbError → db.error.*
Pattern per type: drop thiserror::Error derive, write manual
Display calling crate::t!(), keep #[from] semantics via
explicit From impls, override Error::source() where applicable
so #[source]-style chaining is preserved.
Why this matters (user rationale): "fine to have fallbacks for
errors that are purely technical, but lift the output to a
place where it can be localized later and where an adjustment
with friendly text is easily possible if any of them become
part of the happy path." All surface strings now live in
en-US.yaml and can be reworded or localized without touching
Rust source.
Tests: 769 passing, 0 failed, 1 ignored. Clippy clean with
-D warnings. Cargo.toml: drop thiserror = "2.0.18".
1989 lines
65 KiB
Rust
1989 lines
65 KiB
Rust
//! DSL parser (ADR-0020 + ADR-0021).
|
|
//!
|
|
//! Two-phase: a lexer (`crate::dsl::lexer`) produces a span-tagged
|
|
//! token stream, and chumsky combinators over `&[Token]` build the
|
|
//! `Command` AST. Keyword identity is exact via the `Keyword` enum
|
|
//! from `crate::dsl::keyword`; alternative-aggregation across
|
|
//! `choice` is chumsky-native (the load-bearing fix that motivated
|
|
//! ADR-0020).
|
|
//!
|
|
//! Errors from chumsky are mapped to the local [`ParseError`] type
|
|
//! so callers do not depend on chumsky's API surface.
|
|
|
|
use chumsky::error::{RichPattern, RichReason};
|
|
use chumsky::prelude::*;
|
|
|
|
use crate::dsl::action::ReferentialAction;
|
|
use crate::dsl::command::{
|
|
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
|
|
RelationshipSelector, RowFilter,
|
|
};
|
|
use crate::dsl::ident_slot::IdentSlot;
|
|
use crate::dsl::keyword::{Keyword, Punct};
|
|
use crate::dsl::lexer::{LexError, Token, TokenKind, lex};
|
|
use crate::dsl::types::Type;
|
|
use crate::dsl::value::Value;
|
|
|
|
#[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.
|
|
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
|
|
if input.trim().is_empty() {
|
|
return Err(ParseError::Empty);
|
|
}
|
|
let tokens = lex(input);
|
|
parse_tokens(&tokens, input)
|
|
}
|
|
|
|
/// Parse a token slice into a `Command`. The `source` argument is
|
|
/// kept in scope so the `replay` bare-path special case
|
|
/// (ADR-0020 §6) can source-slice its argument.
|
|
///
|
|
/// Public so future I3 (tab completion) and I4 (syntax
|
|
/// highlighting) work can re-enter the parser at this layer
|
|
/// without having to re-lex.
|
|
pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseError> {
|
|
if tokens.is_empty() {
|
|
return Err(ParseError::Empty);
|
|
}
|
|
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
|
|
return result;
|
|
}
|
|
if let Some(result) = try_parse_app_path_command(tokens, source) {
|
|
return result;
|
|
}
|
|
match command_parser().parse(tokens).into_result() {
|
|
Ok(cmd) => Ok(cmd),
|
|
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
|
|
}
|
|
}
|
|
|
|
/// `replay` source-slice special case (ADR-0020 §6).
|
|
///
|
|
/// `replay <bare-path>` lets the user write paths containing
|
|
/// `/`, `.`, `~`, etc. — characters that the lexer would either
|
|
/// classify as `Punct` or as `Error(UnknownChar)`. To keep the
|
|
/// existing UX working, we detect `replay` followed by anything
|
|
/// other than a `StringLiteral` and source-slice the rest of
|
|
/// the input as the path. The quoted form (`replay '<path>'`)
|
|
/// goes through the regular chumsky path.
|
|
fn try_parse_replay_with_bare_path(
|
|
tokens: &[Token],
|
|
source: &str,
|
|
) -> Option<Result<Command, ParseError>> {
|
|
let first = tokens.first()?;
|
|
if !matches!(first.kind, TokenKind::Keyword(Keyword::Replay)) {
|
|
return None;
|
|
}
|
|
if matches!(
|
|
tokens.get(1).map(|t| &t.kind),
|
|
Some(TokenKind::StringLiteral(_))
|
|
) {
|
|
// Quoted form — chumsky handles it (and rejects any
|
|
// trailing garbage).
|
|
return None;
|
|
}
|
|
let after_replay = first.span.1;
|
|
let rest = source[after_replay..].trim();
|
|
if rest.is_empty() {
|
|
// `replay` with nothing after — produce the same shape
|
|
// of error chumsky would (positioned where the path
|
|
// should have started).
|
|
return Some(Err(ParseError::Invalid {
|
|
message: crate::t!("parse.custom.replay_path_expected"),
|
|
position: after_replay,
|
|
at_eof: true,
|
|
expected: vec!["path".to_string()],
|
|
}));
|
|
}
|
|
Some(Ok(Command::Replay {
|
|
path: rest.to_string(),
|
|
}))
|
|
}
|
|
|
|
/// `export <path>` / `import <path> [as <target>]` source-slice
|
|
/// special case. Same rationale as `try_parse_replay_with_bare_path`
|
|
/// — bare paths contain `/`, `.`, `~` which the lexer would either
|
|
/// split into separate tokens or refuse outright.
|
|
///
|
|
/// Returns `None` for the bare-keyword forms (`export`, `import`
|
|
/// alone), letting the regular chumsky path handle them and
|
|
/// surface the no-arg `Command::App(...)` variant.
|
|
fn try_parse_app_path_command(
|
|
tokens: &[Token],
|
|
source: &str,
|
|
) -> Option<Result<Command, ParseError>> {
|
|
use crate::dsl::command::AppCommand;
|
|
let first = tokens.first()?;
|
|
let kw = match &first.kind {
|
|
TokenKind::Keyword(Keyword::Export) => Keyword::Export,
|
|
TokenKind::Keyword(Keyword::Import) => Keyword::Import,
|
|
_ => return None,
|
|
};
|
|
let after = first.span.1;
|
|
let rest = source[after..].trim();
|
|
if rest.is_empty() {
|
|
return None;
|
|
}
|
|
match kw {
|
|
Keyword::Export => Some(Ok(Command::App(AppCommand::Export {
|
|
path: Some(rest.to_string()),
|
|
}))),
|
|
Keyword::Import => {
|
|
// Trailing `as` with no target is a recognised user
|
|
// mistake — surface the usage hint as a parse error
|
|
// (catalog wording stays in sync with the existing
|
|
// dispatch-time error).
|
|
if rest == "as" || rest.ends_with(" as") {
|
|
return Some(Err(ParseError::Invalid {
|
|
message: crate::t!("project.import_empty_target"),
|
|
position: after + rest.len(),
|
|
at_eof: true,
|
|
expected: Vec::new(),
|
|
}));
|
|
}
|
|
let (path, target) = match rest.split_once(" as ") {
|
|
Some((p, t)) => (p.trim().to_string(), Some(t.trim().to_string())),
|
|
None => (rest.to_string(), None),
|
|
};
|
|
if path.is_empty() {
|
|
return Some(Err(ParseError::Invalid {
|
|
message: crate::t!("project.import_usage"),
|
|
position: after,
|
|
at_eof: true,
|
|
expected: vec!["path".to_string()],
|
|
}));
|
|
}
|
|
Some(Ok(Command::App(AppCommand::Import { path, target })))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// Token-aware combinator helpers (ADR-0020 §5)
|
|
// =========================================================
|
|
|
|
/// Match a specific keyword token.
|
|
fn kw<'a>(
|
|
target: Keyword,
|
|
) -> impl Parser<'a, &'a [Token], (), extra::Err<Rich<'a, Token>>> + Clone {
|
|
select_ref! {
|
|
Token { kind: TokenKind::Keyword(k), .. } if *k == target => ()
|
|
}
|
|
.labelled(format!("`{}`", target.as_str()))
|
|
.as_context()
|
|
}
|
|
|
|
/// Match a specific punctuation token.
|
|
fn punct<'a>(
|
|
target: Punct,
|
|
) -> impl Parser<'a, &'a [Token], (), extra::Err<Rich<'a, Token>>> + Clone {
|
|
select_ref! {
|
|
Token { kind: TokenKind::Punct(p), .. } if *p == target => ()
|
|
}
|
|
.labelled(format!("`{}`", target.as_char()))
|
|
.as_context()
|
|
}
|
|
|
|
/// Match any identifier token, returning its name. Internal —
|
|
/// command parsers must use `ident_ctx(slot)` so the
|
|
/// completion engine knows what kind of identifier each
|
|
/// position expects (ADR-0022 §8). Bare `ident_inner()` calls
|
|
/// outside this module would skip the slot annotation. The
|
|
/// label is applied by `ident_ctx` (one per call site) — none
|
|
/// here.
|
|
fn ident_inner<'a>()
|
|
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
|
select_ref! {
|
|
Token { kind: TokenKind::Identifier(s), .. } => s.clone()
|
|
}
|
|
}
|
|
|
|
/// Tag-and-parse an identifier slot. The slot's user-facing
|
|
/// label (`IdentSlot::expected_label`) replaces the generic
|
|
/// "identifier" in the parser's expected-set machinery, so
|
|
/// the error message reads "expected table name" /
|
|
/// "expected column name" / "expected relationship name" /
|
|
/// "expected identifier" depending on the call site
|
|
/// (ADR-0022 stage 8c). The completion engine reverses the
|
|
/// mapping via `IdentSlot::from_expected_label` to know what
|
|
/// schema list to consult.
|
|
fn ident_ctx<'a>(
|
|
slot: crate::dsl::ident_slot::IdentSlot,
|
|
) -> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
|
ident_inner().labelled(slot.expected_label()).as_context()
|
|
}
|
|
|
|
/// Match a number-literal token, returning a `Value::Number`.
|
|
fn number_literal<'a>()
|
|
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
|
|
select_ref! {
|
|
Token { kind: TokenKind::Number(s), .. } => Value::Number(s.clone())
|
|
}
|
|
.labelled("number")
|
|
.as_context()
|
|
}
|
|
|
|
/// Match a string-literal token, returning a `Value::Text`.
|
|
fn string_literal<'a>()
|
|
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
|
|
select_ref! {
|
|
Token { kind: TokenKind::StringLiteral(s), .. } => Value::Text(s.clone())
|
|
}
|
|
.labelled("string literal")
|
|
.as_context()
|
|
}
|
|
|
|
/// Match a string-literal token, returning the raw payload
|
|
/// (used by the quoted-replay path).
|
|
fn string_payload<'a>()
|
|
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
|
select_ref! {
|
|
Token { kind: TokenKind::StringLiteral(s), .. } => s.clone()
|
|
}
|
|
.labelled("path")
|
|
.as_context()
|
|
}
|
|
|
|
/// Match a flag token whose payload equals `name` (the part
|
|
/// after `--`).
|
|
fn flag<'a>(
|
|
name: &'static str,
|
|
) -> impl Parser<'a, &'a [Token], (), extra::Err<Rich<'a, Token>>> + Clone {
|
|
select_ref! {
|
|
Token { kind: TokenKind::Flag(s), .. } if s == name => ()
|
|
}
|
|
.labelled(format!("`--{name}`"))
|
|
.as_context()
|
|
}
|
|
|
|
/// Match an identifier and parse it as a `Type`. Surfaces the
|
|
/// existing "unknown type 'X' (expected one of: …)" message
|
|
/// (ADR-0020 §4) — keyword-shape errors aggregate naturally,
|
|
/// content errors keep their hand-written voice.
|
|
///
|
|
/// Labelled "type" so the structural-error wording reads as
|
|
/// "next: type" rather than the unhelpful "something else"
|
|
/// the unlabelled `select_ref!` would otherwise produce.
|
|
fn type_keyword<'a>()
|
|
-> impl Parser<'a, &'a [Token], Type, extra::Err<Rich<'a, Token>>> + Clone {
|
|
// Label is applied to the select-ref alone (before
|
|
// try_map) so the unknown-type custom error from try_map
|
|
// still surfaces — labelled() on the whole chain would
|
|
// replace it with "expected type" and lose the
|
|
// "unknown type 'X' (expected one of: …)" wording.
|
|
select_ref! {
|
|
Token { kind: TokenKind::Identifier(s), .. } = e => (s.clone(), e.span())
|
|
}
|
|
.labelled("type")
|
|
.try_map(|(name, span): (String, SimpleSpan), _| {
|
|
name.parse::<Type>()
|
|
.map_err(|err| Rich::custom(span, err.to_string()))
|
|
})
|
|
}
|
|
|
|
// =========================================================
|
|
// Top-level command parser
|
|
// =========================================================
|
|
|
|
fn command_parser<'a>()
|
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
|
let create_table = kw(Keyword::Create)
|
|
.ignore_then(kw(Keyword::Table))
|
|
.ignore_then(ident_ctx(IdentSlot::NewName))
|
|
.then(with_pk_clause())
|
|
.try_map(|(name, pk_specs), span| {
|
|
if pk_specs.is_empty() {
|
|
return Err(Rich::custom(
|
|
span,
|
|
crate::t!("parse.custom.create_table_needs_pk"),
|
|
));
|
|
}
|
|
let columns: Vec<ColumnSpec> = pk_specs
|
|
.iter()
|
|
.map(|(n, t)| ColumnSpec {
|
|
name: n.clone(),
|
|
ty: *t,
|
|
})
|
|
.collect();
|
|
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
|
|
Ok(Command::CreateTable {
|
|
name,
|
|
columns,
|
|
primary_key,
|
|
})
|
|
});
|
|
|
|
let drop_table = kw(Keyword::Drop)
|
|
.ignore_then(kw(Keyword::Table))
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.map(|name| Command::DropTable { name });
|
|
|
|
// `add column [to] [table] <T>: <col> (<type>)`. Both
|
|
// prepositions independently optional — bare identifiers
|
|
// accepted in the unambiguous position.
|
|
let add_column = kw(Keyword::Add)
|
|
.ignore_then(kw(Keyword::Column))
|
|
.ignore_then(kw(Keyword::To).or_not())
|
|
.ignore_then(kw(Keyword::Table).or_not())
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.then_ignore(punct(Punct::Colon))
|
|
.then(ident_ctx(IdentSlot::NewName))
|
|
.then_ignore(punct(Punct::OpenParen))
|
|
.then(type_keyword())
|
|
.then_ignore(punct(Punct::CloseParen))
|
|
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
|
|
|
|
let drop_column = kw(Keyword::Drop)
|
|
.ignore_then(kw(Keyword::Column))
|
|
.ignore_then(kw(Keyword::From).or_not())
|
|
.ignore_then(kw(Keyword::Table).or_not())
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.then_ignore(punct(Punct::Colon))
|
|
.then(ident_ctx(IdentSlot::Column))
|
|
.map(|(table, column)| Command::DropColumn { table, column });
|
|
|
|
let rename_column = kw(Keyword::Rename)
|
|
.ignore_then(kw(Keyword::Column))
|
|
.ignore_then(kw(Keyword::In).or_not())
|
|
.ignore_then(kw(Keyword::Table).or_not())
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.then_ignore(punct(Punct::Colon))
|
|
.then(ident_ctx(IdentSlot::Column))
|
|
.then_ignore(kw(Keyword::To))
|
|
.then(ident_ctx(IdentSlot::NewName))
|
|
.map(|((table, old), new)| Command::RenameColumn { table, old, new });
|
|
|
|
let change_column = kw(Keyword::Change)
|
|
.ignore_then(kw(Keyword::Column))
|
|
.ignore_then(kw(Keyword::In).or_not())
|
|
.ignore_then(kw(Keyword::Table).or_not())
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.then_ignore(punct(Punct::Colon))
|
|
.then(ident_ctx(IdentSlot::Column))
|
|
.then_ignore(punct(Punct::OpenParen))
|
|
.then(type_keyword())
|
|
.then_ignore(punct(Punct::CloseParen))
|
|
.then(change_column_flags())
|
|
.map(|(((table, column), ty), mode)| Command::ChangeColumnType {
|
|
table,
|
|
column,
|
|
ty,
|
|
mode,
|
|
});
|
|
|
|
let add_relationship = add_relationship_parser();
|
|
let drop_relationship = drop_relationship_parser();
|
|
|
|
let show_data = kw(Keyword::Show)
|
|
.ignore_then(kw(Keyword::Data))
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.map(|name| Command::ShowData { name });
|
|
|
|
let show_table = kw(Keyword::Show)
|
|
.ignore_then(kw(Keyword::Table))
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.map(|name| Command::ShowTable { name });
|
|
|
|
let insert_cmd = insert_parser();
|
|
let update_cmd = update_parser();
|
|
let delete_cmd = delete_parser();
|
|
|
|
// The bare-path replay form is intercepted before chumsky
|
|
// sees the tokens (ADR-0020 §6); only the quoted form
|
|
// arrives here.
|
|
let replay = kw(Keyword::Replay)
|
|
.ignore_then(string_payload())
|
|
.map(|path| Command::Replay { path });
|
|
|
|
// ---- App-lifecycle commands -----------------------------
|
|
// No-arg variants and the keyword-value variants. Path-
|
|
// bearing variants (`export <path>`, `import <zip> [as
|
|
// <target>]`) are handled by `try_parse_app_path_command`
|
|
// BEFORE chumsky runs; the bare-keyword forms below
|
|
// surface the `Path: None` / no-source variants for
|
|
// empty-prompt completion + usage rendering.
|
|
let quit_cmd = choice((kw(Keyword::Quit), kw(Keyword::Q)))
|
|
.map(|()| Command::App(AppCommand::Quit));
|
|
let help_cmd = kw(Keyword::Help).map(|()| Command::App(AppCommand::Help));
|
|
let rebuild_cmd =
|
|
kw(Keyword::Rebuild).map(|()| Command::App(AppCommand::Rebuild));
|
|
// `save as` must be tried before bare `save` (more specific).
|
|
let save_as_cmd = kw(Keyword::Save)
|
|
.then_ignore(kw(Keyword::As))
|
|
.map(|()| Command::App(AppCommand::SaveAs));
|
|
let save_cmd = kw(Keyword::Save).map(|()| Command::App(AppCommand::Save));
|
|
let new_cmd = kw(Keyword::New).map(|()| Command::App(AppCommand::New));
|
|
let load_cmd = kw(Keyword::Load).map(|()| Command::App(AppCommand::Load));
|
|
let export_no_arg =
|
|
kw(Keyword::Export).map(|()| Command::App(AppCommand::Export { path: None }));
|
|
let import_no_arg = kw(Keyword::Import).map(|()| {
|
|
Command::App(AppCommand::Import {
|
|
path: String::new(),
|
|
target: None,
|
|
})
|
|
});
|
|
// `mode <value>` and `messages [<value>]` accept either the
|
|
// known keyword forms or any identifier — the identifier
|
|
// branch funnels through `try_map` into a friendly
|
|
// `mode.unknown` / `messages.unknown` error rather than the
|
|
// generic structural-error wording. Mirrors the type-name
|
|
// pattern in `type_keyword` (ADR-0020 §4).
|
|
let known_mode = choice((
|
|
kw(Keyword::Simple).to(ModeValue::Simple),
|
|
kw(Keyword::Advanced).to(ModeValue::Advanced),
|
|
));
|
|
let unknown_mode = ident_inner().try_map(|s, span| {
|
|
Err::<ModeValue, _>(Rich::custom(
|
|
span,
|
|
crate::t!("mode.unknown", value = s),
|
|
))
|
|
});
|
|
let mode_cmd = kw(Keyword::Mode)
|
|
.ignore_then(choice((known_mode, unknown_mode)))
|
|
.map(|value| Command::App(AppCommand::Mode { value }));
|
|
let known_messages = choice((
|
|
kw(Keyword::Short).to(MessagesValue::Short),
|
|
kw(Keyword::Verbose).to(MessagesValue::Verbose),
|
|
));
|
|
let unknown_messages = ident_inner().try_map(|s, span| {
|
|
Err::<MessagesValue, _>(Rich::custom(
|
|
span,
|
|
crate::t!("messages.unknown", value = s),
|
|
))
|
|
});
|
|
let messages_cmd = kw(Keyword::Messages)
|
|
.ignore_then(choice((known_messages, unknown_messages)).or_not())
|
|
.map(|value| Command::App(AppCommand::Messages { value }));
|
|
|
|
choice((
|
|
create_table,
|
|
// `drop column` and `drop relationship` come before
|
|
// `drop table` because both are more specific —
|
|
// chumsky's `choice` tries each in order.
|
|
drop_column,
|
|
drop_relationship,
|
|
drop_table,
|
|
add_column,
|
|
add_relationship,
|
|
rename_column,
|
|
change_column,
|
|
show_data,
|
|
show_table,
|
|
insert_cmd,
|
|
update_cmd,
|
|
delete_cmd,
|
|
replay,
|
|
// App commands. `save as` before bare `save`; everything
|
|
// else order-agnostic.
|
|
quit_cmd,
|
|
help_cmd,
|
|
rebuild_cmd,
|
|
save_as_cmd,
|
|
save_cmd,
|
|
new_cmd,
|
|
load_cmd,
|
|
export_no_arg,
|
|
import_no_arg,
|
|
mode_cmd,
|
|
messages_cmd,
|
|
))
|
|
.then_ignore(end())
|
|
}
|
|
|
|
// =========================================================
|
|
// Per-command sub-parsers
|
|
// =========================================================
|
|
|
|
fn insert_parser<'a>()
|
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
|
let column_list = punct(Punct::OpenParen)
|
|
.ignore_then(
|
|
ident_ctx(IdentSlot::Column)
|
|
.separated_by(punct(Punct::Comma))
|
|
.at_least(1)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.then_ignore(punct(Punct::CloseParen));
|
|
|
|
let value_list = punct(Punct::OpenParen)
|
|
.ignore_then(
|
|
value_literal()
|
|
.separated_by(punct(Punct::Comma))
|
|
.at_least(1)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.then_ignore(punct(Punct::CloseParen));
|
|
|
|
let with_columns_and_values = column_list
|
|
.clone()
|
|
.then_ignore(kw(Keyword::Values))
|
|
.then(value_list.clone())
|
|
.map(|(cols, vals)| (Some(cols), vals));
|
|
|
|
let with_values_keyword_only = kw(Keyword::Values)
|
|
.ignore_then(value_list.clone())
|
|
.map(|vals| (None, vals));
|
|
|
|
let bare_value_list = value_list.map(|vals| (None, vals));
|
|
|
|
kw(Keyword::Insert)
|
|
.ignore_then(kw(Keyword::Into))
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.then(choice((
|
|
with_columns_and_values,
|
|
with_values_keyword_only,
|
|
bare_value_list,
|
|
)))
|
|
.map(|(table, (columns, values))| Command::Insert {
|
|
table,
|
|
columns,
|
|
values,
|
|
})
|
|
}
|
|
|
|
fn update_parser<'a>()
|
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
|
let assignment = ident_ctx(IdentSlot::Column)
|
|
.then_ignore(punct(Punct::Equals))
|
|
.then(value_literal());
|
|
|
|
let assignments = assignment
|
|
.separated_by(punct(Punct::Comma))
|
|
.at_least(1)
|
|
.collect::<Vec<_>>();
|
|
|
|
kw(Keyword::Update)
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.then_ignore(kw(Keyword::Set))
|
|
.then(assignments)
|
|
.then(filter_clause())
|
|
.map(|((table, assignments), filter)| Command::Update {
|
|
table,
|
|
assignments,
|
|
filter,
|
|
})
|
|
}
|
|
|
|
fn delete_parser<'a>()
|
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
|
kw(Keyword::Delete)
|
|
.ignore_then(kw(Keyword::From))
|
|
.ignore_then(ident_ctx(IdentSlot::TableName))
|
|
.then(filter_clause())
|
|
.map(|(table, filter)| Command::Delete { table, filter })
|
|
}
|
|
|
|
fn filter_clause<'a>()
|
|
-> impl Parser<'a, &'a [Token], RowFilter, extra::Err<Rich<'a, Token>>> + Clone {
|
|
let where_clause = kw(Keyword::Where)
|
|
.ignore_then(ident_ctx(IdentSlot::Column))
|
|
.then_ignore(punct(Punct::Equals))
|
|
.then(value_literal())
|
|
.map(|(column, value)| RowFilter::Where { column, value });
|
|
|
|
let all_rows = flag("all-rows").to(RowFilter::AllRows);
|
|
|
|
// No `.labelled()` wrap here: chumsky's expected-set then
|
|
// surfaces the constituent options (`` `where` ``,
|
|
// `` `--all-rows` ``) individually instead of collapsing
|
|
// them to a single descriptive label. The completion
|
|
// engine needs the constituents to offer Tab candidates
|
|
// (ADR-0022 §8); the resulting error prose ("expected `,`,
|
|
// `where`, or `--all-rows`") reads cleanly enough without
|
|
// hand-wrapping.
|
|
where_clause.or(all_rows)
|
|
}
|
|
|
|
fn value_literal<'a>()
|
|
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
|
|
choice((
|
|
kw(Keyword::Null).to(Value::Null),
|
|
kw(Keyword::True).to(Value::Bool(true)),
|
|
kw(Keyword::False).to(Value::Bool(false)),
|
|
number_literal(),
|
|
string_literal(),
|
|
))
|
|
}
|
|
|
|
fn add_relationship_parser<'a>()
|
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
|
// `1:n` lexes as Number("1"), Punct(Colon), Identifier("n").
|
|
let one_token = select_ref! {
|
|
Token { kind: TokenKind::Number(s), .. } if s == "1" => ()
|
|
}
|
|
.labelled("`1`")
|
|
.as_context();
|
|
|
|
let n_ident = select_ref! {
|
|
Token { kind: TokenKind::Identifier(s), .. } if s.eq_ignore_ascii_case("n") => ()
|
|
}
|
|
.labelled("`n`")
|
|
.as_context();
|
|
|
|
let one_to_n = one_token
|
|
.ignore_then(punct(Punct::Colon))
|
|
.ignore_then(n_ident);
|
|
|
|
let optional_name = kw(Keyword::As).ignore_then(ident_ctx(IdentSlot::NewName)).or_not();
|
|
|
|
kw(Keyword::Add)
|
|
.ignore_then(one_to_n)
|
|
.ignore_then(kw(Keyword::Relationship))
|
|
.ignore_then(optional_name)
|
|
.then_ignore(kw(Keyword::From))
|
|
.then(qualified_column())
|
|
.then_ignore(kw(Keyword::To))
|
|
.then(qualified_column())
|
|
.then(referential_clauses())
|
|
.then(create_fk_flag())
|
|
.map(
|
|
|((((name, parent), child), (on_delete, on_update)), create_fk)| {
|
|
Command::AddRelationship {
|
|
name,
|
|
parent_table: parent.0,
|
|
parent_column: parent.1,
|
|
child_table: child.0,
|
|
child_column: child.1,
|
|
on_delete,
|
|
on_update,
|
|
create_fk,
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn drop_relationship_parser<'a>()
|
|
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
|
let endpoints_form = kw(Keyword::From)
|
|
.ignore_then(qualified_column())
|
|
.then_ignore(kw(Keyword::To))
|
|
.then(qualified_column())
|
|
.map(|(parent, child)| RelationshipSelector::Endpoints {
|
|
parent_table: parent.0,
|
|
parent_column: parent.1,
|
|
child_table: child.0,
|
|
child_column: child.1,
|
|
});
|
|
|
|
let named_form = ident_ctx(IdentSlot::RelationshipName)
|
|
.map(|name| RelationshipSelector::Named { name });
|
|
|
|
kw(Keyword::Drop)
|
|
.ignore_then(kw(Keyword::Relationship))
|
|
.ignore_then(choice((endpoints_form, named_form)))
|
|
.map(|selector| Command::DropRelationship { selector })
|
|
}
|
|
|
|
fn qualified_column<'a>()
|
|
-> impl Parser<'a, &'a [Token], (String, String), extra::Err<Rich<'a, Token>>> + Clone {
|
|
ident_ctx(IdentSlot::TableName)
|
|
.then_ignore(punct(Punct::Dot))
|
|
.then(ident_ctx(IdentSlot::Column))
|
|
}
|
|
|
|
fn referential_clauses<'a>() -> impl Parser<
|
|
'a,
|
|
&'a [Token],
|
|
(ReferentialAction, ReferentialAction),
|
|
extra::Err<Rich<'a, Token>>,
|
|
> + Clone {
|
|
let target = kw(Keyword::Delete)
|
|
.to(ReferentialActionTarget::Delete)
|
|
.or(kw(Keyword::Update).to(ReferentialActionTarget::Update));
|
|
let clause = kw(Keyword::On)
|
|
.ignore_then(target)
|
|
.then(action_keyword())
|
|
.map(|(t, a)| (t, a));
|
|
clause
|
|
.repeated()
|
|
.at_most(2)
|
|
.collect::<Vec<_>>()
|
|
.try_map(|clauses, span| {
|
|
let mut on_delete = None;
|
|
let mut on_update = None;
|
|
for (target, action) in clauses {
|
|
let slot = match target {
|
|
ReferentialActionTarget::Delete => &mut on_delete,
|
|
ReferentialActionTarget::Update => &mut on_update,
|
|
};
|
|
if slot.is_some() {
|
|
return Err(Rich::custom(
|
|
span,
|
|
crate::t!(
|
|
"parse.custom.on_action_specified_twice",
|
|
target = target,
|
|
),
|
|
));
|
|
}
|
|
*slot = Some(action);
|
|
}
|
|
Ok((
|
|
on_delete.unwrap_or_else(ReferentialAction::default_action),
|
|
on_update.unwrap_or_else(ReferentialAction::default_action),
|
|
))
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum ReferentialActionTarget {
|
|
Delete,
|
|
Update,
|
|
}
|
|
|
|
impl std::fmt::Display for ReferentialActionTarget {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
Self::Delete => "delete",
|
|
Self::Update => "update",
|
|
})
|
|
}
|
|
}
|
|
|
|
fn action_keyword<'a>()
|
|
-> impl Parser<'a, &'a [Token], ReferentialAction, extra::Err<Rich<'a, Token>>> + Clone {
|
|
choice((
|
|
kw(Keyword::Set)
|
|
.ignore_then(kw(Keyword::Null))
|
|
.to(ReferentialAction::SetNull),
|
|
kw(Keyword::No)
|
|
.ignore_then(kw(Keyword::Action))
|
|
.to(ReferentialAction::NoAction),
|
|
kw(Keyword::Cascade).to(ReferentialAction::Cascade),
|
|
kw(Keyword::Restrict).to(ReferentialAction::Restrict),
|
|
))
|
|
}
|
|
|
|
fn create_fk_flag<'a>()
|
|
-> impl Parser<'a, &'a [Token], bool, extra::Err<Rich<'a, Token>>> + Clone {
|
|
flag("create-fk").or_not().map(|opt| opt.is_some())
|
|
}
|
|
|
|
fn change_column_flags<'a>()
|
|
-> impl Parser<'a, &'a [Token], ChangeColumnMode, extra::Err<Rich<'a, Token>>> + Clone {
|
|
let force = flag("force-conversion").to(ChangeColumnMode::ForceConversion);
|
|
let dont = flag("dont-convert").to(ChangeColumnMode::DontConvert);
|
|
choice((force, dont))
|
|
.repeated()
|
|
.collect::<Vec<_>>()
|
|
.try_map(|flags, span| match flags.as_slice() {
|
|
[] => Ok(ChangeColumnMode::Default),
|
|
[single] => Ok(*single),
|
|
_ => Err(Rich::custom(
|
|
span,
|
|
crate::t!("parse.custom.change_column_flags_exclusive"),
|
|
)),
|
|
})
|
|
}
|
|
|
|
fn with_pk_clause<'a>()
|
|
-> impl Parser<'a, &'a [Token], Vec<(String, Type)>, extra::Err<Rich<'a, Token>>> + Clone {
|
|
// Each PK spec names a NEW column inside the table being
|
|
// created. `with_pk_clause` is reached only inside
|
|
// `create_table`, where the surrounding context is
|
|
// building a new schema entity from scratch.
|
|
let single = ident_ctx(IdentSlot::NewName)
|
|
.then_ignore(punct(Punct::Colon))
|
|
.then(type_keyword())
|
|
.map(|(name, ty)| (name, ty));
|
|
|
|
let spec_list = single
|
|
.clone()
|
|
.separated_by(punct(Punct::Comma))
|
|
.at_least(1)
|
|
.collect::<Vec<_>>();
|
|
|
|
kw(Keyword::With)
|
|
.ignore_then(kw(Keyword::Pk))
|
|
.ignore_then(spec_list.or_not())
|
|
.map(|maybe_specs| {
|
|
// `with pk` alone defaults to a serial id PK.
|
|
maybe_specs.unwrap_or_else(|| vec![("id".to_string(), Type::Serial)])
|
|
})
|
|
.or_not()
|
|
.map(Option::unwrap_or_default)
|
|
}
|
|
|
|
// =========================================================
|
|
// Error humanisation
|
|
// =========================================================
|
|
|
|
fn into_parse_error(errs: &[Rich<'_, Token>], tokens: &[Token], source: &str) -> ParseError {
|
|
// Prefer custom-reason errors over chumsky's structural
|
|
// ones — those carry our hand-tuned messages from `try_map`
|
|
// (e.g. "unknown type 'varchar' (expected one of: ...)").
|
|
let chosen = errs
|
|
.iter()
|
|
.find(|e| matches!(e.reason(), RichReason::Custom(_)))
|
|
.unwrap_or_else(|| errs.first().expect("parser failure with no error"));
|
|
let chumsky_span = chosen.span();
|
|
let position = source_position_at(tokens, chumsky_span.start, source);
|
|
let message = humanise(chosen, tokens, source);
|
|
let (at_eof, expected) = match chosen.reason() {
|
|
// Structural failures know whether they ran out of
|
|
// input — `found = None` ⇔ EOF — and carry the
|
|
// expected-pattern set chumsky was looking for.
|
|
RichReason::ExpectedFound { expected, found } => {
|
|
(found.is_none(), describe_expected(expected))
|
|
}
|
|
// Custom errors: see the docstring on
|
|
// `ParseError::Invalid::at_eof` for why we err on the
|
|
// side of `true` (no live overlay; on-submit error
|
|
// still fires). Custom errors have no expected-set.
|
|
RichReason::Custom(_) => (true, Vec::new()),
|
|
};
|
|
ParseError::Invalid {
|
|
message,
|
|
position,
|
|
at_eof,
|
|
expected,
|
|
}
|
|
}
|
|
|
|
/// Render a chumsky expected-pattern set into the same
|
|
/// human-readable forms `humanise()` uses, but as discrete
|
|
/// items rather than an oxford-joined string. Stable order
|
|
/// (sorted, deduplicated) so callers don't have to.
|
|
fn describe_expected(expected: &[RichPattern<'_, Token>]) -> Vec<String> {
|
|
let has_concrete = expected.iter().any(|p| {
|
|
matches!(
|
|
p,
|
|
RichPattern::Token(_)
|
|
| RichPattern::Identifier(_)
|
|
| RichPattern::Label(_)
|
|
| RichPattern::EndOfInput
|
|
)
|
|
});
|
|
let mut items: Vec<String> = expected
|
|
.iter()
|
|
.filter(|p| {
|
|
!(has_concrete && matches!(p, RichPattern::Any | RichPattern::SomethingElse))
|
|
})
|
|
.map(describe_pattern)
|
|
.collect();
|
|
// Dedup preserving first occurrence (which reflects
|
|
// chumsky's traversal order — typically source order for
|
|
// `or_not` / `choice` chains). Empirically this gives a
|
|
// grammar-natural ordering: `to` before `table` in
|
|
// `add column [to] [table] …`, which alphabetical
|
|
// (table, to) would invert.
|
|
let mut seen = std::collections::HashSet::new();
|
|
items.retain(|s| seen.insert(s.clone()));
|
|
items
|
|
}
|
|
|
|
/// Translate a chumsky token-slice index into a byte position
|
|
/// in the original source. If the index points past the last
|
|
/// token (an end-of-input failure), use the last token's end
|
|
/// or, if there are no tokens, the source length.
|
|
fn source_position_at(tokens: &[Token], slice_index: usize, source: &str) -> usize {
|
|
if slice_index < tokens.len() {
|
|
tokens[slice_index].span.0
|
|
} else {
|
|
tokens.last().map_or(source.len(), |t| t.span.1)
|
|
}
|
|
}
|
|
|
|
fn humanise(err: &Rich<'_, Token>, tokens: &[Token], source: &str) -> String {
|
|
if let RichReason::Custom(msg) = err.reason() {
|
|
return msg.clone();
|
|
}
|
|
let RichReason::ExpectedFound { expected, found } = err.reason() else {
|
|
unreachable!("RichReason has only two variants today");
|
|
};
|
|
// `found` is the offending token (or None at end of input).
|
|
let found_str = found.as_ref().map_or_else(
|
|
|| "end of input".to_string(),
|
|
|maybe_ref| describe_token(maybe_ref),
|
|
);
|
|
|
|
let described = describe_expected(expected);
|
|
let expected_str = oxford_or(&described);
|
|
|
|
let chumsky_span_start = err.span().start;
|
|
let consumed = consumed_context(tokens, chumsky_span_start, source);
|
|
|
|
if expected.is_empty() {
|
|
if consumed.is_empty() {
|
|
format!("unexpected {found_str}")
|
|
} else {
|
|
format!("after `{consumed}`, unexpected {found_str}")
|
|
}
|
|
} else if consumed.is_empty() {
|
|
format!("expected {expected_str}, found {found_str}")
|
|
} else {
|
|
format!("after `{consumed}`, expected {expected_str}, found {found_str}")
|
|
}
|
|
}
|
|
|
|
fn describe_pattern(p: &RichPattern<'_, Token>) -> String {
|
|
match p {
|
|
RichPattern::Token(t) => describe_token(t),
|
|
RichPattern::Identifier(s) => format!("`{s}`"),
|
|
RichPattern::Label(s) => s.to_string(),
|
|
RichPattern::Any => "any token".to_string(),
|
|
RichPattern::SomethingElse => "something else".to_string(),
|
|
RichPattern::EndOfInput => "end of input".to_string(),
|
|
// RichPattern is non_exhaustive; cover the catch-all.
|
|
_ => "<other>".to_string(),
|
|
}
|
|
}
|
|
|
|
fn describe_token(t: &Token) -> String {
|
|
match &t.kind {
|
|
TokenKind::Keyword(k) => format!("`{}`", k.as_str()),
|
|
TokenKind::Identifier(s) => format!("`{s}`"),
|
|
TokenKind::Number(s) => format!("`{s}`"),
|
|
TokenKind::StringLiteral(_) => "string literal".to_string(),
|
|
TokenKind::Punct(p) => format!("`{}`", p.as_char()),
|
|
TokenKind::Flag(s) => format!("`--{s}`"),
|
|
TokenKind::Error(LexError::UnknownChar(c)) => {
|
|
format!("unrecognised character `{c}`")
|
|
}
|
|
TokenKind::Error(LexError::UnterminatedString) => {
|
|
"unterminated string literal".to_string()
|
|
}
|
|
TokenKind::Error(LexError::BadFlag) => "malformed flag (bare `--`)".to_string(),
|
|
}
|
|
}
|
|
|
|
/// "A, B, or C" / "A or B" / "A".
|
|
fn oxford_or(items: &[String]) -> String {
|
|
match items {
|
|
[] => String::new(),
|
|
[a] => a.clone(),
|
|
[a, b] => format!("{a} or {b}"),
|
|
rest => {
|
|
let (last, head) = rest.split_last().expect("len >= 3");
|
|
format!("{}, or {last}", head.join(", "))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Source slice covering all tokens before the failure point,
|
|
/// trimmed to a sensible length.
|
|
fn consumed_context(tokens: &[Token], chumsky_span_start: usize, source: &str) -> String {
|
|
if chumsky_span_start == 0 {
|
|
return String::new();
|
|
}
|
|
let last_consumed_index = chumsky_span_start - 1;
|
|
let Some(last_token) = tokens.get(last_consumed_index) else {
|
|
return String::new();
|
|
};
|
|
let prefix = source[..last_token.span.1].trim();
|
|
if prefix.is_empty() {
|
|
return String::new();
|
|
}
|
|
const MAX: usize = 40;
|
|
if prefix.chars().count() <= MAX {
|
|
prefix.to_string()
|
|
} else {
|
|
let tail: String = prefix
|
|
.chars()
|
|
.rev()
|
|
.take(MAX)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect();
|
|
format!("…{tail}")
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// Tests
|
|
// =========================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
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:?}"),
|
|
}
|
|
}
|
|
}
|