//! User-facing value literals for INSERT / UPDATE / DELETE. //! //! The parser produces a small `Value` enum carrying just the //! shape of the literal as written. Per-column-type validation //! happens at execute time, where the schema is known and //! errors can name the offending column. use std::fmt; use crate::dsl::shortid; use crate::dsl::types::Type; /// A literal value as parsed from DSL input. /// /// `Number` carries the original string so a literal like /// `3.14` can be stored as a decimal (TEXT) or a real (f64) /// depending on the destination column. The conversion happens /// in `bind_for_column`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Value { Number(String), Text(String), Bool(bool), Null, } impl fmt::Display for Value { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Number(n) => f.write_str(n), Self::Text(s) => write!(f, "'{}'", s.replace('\'', "''")), Self::Bool(b) => f.write_str(if *b { "true" } else { "false" }), Self::Null => f.write_str("null"), } } } /// Validated value ready to be bound as a parameter to a SQLite /// statement. Mirrors the storage choices made in ADR-0005. #[derive(Debug, Clone, PartialEq)] pub enum Bound { Integer(i64), Real(f64), Text(String), Null, } /// Per-column value validation error. /// /// Display flows through the i18n catalog (`value.*`); callers /// that format this error get the localised wording for free. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ValueError { TypeMismatch { column: String, expected_human: String, got: String, }, Format { column: String, message: String, }, } impl std::fmt::Display for ValueError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::TypeMismatch { column, expected_human, got, } => f.write_str(&crate::t!( "value.type_mismatch", column = column, expected_human = expected_human, got = got, )), Self::Format { column, message } => f.write_str(&crate::t!( "value.format", column = column, message = message, )), } } } impl std::error::Error for ValueError {} impl Value { /// Validate `self` against `column`'s user-facing type and /// produce a value ready for binding. pub fn bind_for_column(&self, column: &str, ty: Type) -> Result { if matches!(self, Self::Null) { return Ok(Bound::Null); } match ty { Type::Text | Type::ShortId => self.bind_text(column, ty), Type::Int | Type::Serial => self.bind_int(column, ty), Type::Real => self.bind_real(column), Type::Decimal => self.bind_decimal(column), Type::Bool => self.bind_bool(column), Type::Date => self.bind_date(column), Type::DateTime => self.bind_datetime(column), Type::Blob => Err(ValueError::Format { column: column.to_string(), message: "literal `blob` values are not supported in DSL yet".to_string(), }), } } fn bind_text(&self, column: &str, ty: Type) -> Result { match self { Self::Text(s) => { if ty == Type::ShortId { shortid::validate(s).map_err(|message| ValueError::Format { column: column.to_string(), message, })?; } Ok(Bound::Text(s.clone())) } other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: format!("a quoted string for `{ty}`"), got: other.kind_name().to_string(), }), } } fn bind_int(&self, column: &str, ty: Type) -> Result { match self { Self::Number(n) => n .parse::() .map(Bound::Integer) .map_err(|_| ValueError::Format { column: column.to_string(), message: format!("`{n}` is not a valid {ty} (whole number expected)"), }), other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: format!("a whole number for `{ty}`"), got: other.kind_name().to_string(), }), } } fn bind_real(&self, column: &str) -> Result { match self { Self::Number(n) => n .parse::() .map(Bound::Real) .map_err(|_| ValueError::Format { column: column.to_string(), message: format!("`{n}` is not a valid real number"), }), other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: "a real number".to_string(), got: other.kind_name().to_string(), }), } } fn bind_decimal(&self, column: &str) -> Result { match self { Self::Number(n) => { // Validate parse-ability so a typo like `3..14` is rejected; // we still store the original string to preserve precision. if n.parse::().is_err() { return Err(ValueError::Format { column: column.to_string(), message: format!("`{n}` is not a valid decimal number"), }); } Ok(Bound::Text(n.clone())) } other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: "a decimal number".to_string(), got: other.kind_name().to_string(), }), } } fn bind_bool(&self, column: &str) -> Result { match self { Self::Bool(b) => Ok(Bound::Integer(i64::from(*b))), other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: "`true` or `false`".to_string(), got: other.kind_name().to_string(), }), } } fn bind_date(&self, column: &str) -> Result { match self { Self::Text(s) => { validate_date(s).map_err(|message| ValueError::Format { column: column.to_string(), message, })?; Ok(Bound::Text(s.clone())) } other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: "a quoted date 'YYYY-MM-DD'".to_string(), got: other.kind_name().to_string(), }), } } fn bind_datetime(&self, column: &str) -> Result { match self { Self::Text(s) => { validate_datetime(s).map_err(|message| ValueError::Format { column: column.to_string(), message, })?; Ok(Bound::Text(s.clone())) } other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: "a quoted datetime 'YYYY-MM-DDTHH:MM:SS'".to_string(), got: other.kind_name().to_string(), }), } } const fn kind_name(&self) -> &'static str { match self { Self::Number(_) => "number", Self::Text(_) => "string", Self::Bool(_) => "boolean", Self::Null => "null", } } } pub(crate) fn validate_date(s: &str) -> Result<(), String> { // Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions. let bytes = s.as_bytes(); if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { return Err(format!( "`{s}` is not a date in `YYYY-MM-DD` form" )); } let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?; let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?; let day = parse_digits(&s[8..10]).ok_or_else(|| format!("`{s}`: invalid day"))?; if !(1..=9999).contains(&year) { return Err(format!("`{s}`: year {year} out of range 1..=9999")); } if !(1..=12).contains(&month) { return Err(format!("`{s}`: month {month} out of range 1..=12")); } if !(1..=31).contains(&day) { return Err(format!("`{s}`: day {day} out of range 1..=31")); } Ok(()) } pub(crate) fn validate_datetime(s: &str) -> Result<(), String> { // Minimum: YYYY-MM-DDTHH:MM:SS = 19 chars. Allow optional // fractional seconds (.fff) and optional Z or ±HH:MM offset. if s.len() < 19 { return Err(format!( "`{s}` is too short for a datetime in `YYYY-MM-DDTHH:MM:SS` form" )); } let date_part = &s[0..10]; validate_date(date_part)?; let bytes = s.as_bytes(); if bytes[10] != b'T' { return Err(format!("`{s}`: missing `T` separator between date and time")); } if bytes[13] != b':' || bytes[16] != b':' { return Err(format!("`{s}`: time portion must be `HH:MM:SS`")); } let hour = parse_digits(&s[11..13]).ok_or_else(|| format!("`{s}`: invalid hour"))?; let min = parse_digits(&s[14..16]).ok_or_else(|| format!("`{s}`: invalid minute"))?; let sec = parse_digits(&s[17..19]).ok_or_else(|| format!("`{s}`: invalid second"))?; if hour > 23 { return Err(format!("`{s}`: hour {hour} out of range 0..=23")); } if min > 59 { return Err(format!("`{s}`: minute {min} out of range 0..=59")); } if sec > 60 { return Err(format!( "`{s}`: second {sec} out of range 0..=60 (60 allowed for leap second)" )); } // Anything after position 19 is optional fractional / timezone // suffix; we don't strictly validate it here (a future iteration // can tighten this if needed). Ok(()) } fn parse_digits(s: &str) -> Option { if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) { return None; } s.parse::().ok() } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn n(s: &str) -> Value { Value::Number(s.to_string()) } fn t(s: &str) -> Value { Value::Text(s.to_string()) } #[test] fn null_binds_to_null_for_any_type() { for ty in Type::all() { // Skip blob — null still works there too. assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null); } } #[test] fn integer_for_int_column() { assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42)); assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7)); } #[test] fn non_integer_for_int_column_is_format_error() { let err = n("3.14").bind_for_column("c", Type::Int).unwrap_err(); match err { ValueError::Format { message, .. } => assert!(message.contains("whole number")), other => panic!("unexpected: {other:?}"), } } #[test] fn string_for_int_column_is_type_mismatch() { let err = t("hello").bind_for_column("c", Type::Int).unwrap_err(); assert!(matches!(err, ValueError::TypeMismatch { .. })); } #[test] fn text_for_text_column() { assert_eq!( t("hi").bind_for_column("c", Type::Text).unwrap(), Bound::Text("hi".to_string()) ); } #[test] fn shortid_validation_runs_on_text_for_shortid_column() { let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err(); assert!(matches!(err, ValueError::Format { .. })); // Well-formed shortid binds fine. assert_eq!( t("23456789Ab").bind_for_column("c", Type::ShortId).unwrap(), Bound::Text("23456789Ab".to_string()) ); } #[test] fn bool_for_bool_column_maps_to_zero_or_one() { assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1)); assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0)); } #[test] fn date_iso_only() { assert_eq!( t("2025-01-15").bind_for_column("c", Type::Date).unwrap(), Bound::Text("2025-01-15".to_string()) ); let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err(); assert!(matches!(err, ValueError::Format { .. })); } #[test] fn date_range_check() { let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err(); assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month"))); } #[test] fn datetime_iso_only() { assert_eq!( t("2025-01-15T14:30:00") .bind_for_column("c", Type::DateTime) .unwrap(), Bound::Text("2025-01-15T14:30:00".to_string()) ); let err = t("2025-01-15 14:30:00") .bind_for_column("c", Type::DateTime) .unwrap_err(); assert!(matches!(err, ValueError::Format { .. })); } #[test] fn decimal_validates_numeric_string() { assert_eq!( n("3.14").bind_for_column("c", Type::Decimal).unwrap(), Bound::Text("3.14".to_string()) ); let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err(); assert!(matches!(err, ValueError::Format { .. })); } #[test] fn blob_inserts_are_explicitly_unsupported_for_now() { let err = t("0xdead").bind_for_column("c", Type::Blob).unwrap_err(); assert!(matches!(err, ValueError::Format { message, .. } if message.contains("blob"))); } }