Files
rdbms-playground/src/dsl/types.rs
T
claude@clouddev1 1e06490572 round-5 follow-up: completion + i18n sweep
Four user-reported gaps from the round-4 testing pass:

1. Empty-prompt hint reworded from "(no active hint)" to
   "Type a command — press Tab for options, `help` for a
   list" (6 snapshots updated to reflect 80-col truncation).

2. App-lifecycle commands (quit/q, help, rebuild, save/save as,
   new, load, export, import, mode, messages) now flow through
   the DSL parser:
   - 15 new keywords + catalog token entries
   - new Command::App(AppCommand) AST with 11 variants
   - parse-first dispatch in submit() (app commands work in
     both simple and advanced modes)
   - pre-chumsky source-slice for `export <path>` /
     `import <zip> [as <target>]` mirrors the replay precedent
   - UsageEntry registry entries so parse errors surface
     relevant usage templates
   - `mode <bad>` / `messages <bad>` use try_map for the
     friendly "unknown mode/messages" wording

3. DSL completion gaps:
   - `1:n` surfaces as a composite candidate at `add `
   - --all-rows / --create-fk / --force-conversion /
     --dont-convert surface as new CandidateKind::Flag
     candidates (coloured with tok_flag in hint panel)
   - filter_clause .labelled() wrap removed so chumsky's
     expected-set surfaces the constituent options

4. Hardcoded user-facing strings migrated to catalog:
   - 4 parser custom errors (incl. the known "tables need at
     least one column" wart)
   - UnknownType Display now via parse.custom.unknown_type
   - UI panel titles + mode labels (Output / Hint / SIMPLE /
     ADVANCED / Advanced:)
   - app.rs cascade rendering (action labels + summary)
   - runtime --resume CLI stderr
   - db.rs change-column diagnostic tables (7 headers + 3
     wrapper summaries + force-conversion hint)

Tests: 765 → 769 passing, 0 failed, 1 ignored (same doctest
as before). Clippy clean with -D warnings.

Deferred:
- ~25 thiserror #[error] attributes still hand-rolled
  (DbError, ArgsError, ArchiveError, PersistenceError,
  LockError). Tracked separately.
- DSL/SQL relationship in advanced mode — clarified
  implicitly via parse-first dispatch; broader ADR
  amendment to follow.
- Post-complete-parse completion gap (e.g. `save ` Tab
  can't offer `as` because `save` parses bare; same shape
  as `--create-fk` after a complete `add relationship`).
2026-05-13 15:58:29 +00:00

276 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
/// 1012 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.
///
/// Display formatting flows through the i18n catalog
/// (`parse.custom.unknown_type`); call sites that do
/// `err.to_string()` get the localised wording for free.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownType {
pub found: String,
pub expected: String,
}
impl fmt::Display for UnknownType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&crate::t!(
"parse.custom.unknown_type",
found = self.found,
expected = self.expected,
))
}
}
impl std::error::Error for UnknownType {}
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
);
}
}
}