58386d77e9
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.
383 lines
12 KiB
Rust
383 lines
12 KiB
Rust
//! 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<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);
|
||
}
|
||
}
|