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}