Files
rdbms-playground/src/dsl/types.rs
T
claude@clouddev1 58386d77e9 feat: ADR-0035 4a — SQL type-alias resolver (Type::from_sql_name)
Advanced-mode SQL type slot accepts the ten playground keywords plus the
standard-SQL aliases (integer/varchar/timestamp/numeric/float/double
precision/binary/..., case-insensitive). Simple-mode FromStr is unchanged
(rejects aliases). Unknown names -> None for the friendly diagnostic.
2026-05-25 07:55:26 +00:00

383 lines
12 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,
]
}
/// True for the numeric types — `int`, `real`, `decimal`,
/// `serial`. `bool` (stored 0/1) and the text- / blob-backed
/// types are not numeric. Used to flag a `LIKE` text-pattern
/// match against a numeric column (ADR-0027, Amendment 1).
#[must_use]
pub const fn is_numeric(self) -> bool {
matches!(
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
}
/// 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,
}
}
/// Resolve a type name from the **advanced-mode SQL** type slot
/// (ADR-0035 §3). Accepts the ten playground keywords *and* the
/// standard-SQL aliases mapped onto them. Case-insensitive;
/// internal whitespace is collapsed so `double precision` resolves
/// regardless of spacing. Returns `None` for an unrecognised name —
/// the caller turns that into the friendly "unknown type"
/// diagnostic.
///
/// Deliberately distinct from [`FromStr`](std::str::FromStr), which
/// is the *simple-mode* parser and accepts only the ten keywords
/// (no aliases), so simple mode teaches the playground's own
/// vocabulary. A length/precision argument (`varchar(255)`) is
/// stripped by the grammar before the name reaches this resolver.
#[must_use]
pub fn from_sql_name(name: &str) -> Option<Self> {
// Collapse internal whitespace (for the two-word
// `double precision`) and lowercase for case-insensitive match.
let normalised = name
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
match normalised.as_str() {
"integer" | "smallint" | "bigint" => Some(Self::Int),
"varchar" | "char" => Some(Self::Text),
"boolean" => Some(Self::Bool),
"timestamp" => Some(Self::DateTime),
"numeric" => Some(Self::Decimal),
"float" | "double precision" => Some(Self::Real),
"binary" | "varbinary" => Some(Self::Blob),
// Fall through to the canonical ten keywords.
other => other.parse::<Self>().ok(),
}
}
}
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
);
}
}
// --- ADR-0035 §3: advanced-mode SQL type-name resolution ---
// `from_sql_name` accepts the ten playground keywords *and* the
// standard-SQL aliases. Simple-mode `FromStr` is unchanged (it
// still rejects aliases — see `unknown_type_lists_expected_alternatives`).
#[test]
fn sql_resolver_accepts_the_ten_canonical_keywords() {
for &ty in Type::all() {
assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty));
}
}
#[test]
fn sql_resolver_maps_standard_sql_aliases() {
for (alias, expected) in [
("integer", Type::Int),
("smallint", Type::Int),
("bigint", Type::Int),
("varchar", Type::Text),
("char", Type::Text),
("boolean", Type::Bool),
("timestamp", Type::DateTime),
("numeric", Type::Decimal),
("float", Type::Real),
("double precision", Type::Real),
("binary", Type::Blob),
("varbinary", Type::Blob),
] {
assert_eq!(
Type::from_sql_name(alias),
Some(expected),
"alias `{alias}` should map to {expected:?}"
);
}
}
#[test]
fn sql_resolver_is_case_insensitive() {
assert_eq!(Type::from_sql_name("INTEGER"), Some(Type::Int));
assert_eq!(Type::from_sql_name("VarChar"), Some(Type::Text));
assert_eq!(Type::from_sql_name("Double Precision"), Some(Type::Real));
assert_eq!(Type::from_sql_name("TEXT"), Some(Type::Text));
}
#[test]
fn sql_resolver_rejects_genuinely_unknown_names() {
assert_eq!(Type::from_sql_name("money"), None);
assert_eq!(Type::from_sql_name("json"), None);
assert_eq!(Type::from_sql_name(""), None);
}
#[test]
fn sql_resolver_does_not_accept_serial_aliases() {
// `serial`/`shortid` are playground-only; there is no standard
// alias for them, and INTEGER PRIMARY KEY does NOT become serial
// (ADR-0035 §3 — that is enforced at the PK layer, not here).
assert_eq!(Type::from_sql_name("serial"), Some(Type::Serial));
assert_eq!(Type::from_sql_name("autoincrement"), None);
}
}