Skip to main content

toasty_core/stmt/
ty.rs

1use super::{PathFieldSet, Resolve, TypeUnion, Value, ValueObject, ValueRecord};
2use crate::{
3    Result,
4    schema::app::{FieldId, ModelId},
5    stmt,
6};
7
8/// Statement-level type system for values and expressions within Toasty's query engine.
9///
10/// `stmt::Type` represents types at both the **application level** (models, fields, Rust types)
11/// and the **query engine level** (tables, columns, internal processing). These types are
12/// **internal to Toasty** - they describe how Toasty views and processes data throughout the
13/// entire query pipeline, from user queries to driver execution.
14///
15/// # Distinction from Database Types
16///
17/// Toasty has two distinct type systems:
18///
19/// 1. **`stmt::Type`** (this type): Application and query engine types
20///    - Types of [`stmt::Value`] and [`stmt::Expr`] throughout query processing
21///    - Represents Rust primitive types: `I8`, `I16`, `String`, etc.
22///    - Works at both model level (application) and table/column level (engine)
23///    - Internal to Toasty's query processing pipeline
24///
25/// 2. **[`schema::db::Type`](crate::schema::db::Type)**: Database storage types
26///    - External representation for the target database
27///    - Database-specific types: `Integer(n)`, `Text`, `VarChar(n)`, etc.
28///    - Used only at the driver boundary when generating database queries
29///
30/// The key distinction: `stmt::Type` is how **Toasty** views types internally, while
31/// [`schema::db::Type`](crate::schema::db::Type) is how the **database** stores them externally.
32///
33/// # Query Processing Pipeline
34///
35/// Throughout query processing, all values and expressions are typed using `stmt::Type`,
36/// even as they are transformed and converted:
37///
38/// **Application Level (Model/Field)**
39/// - User writes queries referencing models and fields
40/// - Types like `stmt::Type::Model(UserId)`, `stmt::Type::String`
41/// - Values like `stmt::Value::String("alice")`, `stmt::Value::I64(42)`
42///
43/// **Query Engine Level (Table/Column)**
44/// - During planning, queries are "lowered" from models to tables
45/// - Values may be converted between types (e.g., Model → Record, Id → String)
46/// - All conversions are from `stmt::Type` to `stmt::Type`
47/// - Still using the same type system, now at table/column abstraction level
48///
49/// **Driver Boundary (Database Storage)**
50/// - Statements with `stmt::Value` (typed by `stmt::Type`) passed to drivers
51/// - Driver consults schema to map `stmt::Type` → [`schema::db::Type`](crate::schema::db::Type)
52/// - Same `stmt::Type::String` may map to different database types based on schema configuration
53///
54/// # Schema Representation
55///
56/// Each column in the database schema stores both type representations:
57/// - `column.ty: stmt::Type` - How Toasty views this column internally
58/// - `column.storage_ty: Option<db::Type>` - How the database stores it externally
59///
60/// This dual representation enables flexible mapping. For instance, `stmt::Type::String`
61/// might map to `db::Type::Text` in one column and `db::Type::VarChar(100)` in another,
62/// depending on schema configuration and database capabilities.
63///
64/// # See Also
65///
66/// - [`schema::db::Type`](crate::schema::db::Type) External database storage types
67/// - [`stmt::Value`] - Values typed by this system
68/// - [`stmt::Expr`] - Expressions typed by this system
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71pub enum Type {
72    /// Boolean value
73    Bool,
74
75    /// String type
76    String,
77
78    /// Signed 8-bit integer
79    I8,
80
81    /// Signed 16-bit integer
82    I16,
83
84    /// Signed 32-bit integer
85    I32,
86
87    /// Signed 64-bit integer
88    I64,
89
90    /// Unsigned 8-bit integer
91    U8,
92
93    /// Unsigned 16-bit integer
94    U16,
95
96    /// Unsigned 32-bit integer
97    U32,
98
99    /// Unsigned 64-bit integer
100    U64,
101
102    /// 32-bit floating point number
103    F32,
104
105    /// 64-bit floating point number
106    F64,
107
108    /// 128-bit universally unique identifier (UUID)
109    Uuid,
110
111    /// An instance of a model key
112    Key(ModelId),
113
114    /// An instance of a model
115    Model(ModelId),
116
117    /// An instance of a foreign key for a specific relation
118    ForeignKey(FieldId),
119
120    /// A list of a single type
121    List(Box<Type>),
122
123    /// A fixed-length tuple where each item can have a different type.
124    Record(Vec<Type>),
125
126    /// A document value with named fields — the type-level mirror of
127    /// [`Value::Object`](super::Value::Object).
128    ///
129    /// This is how a `#[document]` column is typed at the database and driver
130    /// level: purely structural, like a `jsonb` column. It does not name the
131    /// embedded model whose fields it stores — that identity is an app/engine
132    /// concept, and the engine views the same column as [`Type::Model`]. The
133    /// two views are converted at the driver boundary (see the engine's
134    /// document lowering and raising).
135    Object,
136
137    /// A byte array, more efficient than `List(U8)`.
138    Bytes,
139
140    /// A fixed-precision decimal number.
141    /// See [`rust_decimal::Decimal`].
142    #[cfg(feature = "rust_decimal")]
143    Decimal,
144
145    /// An arbitrary-precision decimal number.
146    /// See [`bigdecimal::BigDecimal`].
147    #[cfg(feature = "bigdecimal")]
148    BigDecimal,
149
150    /// An instant in time represented as the number of nanoseconds since the Unix epoch.
151    /// See [`jiff::Timestamp`].
152    #[cfg(feature = "jiff")]
153    Timestamp,
154
155    /// A time zone aware instant in time.
156    /// See [`jiff::Zoned`]
157    #[cfg(feature = "jiff")]
158    Zoned,
159
160    /// A representation of a civil date in the Gregorian calendar.
161    /// See [`jiff::civil::Date`].
162    #[cfg(feature = "jiff")]
163    Date,
164
165    /// A representation of civil “wall clock” time.
166    /// See [`jiff::civil::Time`].
167    #[cfg(feature = "jiff")]
168    Time,
169
170    /// A representation of a civil datetime in the Gregorian calendar.
171    /// See [`jiff::civil::DateTime`].
172    #[cfg(feature = "jiff")]
173    DateTime,
174
175    /// An IPv4 or IPv6 network prefix.
176    /// See [`cidr::IpCidr`].
177    #[cfg(feature = "net")]
178    Cidr,
179
180    /// An IPv4 or IPv6 host address with a network prefix.
181    /// See [`cidr::IpInet`].
182    #[cfg(feature = "net")]
183    Inet,
184
185    /// A six-byte IEEE EUI-48 address.
186    /// See [`macaddr::MacAddr6`].
187    #[cfg(feature = "net")]
188    MacAddr,
189
190    /// An eight-byte IEEE EUI-64 address.
191    /// See [`macaddr::MacAddr8`].
192    #[cfg(feature = "net")]
193    MacAddr8,
194
195    /// The null type. Represents the type of a null value and is cast-able to
196    /// any type. Also used as the element type of an empty list whose item type
197    /// is not yet known.
198    Null,
199
200    /// A record type where only a subset of fields are populated, identified
201    /// by a [`PathFieldSet`].
202    SparseRecord(PathFieldSet),
203
204    /// Unit type
205    Unit,
206
207    /// A type that could not be inferred (e.g., empty list)
208    Unknown,
209
210    /// A union of possible types.
211    ///
212    /// Used when a match expression's arms can produce values of different types
213    /// (e.g., a mixed enum where unit arms return `I64` and data arms return
214    /// `Record`). A value is compatible with a union if it satisfies any of the
215    /// member types.
216    Union(TypeUnion),
217}
218
219impl Type {
220    /// Creates a [`Type::List`] wrapping the given element type.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// # use toasty_core::stmt::Type;
226    /// let ty = Type::list(Type::String);
227    /// assert!(ty.is_list());
228    /// ```
229    pub fn list(ty: impl Into<Self>) -> Self {
230        Self::List(Box::new(ty.into()))
231    }
232
233    /// Returns the element type of this list type, panicking if this is not
234    /// a [`Type::List`].
235    ///
236    /// # Panics
237    ///
238    /// Panics if the type is not a `List` variant.
239    #[track_caller]
240    pub fn as_list_unwrap(&self) -> &Type {
241        match self {
242            stmt::Type::List(items) => items,
243            _ => panic!("expected stmt::Type::List; actual={self:#?}"),
244        }
245    }
246
247    /// Returns `true` if this is [`Type::Bool`].
248    pub fn is_bool(&self) -> bool {
249        matches!(self, Self::Bool)
250    }
251
252    /// Returns `true` if this is [`Type::Model`].
253    pub fn is_model(&self) -> bool {
254        matches!(self, Self::Model(_))
255    }
256
257    /// Returns `true` if this is [`Type::List`].
258    pub fn is_list(&self) -> bool {
259        matches!(self, Self::List(_))
260    }
261
262    /// Returns `true` if this is [`Type::String`].
263    pub fn is_string(&self) -> bool {
264        matches!(self, Self::String)
265    }
266
267    /// Returns `true` if this is [`Type::Unit`].
268    pub fn is_unit(&self) -> bool {
269        matches!(self, Self::Unit)
270    }
271
272    /// Returns `true` if this is [`Type::Record`].
273    pub fn is_record(&self) -> bool {
274        matches!(self, Self::Record(..))
275    }
276
277    /// Returns `true` if this is [`Type::Object`].
278    pub fn is_object(&self) -> bool {
279        matches!(self, Self::Object)
280    }
281
282    /// Returns `true` if this is [`Type::Bytes`].
283    pub fn is_bytes(&self) -> bool {
284        matches!(self, Self::Bytes)
285    }
286
287    /// Returns `true` if this is [`Type::Decimal`] (requires `rust_decimal` feature).
288    pub fn is_decimal(&self) -> bool {
289        #[cfg(feature = "rust_decimal")]
290        {
291            matches!(self, Self::Decimal)
292        }
293        #[cfg(not(feature = "rust_decimal"))]
294        {
295            false
296        }
297    }
298
299    /// Returns `true` if this is [`Type::BigDecimal`] (requires `bigdecimal` feature).
300    pub fn is_big_decimal(&self) -> bool {
301        #[cfg(feature = "bigdecimal")]
302        {
303            matches!(self, Self::BigDecimal)
304        }
305        #[cfg(not(feature = "bigdecimal"))]
306        {
307            false
308        }
309    }
310
311    /// Returns `true` if this is [`Type::Uuid`].
312    pub fn is_uuid(&self) -> bool {
313        matches!(self, Self::Uuid)
314    }
315
316    /// Returns `true` if this is [`Type::SparseRecord`].
317    pub fn is_sparse_record(&self) -> bool {
318        matches!(self, Self::SparseRecord(..))
319    }
320
321    /// Returns `true` if this type is a numeric integer type.
322    ///
323    /// Numeric types include all signed and unsigned integer types:
324    /// `I8`, `I16`, `I32`, `I64`, `U8`, `U16`, `U32`, `U64`.
325    ///
326    /// This does not include decimal types or floating-point types.
327    ///
328    /// # Examples
329    ///
330    /// ```
331    /// # use toasty_core::stmt::Type;
332    /// assert!(Type::I32.is_numeric());
333    /// assert!(Type::U64.is_numeric());
334    /// assert!(!Type::String.is_numeric());
335    /// assert!(!Type::Bool.is_numeric());
336    /// ```
337    pub fn is_numeric(&self) -> bool {
338        matches!(
339            self,
340            Self::I8
341                | Self::I16
342                | Self::I32
343                | Self::I64
344                | Self::U8
345                | Self::U16
346                | Self::U32
347                | Self::U64
348        )
349    }
350
351    /// Whether this type has a document position (`Type::Model`).
352    ///
353    /// Values at a document position convert between the engine's positional
354    /// records and the named objects drivers consume; such conversions are
355    /// schema-directed and cannot run in a schema-free context.
356    pub fn contains_model(&self) -> bool {
357        match self {
358            Self::Model(_) => true,
359            Self::List(elem) => elem.contains_model(),
360            Self::Record(fields) => fields.iter().any(Self::contains_model),
361            Self::Union(union) => union.iter().any(|ty| ty.contains_model()),
362            _ => false,
363        }
364    }
365
366    /// Casts `value` to this type, returning the converted value.
367    ///
368    /// Null values pass through unchanged. Supported conversions include
369    /// identity casts, string/UUID interchange, string/decimal interchange,
370    /// record-to-sparse-record, integer width conversions, and — directed by
371    /// `resolve` — raising a `#[document]` position's named wire object into
372    /// the embedded model's positional record.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the conversion is not supported, if the value
377    /// is out of range for the target type, or if a schema-directed
378    /// conversion cannot resolve its model through `resolve`.
379    pub fn cast(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
380        self.cast_from(resolve, None, value)
381    }
382
383    /// Casts `value` to this type, additionally directed by the source type
384    /// when one is known (see [`super::ExprCast::from`]).
385    ///
386    /// A model-level `from` type triggers the document *lowering* conversion:
387    /// the engine's positional record becomes the named object drivers
388    /// consume. Every other conversion is directed by the target type alone,
389    /// exactly as [`Self::cast`].
390    pub fn cast_from(
391        &self,
392        resolve: &impl Resolve,
393        from: Option<&Type>,
394        value: Value,
395    ) -> Result<Value> {
396        use stmt::Value;
397
398        // Null values are passed through
399        if value.is_null() {
400            return Ok(value);
401        }
402
403        // Lowering: a `#[document]` position converts from the engine's
404        // positional form to the named object drivers consume, directed by
405        // the *source* type — the structural target does not name the embed
406        // and a positional record is not self-describing.
407        if let Some(from) = from
408            && from.contains_model()
409        {
410            return Self::lower_document(resolve, from, value);
411        }
412
413        #[cfg(feature = "jiff")]
414        if let Some(value) = self.cast_jiff(&value)? {
415            return Ok(value);
416        }
417
418        #[cfg(feature = "net")]
419        if let Some(value) = self.cast_net(&value)? {
420            return Ok(value);
421        }
422
423        Ok(match (value, self) {
424            // Identity
425            (value @ Value::String(_), Self::String) => value,
426            // String <-> Uuid
427            (Value::Uuid(value), Self::String) => Value::String(value.to_string()),
428            (Value::String(value), Self::Uuid) => {
429                Value::Uuid(value.parse().expect("could not parse uuid"))
430            }
431            // Bytes <-> Uuid
432            (Value::Uuid(value), Self::Bytes) => Value::Bytes(value.as_bytes().to_vec()),
433            (Value::Bytes(value), Self::Uuid) => {
434                let bytes = value.clone();
435                Value::Uuid(
436                    value
437                        .try_into()
438                        .map_err(|_| crate::Error::type_conversion(Value::Bytes(bytes), "Uuid"))?,
439                )
440            }
441            // String <-> Decimal
442            #[cfg(feature = "rust_decimal")]
443            (Value::Decimal(value), Self::String) => Value::String(value.to_string()),
444            #[cfg(feature = "rust_decimal")]
445            (Value::String(value), Self::Decimal) => {
446                Value::Decimal(value.parse().expect("could not parse Decimal"))
447            }
448            // String <-> BigDecimal
449            #[cfg(feature = "bigdecimal")]
450            (Value::BigDecimal(value), Self::String) => Value::String(value.to_string()),
451            #[cfg(feature = "bigdecimal")]
452            (Value::String(value), Self::BigDecimal) => {
453                Value::BigDecimal(value.parse().expect("could not parse BigDecimal"))
454            }
455            // Record <-> SparseRecord
456            (Value::Record(record), Self::SparseRecord(fields)) => {
457                Value::sparse_record(fields.clone(), record)
458            }
459            // Bool <-> I8: Bool key/index fields are stored as Integer(1) via
460            // bridge_type. The engine casts Bool -> I8 on write and I8 -> Bool
461            // on read. Only Type::cast supports this; TryFrom is intentionally
462            // kept strict so raw numeric conversions don't silently accept Bool.
463            (Value::Bool(v), Self::I8) => Value::I8(if v { 1 } else { 0 }),
464            (Value::I8(v), Self::Bool) => Value::Bool(v != 0),
465            // Integer conversions - use TryFrom which provides error messages
466            (value, Self::I8) => Value::I8(i8::try_from(value)?),
467            (value, Self::I16) => Value::I16(i16::try_from(value)?),
468            (value, Self::I32) => Value::I32(i32::try_from(value)?),
469            (value, Self::I64) => Value::I64(i64::try_from(value)?),
470            (value, Self::U8) => Value::U8(u8::try_from(value)?),
471            (value, Self::U16) => Value::U16(u16::try_from(value)?),
472            (value, Self::U32) => Value::U32(u32::try_from(value)?),
473            (value, Self::U64) => Value::U64(u64::try_from(value)?),
474            // Integer -> float conversions. Document leaves decode from the
475            // wire by integer fit (an integral JSON number or DynamoDB `N`
476            // arrives as `I64`/`U64`), so raising a float document field must
477            // accept integer-shaped input.
478            (Value::I64(v), Self::F32) => Value::F32(v as f32),
479            (Value::I64(v), Self::F64) => Value::F64(v as f64),
480            (Value::U64(v), Self::F32) => Value::F32(v as f32),
481            (Value::U64(v), Self::F64) => Value::F64(v as f64),
482            // Float casts
483            (Value::F32(v), Self::F32) => Value::F32(v),
484            (Value::F64(v), Self::F32) => {
485                let converted = v as f32;
486                if converted.is_infinite() && !v.is_infinite() {
487                    return Err(crate::Error::type_conversion(
488                        Value::F64(v),
489                        "f32 (overflow)",
490                    ));
491                }
492                Value::F32(converted)
493            }
494            (Value::F32(v), Self::F64) => Value::F64(v as f64),
495            (Value::F64(v), Self::F64) => Value::F64(v),
496            // Raising: a named wire object at a document position becomes the
497            // embedded model's positional record; engine-computed values
498            // already in positional form pass through.
499            (value, Self::Model(_)) => return self.raise_document(resolve, value),
500            (Value::List(items), Self::List(elem)) => Value::List(
501                items
502                    .into_iter()
503                    .map(|item| elem.cast(resolve, item))
504                    .collect::<Result<_>>()?,
505            ),
506            (Value::Record(record), Self::Record(fields)) if fields.len() == record.len() => {
507                Value::Record(ValueRecord::from_vec(
508                    fields
509                        .iter()
510                        .zip(record)
511                        .map(|(ty, value)| ty.cast(resolve, value))
512                        .collect::<Result<_>>()?,
513                ))
514            }
515            // A union member is picked by shape: cast with the first member
516            // the value satisfies (a wire object satisfies its `Type::Model`
517            // member via the named field check).
518            (value, Self::Union(union)) => match union.iter().find(|ty| value.is_a(resolve, ty)) {
519                Some(ty) => return ty.cast(resolve, value),
520                None => value,
521            },
522            (value, _) => todo!("value={value:#?}; ty={self:#?}"),
523        })
524    }
525
526    /// Raise a value at a document position: a named wire object (the form a
527    /// driver decodes shape-directed) becomes the embedded model's positional
528    /// record, in schema field order. A key the writer omitted decodes to
529    /// `Null`; a key unknown to the schema (written by an external client) is
530    /// dropped. A value already in engine form (an engine-computed positional
531    /// record) passes through, so the conversion is idempotent.
532    fn raise_document(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
533        let Self::Model(embed_id) = self else {
534            panic!("raise_document on non-model type; ty={self:#?}")
535        };
536
537        // Already in engine form — idempotence for engine-computed values.
538        let Value::Object(object) = value else {
539            return Ok(value);
540        };
541
542        let Some(model) = resolve.model(*embed_id) else {
543            return Err(crate::Error::expression_evaluation_failed(format!(
544                "cannot cast to {self:?}: the model is not resolvable in this context"
545            )));
546        };
547
548        let mut entries = object.entries;
549        Ok(Value::Record(ValueRecord::from_vec(
550            model
551                .fields()
552                .iter()
553                .map(|field| {
554                    let name = field.name().app_unwrap();
555                    match entries.iter().position(|(key, _)| key == name) {
556                        Some(index) => field
557                            .expr_ty()
558                            .cast_document_leaf(resolve, entries.swap_remove(index).1),
559                        None => Ok(Value::Null),
560                    }
561                })
562                .collect::<Result<_>>()?,
563        )))
564    }
565
566    /// Raise one document-interior value: descend document structure, pass
567    /// through leaves already of the field's type, and cast the rest — the
568    /// wire shapes a shape-directed decode produces (integers by fit,
569    /// temporals / decimals / uuids as text) back to the field's type.
570    fn cast_document_leaf(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
571        match (self, value) {
572            (Self::Model(_), value @ Value::Object(_)) => self.raise_document(resolve, value),
573            (Self::List(elem), Value::List(items)) => Ok(Value::List(
574                items
575                    .into_iter()
576                    .map(|item| elem.cast_document_leaf(resolve, item))
577                    .collect::<Result<_>>()?,
578            )),
579            (_, Value::Null) => Ok(Value::Null),
580            (ty, value) if value.is_a(resolve, ty) => Ok(value),
581            (ty, value) => ty.cast(resolve, value),
582        }
583    }
584
585    /// Lower a document value from the engine's positional form to the named
586    /// object a driver serializes, directed by the model-level source type —
587    /// the inverse of [`Self::raise_document`]. A `Type::Model` position turns
588    /// its `Value::Record` into a `Value::Object`, resolving the embed's field
589    /// names from the schema and recursing; `List` maps elementwise; anything
590    /// else — including an already-named `Value::Object` — passes through, so
591    /// the conversion is idempotent.
592    fn lower_document(resolve: &impl Resolve, from: &Type, value: Value) -> Result<Value> {
593        Ok(match (from, value) {
594            (Type::Model(embed_id), Value::Record(record)) => {
595                let Some(model) = resolve.model(*embed_id) else {
596                    return Err(crate::Error::expression_evaluation_failed(format!(
597                        "cannot cast from {from:?}: the model is not resolvable in this context"
598                    )));
599                };
600
601                Value::Object(ValueObject::from_vec(
602                    model
603                        .fields()
604                        .iter()
605                        .zip(record)
606                        .map(|(field, value)| {
607                            Ok((
608                                field.name().app_unwrap().to_owned(),
609                                Self::lower_document(resolve, field.expr_ty(), value)?,
610                            ))
611                        })
612                        .collect::<Result<_>>()?,
613                ))
614            }
615            (Type::List(elem), Value::List(items)) => Value::List(
616                items
617                    .into_iter()
618                    .map(|item| Self::lower_document(resolve, elem, item))
619                    .collect::<Result<_>>()?,
620            ),
621            (_, value) => value,
622        })
623    }
624}
625
626impl From<&Self> for Type {
627    fn from(value: &Self) -> Self {
628        value.clone()
629    }
630}
631
632impl From<ModelId> for Type {
633    fn from(value: ModelId) -> Self {
634        Self::Model(value)
635    }
636}