Skip to main content

siderite_orm/
types.rs

1//! Mapping between Rust field types, SQL column types and [`Value`].
2//!
3//! [`DbType`] is implemented for every type a model field may have. The
4//! `Model` derive uses [`DbType::SQL_TYPE`] and [`DbType::NULLABLE`] for
5//! migration metadata and [`DbType::to_value`] / [`DbType::from_value`] for
6//! writes and reads.
7//!
8//! # Canonical storage forms
9//!
10//! Backends without a native column type store a canonical form, and
11//! `from_value` accepts both the native value and that form:
12//!
13//! | Rust type | Native `Value` | Canonical fallback |
14//! |---|---|---|
15//! | `bool` | `Bool` | `Int` 0/1 |
16//! | `Decimal` | `Decimal` | `Text` (`"12.50"`), `Int`, `Float` |
17//! | `Uuid` | `Uuid` | `Text` (hyphenated) or 16 `Bytes` |
18//! | `NaiveDate` | `Date` | `Text` [`DATE_FORMAT`] |
19//! | `NaiveTime` | `Time` | `Text` [`TIME_FORMAT`] |
20//! | `DateTime<Utc>` | `Timestamp` | `Text` [`TIMESTAMP_FORMAT`] (fixed width, so text order = time order) |
21//! | `TimeDelta` | `Int` (microseconds) | — |
22//! | `IpAddr` | `Text` | — |
23//! | `serde_json::Value` | `Json` | `Text` holding JSON |
24//!
25//! Integer and float types also accept `Decimal` (integral, respectively any
26//! value), because PostgreSQL returns `numeric` for `SUM` and `AVG`. On
27//! SQLite, declare `Decimal` columns `NUMERIC`: the canonical text form would
28//! otherwise sort and aggregate as text.
29
30use crate::error::QueryError;
31use crate::value::Value;
32use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, Utc};
33use rust_decimal::Decimal;
34use rust_decimal::prelude::ToPrimitive;
35use std::net::IpAddr;
36use uuid::Uuid;
37
38/// Text form of dates on backends without a native date type.
39pub const DATE_FORMAT: &str = "%Y-%m-%d";
40/// Text form of times of day.
41pub const TIME_FORMAT: &str = "%H:%M:%S%.6f";
42/// Text form of UTC timestamps (always 6 fractional digits and `Z`).
43pub const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.6fZ";
44
45/// Portable column type family. Backends map it to concrete DDL.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum SqlType {
49    /// 16-bit integer.
50    SmallInt,
51    /// 32-bit integer.
52    Integer,
53    /// 64-bit integer.
54    BigInt,
55    /// 32-bit float.
56    Real,
57    /// 64-bit float.
58    Double,
59    /// Exact decimal; precision comes from `FieldMeta::max_digits`/`decimal_places`.
60    Decimal,
61    /// Boolean.
62    Bool,
63    /// Text; `VARCHAR(n)` when `FieldMeta::max_length` is set, else `TEXT`.
64    Text,
65    /// Binary data.
66    Binary,
67    /// Calendar date.
68    Date,
69    /// Time of day.
70    Time,
71    /// UTC timestamp.
72    Timestamp,
73    /// Duration stored as 64-bit microseconds.
74    Duration,
75    /// UUID.
76    Uuid,
77    /// JSON document (`jsonb` on PostgreSQL).
78    Json,
79    /// IP address stored as text.
80    IpAddr,
81}
82
83/// A Rust type that can be stored in a model column.
84pub trait DbType: Sized + Send + Sync + 'static {
85    /// Column type family.
86    const SQL_TYPE: SqlType;
87    /// Whether the column accepts `NULL` (true only for `Option<T>`).
88    const NULLABLE: bool = false;
89
90    /// Convert to a bind value.
91    fn to_value(&self) -> Value;
92
93    /// Decode from a value read from any backend.
94    ///
95    /// # Errors
96    /// A human-readable reason when `value` has the wrong shape; callers wrap
97    /// it into [`QueryError::Decode`] with the column name.
98    fn from_value(value: Value) -> Result<Self, String>;
99}
100
101/// Decode column `column` of `value` as `T`, naming the column on failure.
102///
103/// # Errors
104/// [`QueryError::Decode`].
105pub fn decode<T: DbType>(column: &str, value: Value) -> Result<T, QueryError> {
106    T::from_value(value).map_err(|reason| QueryError::Decode {
107        column: column.to_owned(),
108        reason,
109    })
110}
111
112fn mismatch(expected: &str, got: &Value) -> String {
113    format!("expected {expected}, got {}", got.kind())
114}
115
116macro_rules! int_type {
117    ($($t:ty => $sql:ident),*) => {$(
118        impl DbType for $t {
119            const SQL_TYPE: SqlType = SqlType::$sql;
120            fn to_value(&self) -> Value {
121                Value::Int(i64::from(*self))
122            }
123            fn from_value(value: Value) -> Result<Self, String> {
124                let int = match value {
125                    Value::Int(i) => i,
126                    // PostgreSQL returns `numeric` for `SUM` / `AVG` of integers.
127                    Value::Decimal(d) if d.is_integer() => {
128                        i64::try_from(d).map_err(|_| format!("{d} is out of range"))?
129                    }
130                    other => return Err(mismatch("integer", &other)),
131                };
132                <$t>::try_from(int).map_err(|_| format!("{int} is out of range"))
133            }
134        }
135    )*};
136}
137int_type!(i16 => SmallInt, i32 => Integer, i64 => BigInt);
138
139impl DbType for f64 {
140    const SQL_TYPE: SqlType = SqlType::Double;
141    fn to_value(&self) -> Value {
142        Value::Float(*self)
143    }
144    #[allow(clippy::cast_precision_loss)]
145    fn from_value(value: Value) -> Result<Self, String> {
146        match value {
147            Value::Float(f) => Ok(f),
148            Value::Int(i) => Ok(i as f64),
149            Value::Decimal(d) => d
150                .to_f64()
151                .ok_or_else(|| format!("{d} is not a valid float")),
152            other => Err(mismatch("float", &other)),
153        }
154    }
155}
156
157impl DbType for f32 {
158    const SQL_TYPE: SqlType = SqlType::Real;
159    fn to_value(&self) -> Value {
160        Value::Float(f64::from(*self))
161    }
162    #[allow(clippy::cast_possible_truncation)]
163    fn from_value(value: Value) -> Result<Self, String> {
164        f64::from_value(value).map(|f| f as f32)
165    }
166}
167
168impl DbType for bool {
169    const SQL_TYPE: SqlType = SqlType::Bool;
170    fn to_value(&self) -> Value {
171        Value::Bool(*self)
172    }
173    fn from_value(value: Value) -> Result<Self, String> {
174        match value {
175            Value::Bool(b) => Ok(b),
176            Value::Int(0) => Ok(false),
177            Value::Int(1) => Ok(true),
178            other => Err(mismatch("boolean", &other)),
179        }
180    }
181}
182
183impl DbType for String {
184    const SQL_TYPE: SqlType = SqlType::Text;
185    fn to_value(&self) -> Value {
186        Value::Text(self.clone())
187    }
188    fn from_value(value: Value) -> Result<Self, String> {
189        match value {
190            Value::Text(s) => Ok(s),
191            other => Err(mismatch("text", &other)),
192        }
193    }
194}
195
196impl DbType for Vec<u8> {
197    const SQL_TYPE: SqlType = SqlType::Binary;
198    fn to_value(&self) -> Value {
199        Value::Bytes(self.clone())
200    }
201    fn from_value(value: Value) -> Result<Self, String> {
202        match value {
203            Value::Bytes(b) => Ok(b),
204            other => Err(mismatch("bytes", &other)),
205        }
206    }
207}
208
209impl DbType for Decimal {
210    const SQL_TYPE: SqlType = SqlType::Decimal;
211    fn to_value(&self) -> Value {
212        Value::Decimal(*self)
213    }
214    fn from_value(value: Value) -> Result<Self, String> {
215        match value {
216            Value::Decimal(d) => Ok(d),
217            Value::Int(i) => Ok(Decimal::from(i)),
218            Value::Float(f) => Decimal::try_from(f).map_err(|e| e.to_string()),
219            Value::Text(s) => s.parse().map_err(|e| format!("invalid decimal {s:?}: {e}")),
220            other => Err(mismatch("decimal", &other)),
221        }
222    }
223}
224
225impl DbType for Uuid {
226    const SQL_TYPE: SqlType = SqlType::Uuid;
227    fn to_value(&self) -> Value {
228        Value::Uuid(*self)
229    }
230    fn from_value(value: Value) -> Result<Self, String> {
231        match value {
232            Value::Uuid(u) => Ok(u),
233            Value::Text(s) => Uuid::parse_str(&s).map_err(|e| format!("invalid uuid {s:?}: {e}")),
234            Value::Bytes(b) => Uuid::from_slice(&b).map_err(|e| e.to_string()),
235            other => Err(mismatch("uuid", &other)),
236        }
237    }
238}
239
240impl DbType for NaiveDate {
241    const SQL_TYPE: SqlType = SqlType::Date;
242    fn to_value(&self) -> Value {
243        Value::Date(*self)
244    }
245    fn from_value(value: Value) -> Result<Self, String> {
246        match value {
247            Value::Date(d) => Ok(d),
248            Value::Text(s) => NaiveDate::parse_from_str(&s, DATE_FORMAT)
249                .map_err(|e| format!("invalid date {s:?}: {e}")),
250            other => Err(mismatch("date", &other)),
251        }
252    }
253}
254
255impl DbType for NaiveTime {
256    const SQL_TYPE: SqlType = SqlType::Time;
257    fn to_value(&self) -> Value {
258        Value::Time(*self)
259    }
260    fn from_value(value: Value) -> Result<Self, String> {
261        match value {
262            Value::Time(t) => Ok(t),
263            Value::Text(s) => NaiveTime::parse_from_str(&s, TIME_FORMAT)
264                .or_else(|_| NaiveTime::parse_from_str(&s, "%H:%M:%S"))
265                .map_err(|e| format!("invalid time {s:?}: {e}")),
266            other => Err(mismatch("time", &other)),
267        }
268    }
269}
270
271impl DbType for DateTime<Utc> {
272    const SQL_TYPE: SqlType = SqlType::Timestamp;
273    fn to_value(&self) -> Value {
274        Value::Timestamp(*self)
275    }
276    fn from_value(value: Value) -> Result<Self, String> {
277        match value {
278            Value::Timestamp(t) => Ok(t),
279            Value::Text(s) => DateTime::parse_from_rfc3339(&s)
280                .map(|t| t.with_timezone(&Utc))
281                .or_else(|_| {
282                    NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f").map(|t| t.and_utc())
283                })
284                .map_err(|e| format!("invalid timestamp {s:?}: {e}")),
285            other => Err(mismatch("timestamp", &other)),
286        }
287    }
288}
289
290impl DbType for TimeDelta {
291    const SQL_TYPE: SqlType = SqlType::Duration;
292    fn to_value(&self) -> Value {
293        // Durations beyond ±292k years do not fit; saturate rather than panic.
294        Value::Int(self.num_microseconds().unwrap_or(i64::MAX))
295    }
296    fn from_value(value: Value) -> Result<Self, String> {
297        match value {
298            Value::Int(us) => Ok(TimeDelta::microseconds(us)),
299            other => Err(mismatch("duration (microseconds)", &other)),
300        }
301    }
302}
303
304impl DbType for IpAddr {
305    const SQL_TYPE: SqlType = SqlType::IpAddr;
306    fn to_value(&self) -> Value {
307        Value::Text(self.to_string())
308    }
309    fn from_value(value: Value) -> Result<Self, String> {
310        match value {
311            Value::Text(s) => s.parse().map_err(|e| format!("invalid ip {s:?}: {e}")),
312            other => Err(mismatch("ip address", &other)),
313        }
314    }
315}
316
317impl DbType for serde_json::Value {
318    const SQL_TYPE: SqlType = SqlType::Json;
319    fn to_value(&self) -> Value {
320        Value::Json(self.clone())
321    }
322    fn from_value(value: Value) -> Result<Self, String> {
323        match value {
324            Value::Json(j) => Ok(j),
325            Value::Text(s) => serde_json::from_str(&s).map_err(|e| format!("invalid json: {e}")),
326            other => Err(mismatch("json", &other)),
327        }
328    }
329}
330
331impl<T: DbType> DbType for Option<T> {
332    const SQL_TYPE: SqlType = T::SQL_TYPE;
333    const NULLABLE: bool = true;
334    fn to_value(&self) -> Value {
335        self.as_ref().map_or(Value::Null, DbType::to_value)
336    }
337    fn from_value(value: Value) -> Result<Self, String> {
338        match value {
339            Value::Null => Ok(None),
340            other => T::from_value(other).map(Some),
341        }
342    }
343}
344
345/// Canonical text for values a backend cannot store natively (see module docs).
346///
347/// Returns `None` for variants that are always bound natively.
348pub fn canonical_text(value: &Value) -> Option<String> {
349    Some(match value {
350        Value::Decimal(d) => d.to_string(),
351        Value::Uuid(u) => u.hyphenated().to_string(),
352        Value::Date(d) => d.format(DATE_FORMAT).to_string(),
353        Value::Time(t) => t.format(TIME_FORMAT).to_string(),
354        Value::Timestamp(t) => t.format(TIMESTAMP_FORMAT).to_string(),
355        Value::Json(j) => j.to_string(),
356        _ => return None,
357    })
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn round_trip<T: DbType + PartialEq + std::fmt::Debug>(v: T) {
365        let native = v.to_value();
366        let text = canonical_text(&native).map_or(native.clone(), Value::Text);
367        assert_eq!(T::from_value(native).as_ref(), Ok(&v));
368        assert_eq!(T::from_value(text).as_ref(), Ok(&v));
369    }
370
371    #[test]
372    fn native_and_canonical_forms_round_trip() {
373        round_trip(42_i64);
374        round_trip(true);
375        round_trip("x".to_owned());
376        round_trip(Decimal::new(1250, 2));
377        round_trip(Uuid::from_u128(7));
378        round_trip(NaiveDate::from_ymd_opt(2026, 9, 29).unwrap_or_default());
379        round_trip(NaiveTime::from_hms_micro_opt(1, 2, 3, 4).unwrap_or_default());
380        round_trip(DateTime::from_timestamp_micros(1_700_000_000_123_456).unwrap_or_default());
381        round_trip(TimeDelta::milliseconds(1500));
382        round_trip(serde_json::json!({"a": [1]}));
383        round_trip(Some(3_i32));
384        round_trip(None::<String>);
385    }
386
387    #[test]
388    fn sqlite_bool_and_range_errors() {
389        assert_eq!(bool::from_value(Value::Int(1)), Ok(true));
390        assert!(i16::from_value(Value::Int(1 << 20)).is_err());
391        assert!(matches!(
392            decode::<i64>("age", Value::Text("x".into())),
393            Err(QueryError::Decode { column, .. }) if column == "age"
394        ));
395    }
396
397    #[test]
398    fn timestamp_text_orders_like_time() {
399        let a = canonical_text(&Value::Timestamp(
400            DateTime::from_timestamp(9, 0).unwrap_or_default(),
401        ));
402        let b = canonical_text(&Value::Timestamp(
403            DateTime::from_timestamp(10, 5).unwrap_or_default(),
404        ));
405        assert!(a < b);
406    }
407}