DSL parser, async DB worker, types, history, metadata, polish
Track 1 implementation plus polish round. Parser (chumsky): - Grammar-based DSL producing a typed Command AST. - create table X with pk [name:type[,name:type...]] supports arbitrary names, any user type, compound PKs natively. Bare form errors with a friendly hint pointing at `with pk`. - add column to table X: Name (type); drop table X. - Required clauses use keyword grammar; -- reserved for opt-in flags (ADR-0009). Custom Rich reasons preferred when surfacing chumsky errors so unknown-type messages list valid alternatives. Database (ADR-0010, ADR-0012): - rusqlite + STRICT tables + foreign_keys=ON. - Dedicated worker thread; mpsc Request inbox, oneshot replies. - Typed DbError with friendly_message() hook for H1. - Internal __rdbms_playground_columns metadata table preserves user-facing types across schema reads, atomically maintained alongside DDL via Connection transactions. list_tables hides it via the new __rdbms_ internal-table convention. Types (ADR-0005, ADR-0011): - All ten user-facing types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid. - Type::fk_target_type() for FK-side column-type rule (Serial->Int, ShortId->Text, others identity) -- foundation for the FK iteration. App / Runtime / UI: - update() stays pure-sync; runtime dispatches DSL via spawned tasks, results post back as AppEvent::Dsl*. - Items panel renders live tables list; output panel shows the user-facing structure of the current table after each DDL. - In-memory command history (Up/Down, draft preservation, consecutive-duplicate dedup) -- I2 partial. - Mouse capture removed; terminal native text selection restored (toggle approach revisited when scroll/click features land). Docs: - ADRs 0009 (DSL syntax conventions), 0010 (DB worker), 0011 (FK type compat), 0012 (internal metadata table). - requirements.md progress notes; new V4 entry for the scrollable session-log + inline rich rendering + Markdown export direction. Tests: 103 passing (91 lib + 12 integration), 0 skipped. Clippy clean with nursery enabled.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
//! The Command AST.
|
||||
//!
|
||||
//! `Command` is the parser's output and the database worker's
|
||||
//! input. Each variant carries fully validated data — the parser
|
||||
//! is responsible for shape, the database worker for semantics
|
||||
//! (e.g. "table does not exist").
|
||||
//!
|
||||
//! The shape supports compound primary keys natively even though
|
||||
//! only the dedicated `with pk a:int,b:int` grammar exposes them
|
||||
//! today. Future grammar extensions (inline column specs, `set
|
||||
//! primary key`, junction-table convenience commands) emit into
|
||||
//! the same shape.
|
||||
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
/// A column at table-creation time: a name and a user-facing
|
||||
/// type. Constraints beyond `PRIMARY KEY` (NOT NULL, UNIQUE,
|
||||
/// CHECK, DEFAULT) come in later iterations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColumnSpec {
|
||||
pub name: String,
|
||||
pub ty: Type,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
CreateTable {
|
||||
name: String,
|
||||
/// Columns to create, in declaration order.
|
||||
columns: Vec<ColumnSpec>,
|
||||
/// Names of columns forming the primary key. Length 1 is
|
||||
/// a single PK; length >= 2 is a compound PK; length 0
|
||||
/// indicates no primary key (a future grammar option,
|
||||
/// not produced by today's parser).
|
||||
primary_key: Vec<String>,
|
||||
},
|
||||
DropTable {
|
||||
name: String,
|
||||
},
|
||||
AddColumn {
|
||||
table: String,
|
||||
column: String,
|
||||
ty: Type,
|
||||
},
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Short label for log output and result rendering.
|
||||
#[must_use]
|
||||
pub const fn verb(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CreateTable { .. } => "create table",
|
||||
Self::DropTable { .. } => "drop table",
|
||||
Self::AddColumn { .. } => "add column",
|
||||
}
|
||||
}
|
||||
|
||||
/// The table this command targets — every Command in this
|
||||
/// iteration operates on exactly one table.
|
||||
#[must_use]
|
||||
pub fn target_table(&self) -> &str {
|
||||
match self {
|
||||
Self::CreateTable { name, .. } | Self::DropTable { name } => name,
|
||||
Self::AddColumn { table, .. } => table,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! The Playground DSL.
|
||||
//!
|
||||
//! The DSL is the simplified, beginner-friendly command surface
|
||||
//! described in ADR-0003. This module owns its grammar
|
||||
//! (`parser`), its abstract syntax tree (`command`), and the
|
||||
//! user-facing type vocabulary (`types`).
|
||||
//!
|
||||
//! Raw SQL handling for advanced mode is intentionally *not* in
|
||||
//! this module — that path uses `sqlparser-rs` and lives
|
||||
//! elsewhere when it lands.
|
||||
|
||||
pub mod command;
|
||||
pub mod parser;
|
||||
pub mod types;
|
||||
|
||||
pub use command::{ColumnSpec, Command};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Grammar-based DSL parser built on chumsky.
|
||||
//!
|
||||
//! The parser produces a `Command` AST directly — there is no
|
||||
//! intermediate token tree to translate. Composable rules
|
||||
//! (identifier, type keyword, padded keyword) are defined once
|
||||
//! and reused across command variants, which is the point of
|
||||
//! choosing a grammar approach (see Phase 2/3 selection).
|
||||
//!
|
||||
//! Errors from chumsky are mapped to the local `ParseError` type
|
||||
//! so callers do not depend on chumsky's API surface — that
|
||||
//! keeps the parser swappable if we ever revisit the choice.
|
||||
|
||||
use chumsky::error::RichReason;
|
||||
use chumsky::prelude::*;
|
||||
|
||||
use crate::dsl::command::{ColumnSpec, Command};
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
#[error("could not parse command: {message}")]
|
||||
Invalid { message: String, position: usize },
|
||||
#[error("empty input")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
#[must_use]
|
||||
pub const fn position(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Invalid { position, .. } => Some(*position),
|
||||
Self::Empty => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single DSL command.
|
||||
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ParseError::Empty);
|
||||
}
|
||||
match command_parser().parse(trimmed).into_result() {
|
||||
Ok(cmd) => Ok(cmd),
|
||||
Err(errs) => Err(into_parse_error(&errs, trimmed)),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_parse_error(errs: &[Rich<'_, char>], input: &str) -> ParseError {
|
||||
// Prefer custom-reason errors over chumsky's structural
|
||||
// ones — those carry our friendly messages from `try_map`
|
||||
// (e.g. "unknown type 'varchar' (expected one of: ...)").
|
||||
let chosen = errs
|
||||
.iter()
|
||||
.find(|e| has_custom_reason(e.reason()))
|
||||
.unwrap_or_else(|| errs.first().expect("parser failure with no error"));
|
||||
let span = chosen.span();
|
||||
let position = span.start;
|
||||
let message = humanise(chosen, input);
|
||||
ParseError::Invalid { message, position }
|
||||
}
|
||||
|
||||
const fn has_custom_reason<T, C>(reason: &RichReason<'_, T, C>) -> bool {
|
||||
matches!(reason, RichReason::Custom(_))
|
||||
}
|
||||
|
||||
fn humanise(err: &Rich<'_, char>, input: &str) -> String {
|
||||
// For custom errors, the underlying message is what we want
|
||||
// to show, not chumsky's "found ... expected ..." rendering.
|
||||
if let Some(msg) = first_custom_message(err.reason()) {
|
||||
return msg;
|
||||
}
|
||||
let span = err.span();
|
||||
let snippet: String = input
|
||||
.chars()
|
||||
.skip(span.start)
|
||||
.take((span.end - span.start).max(1))
|
||||
.collect();
|
||||
if snippet.is_empty() {
|
||||
format!("{err}")
|
||||
} else {
|
||||
format!("{err} (near `{snippet}`)")
|
||||
}
|
||||
}
|
||||
|
||||
fn first_custom_message<T>(reason: &RichReason<'_, T, String>) -> Option<String> {
|
||||
match reason {
|
||||
RichReason::Custom(msg) => Some(msg.clone()),
|
||||
RichReason::ExpectedFound { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level command parser.
|
||||
fn command_parser<'a>()
|
||||
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let create_table = keyword_ci("create")
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(identifier())
|
||||
.then(with_pk_clause())
|
||||
.try_map(|(name, pk_specs), span| {
|
||||
if pk_specs.is_empty() {
|
||||
return Err(Rich::custom(
|
||||
span,
|
||||
"tables need at least one column. Add `with pk` for a default \
|
||||
`id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. \
|
||||
Use a comma-separated list for compound primary keys."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
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 = keyword_ci("drop")
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(identifier())
|
||||
.map(|name| Command::DropTable { name });
|
||||
|
||||
let add_column = keyword_ci("add")
|
||||
.ignore_then(keyword_ci("column"))
|
||||
.ignore_then(keyword_ci("to"))
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(identifier())
|
||||
.then_ignore(just(':').padded())
|
||||
.then(identifier())
|
||||
.then_ignore(just('(').padded())
|
||||
.then(type_keyword())
|
||||
.then_ignore(just(')').padded())
|
||||
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
|
||||
|
||||
choice((create_table, drop_table, add_column))
|
||||
.padded()
|
||||
.then_ignore(end())
|
||||
}
|
||||
|
||||
/// Parse the optional `with pk [<spec>]` clause that may follow
|
||||
/// `create table <Name>`. Returns the list of (name, type) pairs
|
||||
/// that form the primary key. An absent clause returns an empty
|
||||
/// vector; a present `with pk` (no spec) returns the default
|
||||
/// `id:serial`. Compound PK is a comma-separated list of specs.
|
||||
fn with_pk_clause<'a>()
|
||||
-> impl Parser<'a, &'a str, Vec<(String, Type)>, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let single = identifier()
|
||||
.then_ignore(just(':').padded())
|
||||
.then(type_keyword())
|
||||
.map(|(name, ty)| (name, ty));
|
||||
|
||||
let spec_list = single
|
||||
.clone()
|
||||
.separated_by(just(',').padded())
|
||||
.at_least(1)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
keyword_ci("with")
|
||||
.ignore_then(keyword_ci("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)
|
||||
}
|
||||
|
||||
/// Identifier: a letter or underscore followed by letters,
|
||||
/// digits, or underscores. Returned as an owned `String` so the
|
||||
/// `Command` AST has no lifetime tying it to the input.
|
||||
fn identifier<'a>()
|
||||
-> impl Parser<'a, &'a str, String, extra::Err<Rich<'a, char>>> + Clone {
|
||||
any()
|
||||
.filter(|c: &char| c.is_ascii_alphabetic() || *c == '_')
|
||||
.then(
|
||||
any()
|
||||
.filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_')
|
||||
.repeated()
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map(|(first, rest)| {
|
||||
let mut s = String::with_capacity(rest.len() + 1);
|
||||
s.push(first);
|
||||
s.extend(rest);
|
||||
s
|
||||
})
|
||||
.padded()
|
||||
}
|
||||
|
||||
/// One of the supported type keywords, mapped to `Type`. The
|
||||
/// `try_map` yields a `Custom` Rich error on unknown input,
|
||||
/// which carries the friendly "unknown type 'X' (expected one
|
||||
/// of: ...)" message — surfaced via `humanise()`. Note: no
|
||||
/// `.labelled` here, because that would replace the custom
|
||||
/// message with a generic "expected type".
|
||||
fn type_keyword<'a>()
|
||||
-> impl Parser<'a, &'a str, Type, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let alphabetic = any()
|
||||
.filter(|c: &char| c.is_ascii_alphabetic())
|
||||
.repeated()
|
||||
.at_least(1)
|
||||
.collect::<String>();
|
||||
alphabetic.padded().try_map(|word, span| {
|
||||
word.parse::<Type>()
|
||||
.map_err(|e| Rich::custom(span, e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Case-insensitive keyword matcher. Consumes leading and
|
||||
/// trailing whitespace and, importantly, requires a word
|
||||
/// boundary so `create` does not match a prefix of `created`.
|
||||
fn keyword_ci<'a>(
|
||||
kw: &'static str,
|
||||
) -> impl Parser<'a, &'a str, (), extra::Err<Rich<'a, char>>> + Clone {
|
||||
let alphabetic = any()
|
||||
.filter(|c: &char| c.is_ascii_alphabetic())
|
||||
.repeated()
|
||||
.at_least(1)
|
||||
.collect::<String>();
|
||||
alphabetic.padded().try_map(move |word, span| {
|
||||
if word.eq_ignore_ascii_case(kw) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Rich::custom(
|
||||
span,
|
||||
format!("expected '{kw}', found '{word}'"),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[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 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() {
|
||||
// Pedagogical freedom — the grammar imposes no
|
||||
// "sensible PK type" filter. Every user-facing type is
|
||||
// accepted; learners discover for themselves.
|
||||
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_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_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:?}");
|
||||
}
|
||||
|
||||
#[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()],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! User-facing column types and their mapping to SQLite STRICT.
|
||||
//!
|
||||
//! Implements the full ten-type vocabulary committed to in
|
||||
//! ADR-0005. Storage choices for the text-backed types
|
||||
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
|
||||
//! readability; comparisons rely on lexicographic ordering or
|
||||
//! explicit casts at query time, which is acceptable for a
|
||||
//! teaching tool and is documented in user-facing help.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Type {
|
||||
/// UTF-8 text of any length.
|
||||
Text,
|
||||
/// 64-bit signed integer.
|
||||
Int,
|
||||
/// IEEE-754 double-precision float.
|
||||
Real,
|
||||
/// Arbitrary-precision decimal stored as a string. Sorts and
|
||||
/// compares lexicographically; numeric ops require casts.
|
||||
Decimal,
|
||||
/// Boolean stored as 0/1; rendered `true`/`false`.
|
||||
Bool,
|
||||
/// ISO 8601 date stored as `YYYY-MM-DD` (TEXT).
|
||||
Date,
|
||||
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
|
||||
DateTime,
|
||||
/// Arbitrary binary data.
|
||||
Blob,
|
||||
/// Auto-incrementing integer; intended as a default primary key.
|
||||
Serial,
|
||||
/// 10–12 character base58 random identifier (no ambiguous chars).
|
||||
ShortId,
|
||||
}
|
||||
|
||||
impl Type {
|
||||
/// The user-facing keyword as it appears in DSL input.
|
||||
#[must_use]
|
||||
pub const fn keyword(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Int => "int",
|
||||
Self::Real => "real",
|
||||
Self::Decimal => "decimal",
|
||||
Self::Bool => "bool",
|
||||
Self::Date => "date",
|
||||
Self::DateTime => "datetime",
|
||||
Self::Blob => "blob",
|
||||
Self::Serial => "serial",
|
||||
Self::ShortId => "shortid",
|
||||
}
|
||||
}
|
||||
|
||||
/// The SQLite STRICT type clause for this column. The
|
||||
/// `serial` type also implies `PRIMARY KEY` semantics in
|
||||
/// `sqlite_strict_extra` — see [`Self::sqlite_strict_extra`].
|
||||
#[must_use]
|
||||
pub const fn sqlite_strict_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text
|
||||
| Self::ShortId
|
||||
| Self::Decimal
|
||||
| Self::Date
|
||||
| Self::DateTime => "TEXT",
|
||||
Self::Int | Self::Serial | Self::Bool => "INTEGER",
|
||||
Self::Real => "REAL",
|
||||
Self::Blob => "BLOB",
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra clause appended after the type in DDL — e.g.
|
||||
/// `PRIMARY KEY` for `serial`. Empty when no extra clause
|
||||
/// applies.
|
||||
#[must_use]
|
||||
pub const fn sqlite_strict_extra(self) -> &'static str {
|
||||
match self {
|
||||
Self::Serial => " PRIMARY KEY",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// All types known in this iteration, in stable order.
|
||||
/// Ordering groups numeric types together, then boolean,
|
||||
/// then temporal, then binary, then identity-flavoured
|
||||
/// auto-generated types.
|
||||
#[must_use]
|
||||
pub const fn all() -> &'static [Self] {
|
||||
&[
|
||||
Self::Text,
|
||||
Self::Int,
|
||||
Self::Real,
|
||||
Self::Decimal,
|
||||
Self::Bool,
|
||||
Self::Date,
|
||||
Self::DateTime,
|
||||
Self::Blob,
|
||||
Self::Serial,
|
||||
Self::ShortId,
|
||||
]
|
||||
}
|
||||
|
||||
/// The user-facing type that an FK column should use to
|
||||
/// reference a primary key of *this* type. For most types
|
||||
/// the answer is the same type; for `serial` and `shortid`
|
||||
/// it differs, because those types carry insert-time
|
||||
/// auto-generation semantics that only apply on the PK
|
||||
/// side. The FK side stores plain looked-up values, so:
|
||||
///
|
||||
/// - `serial` → `int` (FK holds a plain integer value)
|
||||
/// - `shortid` → `text` (FK holds a plain text value)
|
||||
///
|
||||
/// Consumed by the FK declaration grammar in a later
|
||||
/// iteration; defined here so the type system is complete
|
||||
/// before that work begins.
|
||||
#[must_use]
|
||||
pub const fn fk_target_type(self) -> Self {
|
||||
match self {
|
||||
Self::Serial => Self::Int,
|
||||
Self::ShortId => Self::Text,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Type {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.keyword())
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when parsing a type keyword that isn't
|
||||
/// recognised.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("unknown type '{found}' (expected one of: {expected})")]
|
||||
pub struct UnknownType {
|
||||
pub found: String,
|
||||
pub expected: String,
|
||||
}
|
||||
|
||||
impl FromStr for Type {
|
||||
type Err = UnknownType;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
for &ty in Self::all() {
|
||||
if ty.keyword().eq_ignore_ascii_case(s) {
|
||||
return Ok(ty);
|
||||
}
|
||||
}
|
||||
Err(UnknownType {
|
||||
found: s.to_string(),
|
||||
expected: Self::all()
|
||||
.iter()
|
||||
.map(|t| t.keyword())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn keyword_round_trip_for_every_type() {
|
||||
for &ty in Type::all() {
|
||||
let parsed: Type = ty.keyword().parse().expect("round-trip");
|
||||
assert_eq!(parsed, ty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_is_case_insensitive() {
|
||||
assert_eq!("TEXT".parse::<Type>().unwrap(), Type::Text);
|
||||
assert_eq!("Int".parse::<Type>().unwrap(), Type::Int);
|
||||
assert_eq!("ShortId".parse::<Type>().unwrap(), Type::ShortId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_lists_expected_alternatives() {
|
||||
let err = "varchar".parse::<Type>().unwrap_err();
|
||||
assert_eq!(err.found, "varchar");
|
||||
assert!(err.expected.contains("text"));
|
||||
assert!(err.expected.contains("shortid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_maps_to_integer_with_primary_key() {
|
||||
assert_eq!(Type::Serial.sqlite_strict_type(), "INTEGER");
|
||||
assert_eq!(Type::Serial.sqlite_strict_extra(), " PRIMARY KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortid_maps_to_text() {
|
||||
assert_eq!(Type::ShortId.sqlite_strict_type(), "TEXT");
|
||||
assert_eq!(Type::ShortId.sqlite_strict_extra(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_target_type_strips_auto_gen_semantics() {
|
||||
// The two non-identity mappings.
|
||||
assert_eq!(Type::Serial.fk_target_type(), Type::Int);
|
||||
assert_eq!(Type::ShortId.fk_target_type(), Type::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_target_type_is_identity_for_plain_value_types() {
|
||||
for ty in [
|
||||
Type::Text,
|
||||
Type::Int,
|
||||
Type::Real,
|
||||
Type::Decimal,
|
||||
Type::Bool,
|
||||
Type::Date,
|
||||
Type::DateTime,
|
||||
Type::Blob,
|
||||
] {
|
||||
assert_eq!(ty.fk_target_type(), ty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_ten_types_are_present_and_distinct() {
|
||||
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
|
||||
assert_eq!(kws.len(), 10);
|
||||
let mut sorted = kws.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), 10, "keywords must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_and_decimal_types_map_to_text_storage() {
|
||||
assert_eq!(Type::Decimal.sqlite_strict_type(), "TEXT");
|
||||
assert_eq!(Type::Date.sqlite_strict_type(), "TEXT");
|
||||
assert_eq!(Type::DateTime.sqlite_strict_type(), "TEXT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_type_maps_to_blob_storage() {
|
||||
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_message_lists_all_ten() {
|
||||
let err = "varchar".parse::<Type>().unwrap_err();
|
||||
for kw in [
|
||||
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
|
||||
"shortid",
|
||||
] {
|
||||
assert!(
|
||||
err.expected.contains(kw),
|
||||
"expected list should contain `{kw}`: {}",
|
||||
err.expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user