//! 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, ] } /// 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 { // Collapse internal whitespace (for the two-word // `double precision`) and lowercase for case-insensitive match. let normalised = name .split_whitespace() .collect::>() .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::().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 { 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::>() .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::().unwrap(), Type::Text); assert_eq!("Int".parse::().unwrap(), Type::Int); assert_eq!("ShortId".parse::().unwrap(), Type::ShortId); } #[test] fn unknown_type_lists_expected_alternatives() { let err = "varchar".parse::().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::().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); } }