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 /// Returns `true` when removing this decode cast preserves equality for
367 /// values encoded by Toasty.
368 ///
369 /// A decode cast from `self` to `target` preserves equality when the
370 /// reciprocal write cast gives equal target values the same stored
371 /// representation and distinct target values different representations.
372 /// Keep this allowlist narrow: a supported cast is not necessarily
373 /// equality-preserving. For example, equal decimal values can retain
374 /// different scales when encoded as strings (`1.0` and `1.00`).
375 pub fn cast_preserves_equality(&self, target: &Self) -> bool {
376 match (self, target) {
377 // Identity casts do not change the representation.
378 (source, target) if source == target => true,
379
380 // UUID write casts produce one canonical string or byte sequence.
381 (Self::String | Self::Bytes, Self::Uuid) => true,
382
383 // Integer casts preserve the represented mathematical integer.
384 (
385 Self::I8
386 | Self::I16
387 | Self::I32
388 | Self::I64
389 | Self::U8
390 | Self::U16
391 | Self::U32
392 | Self::U64,
393 Self::I8
394 | Self::I16
395 | Self::I32
396 | Self::I64
397 | Self::U8
398 | Self::U16
399 | Self::U32
400 | Self::U64,
401 ) => true,
402
403 // Collection casts apply the element cast independently.
404 (Self::List(source), Self::List(target)) => source.cast_preserves_equality(target),
405
406 _ => false,
407 }
408 }
409
410 /// Casts `value` to this type, returning the converted value.
411 ///
412 /// Null values pass through unchanged. Supported conversions include
413 /// identity casts, string/UUID interchange, string/decimal interchange,
414 /// record-to-sparse-record, integer width conversions, and — directed by
415 /// `resolve` — raising a `#[document]` position's named wire object into
416 /// the embedded model's positional record.
417 ///
418 /// # Errors
419 ///
420 /// Returns an error if the conversion is not supported, if the value
421 /// is out of range for the target type, or if a schema-directed
422 /// conversion cannot resolve its model through `resolve`.
423 pub fn cast(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
424 self.cast_from(resolve, None, value)
425 }
426
427 /// Casts `value` to this type, additionally directed by the source type
428 /// when one is known (see [`super::ExprCast::from`]).
429 ///
430 /// A model-level `from` type triggers the document *lowering* conversion:
431 /// the engine's positional record becomes the named object drivers
432 /// consume. Every other conversion is directed by the target type alone,
433 /// exactly as [`Self::cast`].
434 pub fn cast_from(
435 &self,
436 resolve: &impl Resolve,
437 from: Option<&Type>,
438 value: Value,
439 ) -> Result<Value> {
440 use stmt::Value;
441
442 // Null values are passed through
443 if value.is_null() {
444 return Ok(value);
445 }
446
447 // Lowering: a `#[document]` position converts from the engine's
448 // positional form to the named object drivers consume, directed by
449 // the *source* type — the structural target does not name the embed
450 // and a positional record is not self-describing.
451 if let Some(from) = from
452 && from.contains_model()
453 {
454 return Self::lower_document(resolve, from, value);
455 }
456
457 #[cfg(feature = "jiff")]
458 if let Some(value) = self.cast_jiff(&value)? {
459 return Ok(value);
460 }
461
462 #[cfg(feature = "net")]
463 if let Some(value) = self.cast_net(&value)? {
464 return Ok(value);
465 }
466
467 Ok(match (value, self) {
468 // Identity
469 (value @ Value::String(_), Self::String) => value,
470 // String <-> Uuid
471 (Value::Uuid(value), Self::String) => Value::String(value.to_string()),
472 (Value::String(value), Self::Uuid) => {
473 Value::Uuid(value.parse().expect("could not parse uuid"))
474 }
475 // Bytes <-> Uuid
476 (Value::Uuid(value), Self::Bytes) => Value::Bytes(value.as_bytes().to_vec()),
477 (Value::Bytes(value), Self::Uuid) => {
478 let bytes = value.clone();
479 Value::Uuid(
480 value
481 .try_into()
482 .map_err(|_| crate::Error::type_conversion(Value::Bytes(bytes), "Uuid"))?,
483 )
484 }
485 // String <-> Decimal
486 #[cfg(feature = "rust_decimal")]
487 (Value::Decimal(value), Self::String) => Value::String(value.to_string()),
488 #[cfg(feature = "rust_decimal")]
489 (Value::String(value), Self::Decimal) => {
490 Value::Decimal(value.parse().expect("could not parse Decimal"))
491 }
492 // String <-> BigDecimal
493 #[cfg(feature = "bigdecimal")]
494 (Value::BigDecimal(value), Self::String) => Value::String(value.to_string()),
495 #[cfg(feature = "bigdecimal")]
496 (Value::String(value), Self::BigDecimal) => {
497 Value::BigDecimal(value.parse().expect("could not parse BigDecimal"))
498 }
499 // Record <-> SparseRecord
500 (Value::Record(record), Self::SparseRecord(fields)) => {
501 Value::sparse_record(fields.clone(), record)
502 }
503 // Bool <-> I8: Bool key/index fields are stored as Integer(1) via
504 // bridge_type. The engine casts Bool -> I8 on write and I8 -> Bool
505 // on read. Only Type::cast supports this; TryFrom is intentionally
506 // kept strict so raw numeric conversions don't silently accept Bool.
507 (Value::Bool(v), Self::I8) => Value::I8(if v { 1 } else { 0 }),
508 (Value::I8(v), Self::Bool) => Value::Bool(v != 0),
509 // Integer conversions - use TryFrom which provides error messages
510 (value, Self::I8) => Value::I8(i8::try_from(value)?),
511 (value, Self::I16) => Value::I16(i16::try_from(value)?),
512 (value, Self::I32) => Value::I32(i32::try_from(value)?),
513 (value, Self::I64) => Value::I64(i64::try_from(value)?),
514 (value, Self::U8) => Value::U8(u8::try_from(value)?),
515 (value, Self::U16) => Value::U16(u16::try_from(value)?),
516 (value, Self::U32) => Value::U32(u32::try_from(value)?),
517 (value, Self::U64) => Value::U64(u64::try_from(value)?),
518 // Integer -> float conversions. Document leaves decode from the
519 // wire by integer fit (an integral JSON number or DynamoDB `N`
520 // arrives as `I64`/`U64`), so raising a float document field must
521 // accept integer-shaped input.
522 (Value::I64(v), Self::F32) => Value::F32(v as f32),
523 (Value::I64(v), Self::F64) => Value::F64(v as f64),
524 (Value::U64(v), Self::F32) => Value::F32(v as f32),
525 (Value::U64(v), Self::F64) => Value::F64(v as f64),
526 // Float casts
527 (Value::F32(v), Self::F32) => Value::F32(v),
528 (Value::F64(v), Self::F32) => {
529 let converted = v as f32;
530 if converted.is_infinite() && !v.is_infinite() {
531 return Err(crate::Error::type_conversion(
532 Value::F64(v),
533 "f32 (overflow)",
534 ));
535 }
536 Value::F32(converted)
537 }
538 (Value::F32(v), Self::F64) => Value::F64(v as f64),
539 (Value::F64(v), Self::F64) => Value::F64(v),
540 // Raising: a named wire object at a document position becomes the
541 // embedded model's positional record; engine-computed values
542 // already in positional form pass through.
543 (value, Self::Model(_)) => return self.raise_document(resolve, value),
544 (Value::List(items), Self::List(elem)) => Value::List(
545 items
546 .into_iter()
547 .map(|item| elem.cast(resolve, item))
548 .collect::<Result<_>>()?,
549 ),
550 (Value::Record(record), Self::Record(fields)) if fields.len() == record.len() => {
551 Value::Record(ValueRecord::from_vec(
552 fields
553 .iter()
554 .zip(record)
555 .map(|(ty, value)| ty.cast(resolve, value))
556 .collect::<Result<_>>()?,
557 ))
558 }
559 // A union member is picked by shape: cast with the first member
560 // the value satisfies (a wire object satisfies its `Type::Model`
561 // member via the named field check).
562 (value, Self::Union(union)) => match union.iter().find(|ty| value.is_a(resolve, ty)) {
563 Some(ty) => return ty.cast(resolve, value),
564 None => value,
565 },
566 (value, _) => todo!("value={value:#?}; ty={self:#?}"),
567 })
568 }
569
570 /// Raise a value at a document position: a named wire object (the form a
571 /// driver decodes shape-directed) becomes the embedded model's positional
572 /// record, in schema field order. A key the writer omitted decodes to
573 /// `Null`; a key unknown to the schema (written by an external client) is
574 /// dropped. A value already in engine form (an engine-computed positional
575 /// record) passes through, so the conversion is idempotent.
576 fn raise_document(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
577 let Self::Model(embed_id) = self else {
578 panic!("raise_document on non-model type; ty={self:#?}")
579 };
580
581 // Already in engine form — idempotence for engine-computed values.
582 let Value::Object(object) = value else {
583 return Ok(value);
584 };
585
586 let Some(model) = resolve.model(*embed_id) else {
587 return Err(crate::Error::expression_evaluation_failed(format!(
588 "cannot cast to {self:?}: the model is not resolvable in this context"
589 )));
590 };
591
592 let mut entries = object.entries;
593 Ok(Value::Record(ValueRecord::from_vec(
594 model
595 .fields()
596 .iter()
597 .map(|field| {
598 let name = field.name().app_unwrap();
599 match entries.iter().position(|(key, _)| key == name) {
600 Some(index) => field
601 .expr_ty()
602 .cast_document_leaf(resolve, entries.swap_remove(index).1),
603 None => Ok(Value::Null),
604 }
605 })
606 .collect::<Result<_>>()?,
607 )))
608 }
609
610 /// Raise one document-interior value: descend document structure, pass
611 /// through leaves already of the field's type, and cast the rest — the
612 /// wire shapes a shape-directed decode produces (integers by fit,
613 /// temporals / decimals / uuids as text) back to the field's type.
614 fn cast_document_leaf(&self, resolve: &impl Resolve, value: Value) -> Result<Value> {
615 match (self, value) {
616 (Self::Model(_), value @ Value::Object(_)) => self.raise_document(resolve, value),
617 (Self::List(elem), Value::List(items)) => Ok(Value::List(
618 items
619 .into_iter()
620 .map(|item| elem.cast_document_leaf(resolve, item))
621 .collect::<Result<_>>()?,
622 )),
623 (_, Value::Null) => Ok(Value::Null),
624 (ty, value) if value.is_a(resolve, ty) => Ok(value),
625 (ty, value) => ty.cast(resolve, value),
626 }
627 }
628
629 /// Lower a document value from the engine's positional form to the named
630 /// object a driver serializes, directed by the model-level source type —
631 /// the inverse of [`Self::raise_document`]. A `Type::Model` position turns
632 /// its `Value::Record` into a `Value::Object`, resolving the embed's field
633 /// names from the schema and recursing; `List` maps elementwise; anything
634 /// else — including an already-named `Value::Object` — passes through, so
635 /// the conversion is idempotent.
636 fn lower_document(resolve: &impl Resolve, from: &Type, value: Value) -> Result<Value> {
637 Ok(match (from, value) {
638 (Type::Model(embed_id), Value::Record(record)) => {
639 let Some(model) = resolve.model(*embed_id) else {
640 return Err(crate::Error::expression_evaluation_failed(format!(
641 "cannot cast from {from:?}: the model is not resolvable in this context"
642 )));
643 };
644
645 Value::Object(ValueObject::from_vec(
646 model
647 .fields()
648 .iter()
649 .zip(record)
650 .map(|(field, value)| {
651 Ok((
652 field.name().app_unwrap().to_owned(),
653 Self::lower_document(resolve, field.expr_ty(), value)?,
654 ))
655 })
656 .collect::<Result<_>>()?,
657 ))
658 }
659 (Type::List(elem), Value::List(items)) => Value::List(
660 items
661 .into_iter()
662 .map(|item| Self::lower_document(resolve, elem, item))
663 .collect::<Result<_>>()?,
664 ),
665 (_, value) => value,
666 })
667 }
668}
669
670impl From<&Self> for Type {
671 fn from(value: &Self) -> Self {
672 value.clone()
673 }
674}
675
676impl From<ModelId> for Type {
677 fn from(value: ModelId) -> Self {
678 Self::Model(value)
679 }
680}