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}