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    /// The null type. Represents the type of a null value and is cast-able to
176    /// any type. Also used as the element type of an empty list whose item type
177    /// is not yet known.
178    Null,
179
180    /// A record type where only a subset of fields are populated, identified
181    /// by a [`PathFieldSet`].
182    SparseRecord(PathFieldSet),
183
184    /// Unit type
185    Unit,
186
187    /// A type that could not be inferred (e.g., empty list)
188    Unknown,
189
190    /// A union of possible types.
191    ///
192    /// Used when a match expression's arms can produce values of different types
193    /// (e.g., a mixed enum where unit arms return `I64` and data arms return
194    /// `Record`). A value is compatible with a union if it satisfies any of the
195    /// member types.
196    Union(TypeUnion),
197}
198
199impl Type {
200    /// Creates a [`Type::List`] wrapping the given element type.
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// # use toasty_core::stmt::Type;
206    /// let ty = Type::list(Type::String);
207    /// assert!(ty.is_list());
208    /// ```
209    pub fn list(ty: impl Into<Self>) -> Self {
210        Self::List(Box::new(ty.into()))
211    }
212
213    /// Returns the element type of this list type, panicking if this is not
214    /// a [`Type::List`].
215    ///
216    /// # Panics
217    ///
218    /// Panics if the type is not a `List` variant.
219    #[track_caller]
220    pub fn as_list_unwrap(&self) -> &Type {
221        match self {
222            stmt::Type::List(items) => items,
223            _ => panic!("expected stmt::Type::List; actual={self:#?}"),
224        }
225    }
226
227    /// Returns `true` if this is [`Type::Bool`].
228    pub fn is_bool(&self) -> bool {
229        matches!(self, Self::Bool)
230    }
231
232    /// Returns `true` if this is [`Type::Model`].
233    pub fn is_model(&self) -> bool {
234        matches!(self, Self::Model(_))
235    }
236
237    /// Returns `true` if this is [`Type::List`].
238    pub fn is_list(&self) -> bool {
239        matches!(self, Self::List(_))
240    }
241
242    /// Returns `true` if this is [`Type::String`].
243    pub fn is_string(&self) -> bool {
244        matches!(self, Self::String)
245    }
246
247    /// Returns `true` if this is [`Type::Unit`].
248    pub fn is_unit(&self) -> bool {
249        matches!(self, Self::Unit)
250    }
251
252    /// Returns `true` if this is [`Type::Record`].
253    pub fn is_record(&self) -> bool {
254        matches!(self, Self::Record(..))
255    }
256
257    /// Returns `true` if this is [`Type::Object`].
258    pub fn is_object(&self) -> bool {
259        matches!(self, Self::Object)
260    }
261
262    /// Returns `true` if this is [`Type::Bytes`].
263    pub fn is_bytes(&self) -> bool {
264        matches!(self, Self::Bytes)
265    }
266
267    /// Returns `true` if this is [`Type::Decimal`] (requires `rust_decimal` feature).
268    pub fn is_decimal(&self) -> bool {
269        #[cfg(feature = "rust_decimal")]
270        {
271            matches!(self, Self::Decimal)
272        }
273        #[cfg(not(feature = "rust_decimal"))]
274        {
275            false
276        }
277    }
278
279    /// Returns `true` if this is [`Type::BigDecimal`] (requires `bigdecimal` feature).
280    pub fn is_big_decimal(&self) -> bool {
281        #[cfg(feature = "bigdecimal")]
282        {
283            matches!(self, Self::BigDecimal)
284        }
285        #[cfg(not(feature = "bigdecimal"))]
286        {
287            false
288        }
289    }
290
291    /// Returns `true` if this is [`Type::Uuid`].
292    pub fn is_uuid(&self) -> bool {
293        matches!(self, Self::Uuid)
294    }
295
296    /// Returns `true` if this is [`Type::SparseRecord`].
297    pub fn is_sparse_record(&self) -> bool {
298        matches!(self, Self::SparseRecord(..))
299    }
300
301    /// Returns `true` if this type is a numeric integer type.
302    ///
303    /// Numeric types include all signed and unsigned integer types:
304    /// `I8`, `I16`, `I32`, `I64`, `U8`, `U16`, `U32`, `U64`.
305    ///
306    /// This does not include decimal types or floating-point types.
307    ///
308    /// # Examples
309    ///
310    /// ```
311    /// # use toasty_core::stmt::Type;
312    /// assert!(Type::I32.is_numeric());
313    /// assert!(Type::U64.is_numeric());
314    /// assert!(!Type::String.is_numeric());
315    /// assert!(!Type::Bool.is_numeric());
316    /// ```
317    pub fn is_numeric(&self) -> bool {
318        matches!(
319            self,
320            Self::I8
321                | Self::I16
322                | Self::I32
323                | Self::I64
324                | Self::U8
325                | Self::U16
326                | Self::U32
327                | Self::U64
328        )
329    }
330
331    /// Whether this type has a document position (`Type::Model`).
332    ///
333    /// Values at a document position convert between the engine's positional
334    /// records and the named objects drivers consume; such conversions are
335    /// schema-directed and cannot run in a schema-free context.
336    pub fn contains_model(&self) -> bool {
337        match self {
338            Self::Model(_) => true,
339            Self::List(elem) => elem.contains_model(),
340            Self::Record(fields) => fields.iter().any(Self::contains_model),
341            Self::Union(union) => union.iter().any(|ty| ty.contains_model()),
342            _ => false,
343        }
344    }
345
346    /// Casts `value` to this type, returning the converted value.
347    ///
348    /// Null values pass through unchanged. Supported conversions include
349    /// identity casts, string/UUID interchange, string/decimal interchange,
350    /// record-to-sparse-record, integer width conversions, and — directed by
351    /// `resolve` — raising a `#[document]` position's named wire object into
352    /// the embedded model's positional record.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if the conversion is not supported, if the value
357    /// is out of range for the target type, or if a schema-directed
358    /// conversion cannot resolve its model through `resolve`.
359    pub fn cast(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
360        self.cast_from(resolve, None, value)
361    }
362
363    /// Casts `value` to this type, additionally directed by the source type
364    /// when one is known (see [`super::ExprCast::from`]).
365    ///
366    /// A model-level `from` type triggers the document *lowering* conversion:
367    /// the engine's positional record becomes the named object drivers
368    /// consume. Every other conversion is directed by the target type alone,
369    /// exactly as [`Self::cast`].
370    pub fn cast_from(
371        &self,
372        resolve: &impl Resolve,
373        from: Option<&Type>,
374        value: Value,
375    ) -> Result<Value> {
376        use stmt::Value;
377
378        // Null values are passed through
379        if value.is_null() {
380            return Ok(value);
381        }
382
383        // Lowering: a `#[document]` position converts from the engine's
384        // positional form to the named object drivers consume, directed by
385        // the *source* type — the structural target does not name the embed
386        // and a positional record is not self-describing.
387        if let Some(from) = from
388            && from.contains_model()
389        {
390            return Self::lower_document(resolve, from, value);
391        }
392
393        #[cfg(feature = "jiff")]
394        if let Some(value) = self.cast_jiff(&value)? {
395            return Ok(value);
396        }
397
398        Ok(match (value, self) {
399            // Identity
400            (value @ Value::String(_), Self::String) => value,
401            // String <-> Uuid
402            (Value::Uuid(value), Self::String) => Value::String(value.to_string()),
403            (Value::String(value), Self::Uuid) => {
404                Value::Uuid(value.parse().expect("could not parse uuid"))
405            }
406            // Bytes <-> Uuid
407            (Value::Uuid(value), Self::Bytes) => Value::Bytes(value.as_bytes().to_vec()),
408            (Value::Bytes(value), Self::Uuid) => {
409                let bytes = value.clone();
410                Value::Uuid(
411                    value
412                        .try_into()
413                        .map_err(|_| crate::Error::type_conversion(Value::Bytes(bytes), "Uuid"))?,
414                )
415            }
416            // String <-> Decimal
417            #[cfg(feature = "rust_decimal")]
418            (Value::Decimal(value), Self::String) => Value::String(value.to_string()),
419            #[cfg(feature = "rust_decimal")]
420            (Value::String(value), Self::Decimal) => {
421                Value::Decimal(value.parse().expect("could not parse Decimal"))
422            }
423            // String <-> BigDecimal
424            #[cfg(feature = "bigdecimal")]
425            (Value::BigDecimal(value), Self::String) => Value::String(value.to_string()),
426            #[cfg(feature = "bigdecimal")]
427            (Value::String(value), Self::BigDecimal) => {
428                Value::BigDecimal(value.parse().expect("could not parse BigDecimal"))
429            }
430            // Record <-> SparseRecord
431            (Value::Record(record), Self::SparseRecord(fields)) => {
432                Value::sparse_record(fields.clone(), record)
433            }
434            // Bool <-> I8: Bool key/index fields are stored as Integer(1) via
435            // bridge_type. The engine casts Bool -> I8 on write and I8 -> Bool
436            // on read. Only Type::cast supports this; TryFrom is intentionally
437            // kept strict so raw numeric conversions don't silently accept Bool.
438            (Value::Bool(v), Self::I8) => Value::I8(if v { 1 } else { 0 }),
439            (Value::I8(v), Self::Bool) => Value::Bool(v != 0),
440            // Integer conversions - use TryFrom which provides error messages
441            (value, Self::I8) => Value::I8(i8::try_from(value)?),
442            (value, Self::I16) => Value::I16(i16::try_from(value)?),
443            (value, Self::I32) => Value::I32(i32::try_from(value)?),
444            (value, Self::I64) => Value::I64(i64::try_from(value)?),
445            (value, Self::U8) => Value::U8(u8::try_from(value)?),
446            (value, Self::U16) => Value::U16(u16::try_from(value)?),
447            (value, Self::U32) => Value::U32(u32::try_from(value)?),
448            (value, Self::U64) => Value::U64(u64::try_from(value)?),
449            // Integer -> float conversions. Document leaves decode from the
450            // wire by integer fit (an integral JSON number or DynamoDB `N`
451            // arrives as `I64`/`U64`), so raising a float document field must
452            // accept integer-shaped input.
453            (Value::I64(v), Self::F32) => Value::F32(v as f32),
454            (Value::I64(v), Self::F64) => Value::F64(v as f64),
455            (Value::U64(v), Self::F32) => Value::F32(v as f32),
456            (Value::U64(v), Self::F64) => Value::F64(v as f64),
457            // Float casts
458            (Value::F32(v), Self::F32) => Value::F32(v),
459            (Value::F64(v), Self::F32) => {
460                let converted = v as f32;
461                if converted.is_infinite() && !v.is_infinite() {
462                    return Err(crate::Error::type_conversion(
463                        Value::F64(v),
464                        "f32 (overflow)",
465                    ));
466                }
467                Value::F32(converted)
468            }
469            (Value::F32(v), Self::F64) => Value::F64(v as f64),
470            (Value::F64(v), Self::F64) => Value::F64(v),
471            // Raising: a named wire object at a document position becomes the
472            // embedded model's positional record; engine-computed values
473            // already in positional form pass through.
474            (value, Self::Model(_)) => return self.raise_document(resolve, value),
475            (Value::List(items), Self::List(elem)) => Value::List(
476                items
477                    .into_iter()
478                    .map(|item| elem.cast(resolve, item))
479                    .collect::<Result<_>>()?,
480            ),
481            (Value::Record(record), Self::Record(fields)) if fields.len() == record.len() => {
482                Value::Record(ValueRecord::from_vec(
483                    fields
484                        .iter()
485                        .zip(record)
486                        .map(|(ty, value)| ty.cast(resolve, value))
487                        .collect::<Result<_>>()?,
488                ))
489            }
490            // A union member is picked by shape: cast with the first member
491            // the value satisfies (a wire object satisfies its `Type::Model`
492            // member via the named field check).
493            (value, Self::Union(union)) => match union.iter().find(|ty| value.is_a(resolve, ty)) {
494                Some(ty) => return ty.cast(resolve, value),
495                None => value,
496            },
497            (value, _) => todo!("value={value:#?}; ty={self:#?}"),
498        })
499    }
500
501    /// Raise a value at a document position: a named wire object (the form a
502    /// driver decodes shape-directed) becomes the embedded model's positional
503    /// record, in schema field order. A key the writer omitted decodes to
504    /// `Null`; a key unknown to the schema (written by an external client) is
505    /// dropped. A value already in engine form (an engine-computed positional
506    /// record) passes through, so the conversion is idempotent.
507    fn raise_document(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
508        let Self::Model(embed_id) = self else {
509            panic!("raise_document on non-model type; ty={self:#?}")
510        };
511
512        // Already in engine form — idempotence for engine-computed values.
513        let Value::Object(object) = value else {
514            return Ok(value);
515        };
516
517        let Some(model) = resolve.model(*embed_id) else {
518            return Err(crate::Error::expression_evaluation_failed(format!(
519                "cannot cast to {self:?}: the model is not resolvable in this context"
520            )));
521        };
522
523        let mut entries = object.entries;
524        Ok(Value::Record(ValueRecord::from_vec(
525            model
526                .fields()
527                .iter()
528                .map(|field| {
529                    let name = field.name().app_unwrap();
530                    match entries.iter().position(|(key, _)| key == name) {
531                        Some(index) => field
532                            .expr_ty()
533                            .cast_document_leaf(resolve, entries.swap_remove(index).1),
534                        None => Ok(Value::Null),
535                    }
536                })
537                .collect::<Result<_>>()?,
538        )))
539    }
540
541    /// Raise one document-interior value: descend document structure, pass
542    /// through leaves already of the field's type, and cast the rest — the
543    /// wire shapes a shape-directed decode produces (integers by fit,
544    /// temporals / decimals / uuids as text) back to the field's type.
545    fn cast_document_leaf(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
546        match (self, value) {
547            (Self::Model(_), value @ Value::Object(_)) => self.raise_document(resolve, value),
548            (Self::List(elem), Value::List(items)) => Ok(Value::List(
549                items
550                    .into_iter()
551                    .map(|item| elem.cast_document_leaf(resolve, item))
552                    .collect::<Result<_>>()?,
553            )),
554            (_, Value::Null) => Ok(Value::Null),
555            (ty, value) if value.is_a(resolve, ty) => Ok(value),
556            (ty, value) => ty.cast(resolve, value),
557        }
558    }
559
560    /// Lower a document value from the engine's positional form to the named
561    /// object a driver serializes, directed by the model-level source type —
562    /// the inverse of [`Self::raise_document`]. A `Type::Model` position turns
563    /// its `Value::Record` into a `Value::Object`, resolving the embed's field
564    /// names from the schema and recursing; `List` maps elementwise; anything
565    /// else — including an already-named `Value::Object` — passes through, so
566    /// the conversion is idempotent.
567    fn lower_document(resolve: &impl Resolve, from: &Type, value: Value) -> Result<Value> {
568        Ok(match (from, value) {
569            (Type::Model(embed_id), Value::Record(record)) => {
570                let Some(model) = resolve.model(*embed_id) else {
571                    return Err(crate::Error::expression_evaluation_failed(format!(
572                        "cannot cast from {from:?}: the model is not resolvable in this context"
573                    )));
574                };
575
576                Value::Object(ValueObject::from_vec(
577                    model
578                        .fields()
579                        .iter()
580                        .zip(record)
581                        .map(|(field, value)| {
582                            Ok((
583                                field.name().app_unwrap().to_owned(),
584                                Self::lower_document(resolve, field.expr_ty(), value)?,
585                            ))
586                        })
587                        .collect::<Result<_>>()?,
588                ))
589            }
590            (Type::List(elem), Value::List(items)) => Value::List(
591                items
592                    .into_iter()
593                    .map(|item| Self::lower_document(resolve, elem, item))
594                    .collect::<Result<_>>()?,
595            ),
596            (_, value) => value,
597        })
598    }
599}
600
601impl From<&Self> for Type {
602    fn from(value: &Self) -> Self {
603        value.clone()
604    }
605}
606
607impl From<ModelId> for Type {
608    fn from(value: ModelId) -> Self {
609        Self::Model(value)
610    }
611}