Skip to main content

toasty_core/schema/
builder.rs

1mod table;
2
3use super::{Result, app, db, mapping};
4use crate::schema::mapping::TableToModel;
5use crate::schema::{Mapping, Schema, Table, TableId};
6use crate::{driver, stmt};
7use indexmap::IndexMap;
8use std::collections::HashSet;
9
10/// Constructs a [`Schema`] from an app-level schema and driver capabilities.
11///
12/// The builder generates the database-level schema (tables, columns, indices)
13/// and the mapping layer that connects app fields to database columns. Call
14/// [`build`](Builder::build) to produce the final, validated [`Schema`].
15///
16/// # Examples
17///
18/// ```ignore
19/// use toasty_core::schema::Builder;
20///
21/// let schema = Builder::new()
22///     .table_name_prefix("myapp_")
23///     .build(app_schema, &capability)
24///     .expect("valid schema");
25/// ```
26#[derive(Debug)]
27pub struct Builder {
28    /// If set, prefix all table names with this string.
29    table_name_prefix: Option<String>,
30}
31
32/// Used to track state during the build process.
33struct BuildSchema<'a> {
34    /// Build options.
35    builder: &'a Builder,
36
37    db: &'a driver::Capability,
38
39    /// Maps table names to identifiers. The identifiers are reserved before the
40    /// table objects are actually created.
41    table_lookup: IndexMap<String, TableId>,
42
43    /// Tables as they are built.
44    tables: Vec<Table>,
45
46    /// App-level to db-level schema mapping.
47    mapping: Mapping,
48}
49
50impl Builder {
51    /// Creates a new `Builder` with default settings.
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use toasty_core::schema::Builder;
57    ///
58    /// let builder = Builder::new();
59    /// ```
60    pub fn new() -> Self {
61        Self {
62            table_name_prefix: None,
63        }
64    }
65
66    /// Sets a prefix that will be prepended to all generated table names.
67    ///
68    /// This is useful for multi-tenant setups or avoiding name collisions.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use toasty_core::schema::Builder;
74    ///
75    /// let mut builder = Builder::new();
76    /// builder.table_name_prefix("myapp_");
77    /// ```
78    pub fn table_name_prefix(&mut self, prefix: &str) -> &mut Self {
79        self.table_name_prefix = Some(prefix.to_string());
80        self
81    }
82
83    /// Builds the complete [`Schema`] from the given app schema and driver
84    /// capabilities.
85    ///
86    /// This method:
87    /// 1. Verifies each model against the driver's capabilities
88    /// 2. Generates field-level constraints (e.g., `VARCHAR` length limits)
89    /// 3. Creates database tables, columns, and indices
90    /// 4. Builds the bidirectional mapping between models and tables
91    /// 5. Validates the resulting schema
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the schema is invalid (e.g., duplicate index names,
96    /// unsupported types, missing references).
97    ///
98    /// # Examples
99    ///
100    /// ```ignore
101    /// use toasty_core::schema::Builder;
102    ///
103    /// let schema = Builder::new()
104    ///     .build(app_schema, &capability)?;
105    /// ```
106    pub fn build(&self, mut app: app::Schema, db: &driver::Capability) -> Result<Schema> {
107        let mut builder = BuildSchema {
108            builder: self,
109            db,
110            table_lookup: IndexMap::new(),
111            tables: vec![],
112            mapping: Mapping {
113                models: IndexMap::new(),
114                document_columns: IndexMap::new(),
115            },
116        };
117
118        // Validate `#[document]` embeds now that every embed is registered.
119        // A document column is typed by the structural `Type::Object`; its
120        // embedded model (`Type::Model`) is recorded in the mapping's
121        // document-column index and resolved on demand, so there is nothing
122        // to rewrite — only to check (named fields, no `#[column]` rename, no
123        // relations, no unrepresentable leaf).
124        verify_document_types(&app)?;
125
126        for model in app.models.values_mut() {
127            // Initial verification pass to ensure all models are valid based on the
128            // specified driver capability.
129            model.verify(db)?;
130
131            // Generate any additional field-level constraints to satisfy the
132            // target database.
133            builder.build_model_constraints(model)?;
134        }
135
136        // Find all models that specified a table name, ensure a table is
137        // created for that model, and link the model with the table.
138        // Skip embedded models as they don't have their own tables.
139        for model in app.models() {
140            // Skip embedded models - they are flattened into parent tables
141            let app::Model::Root(model) = model else {
142                continue;
143            };
144
145            let table = builder.build_table_stub_for_model(model);
146
147            // Create a mapping shell for the model (fields will be built during mapping phase)
148            builder.mapping.models.insert(
149                model.id,
150                mapping::Model {
151                    id: model.id,
152                    table,
153                    columns: vec![],
154                    fields: vec![], // Will be populated during mapping phase
155                    model_to_table: stmt::ExprRecord::default(),
156                    table_to_model: TableToModel::default(),
157                    default_returning: stmt::Expr::null(),
158                },
159            );
160        }
161
162        builder.build_tables_from_models(&app, db)?;
163        builder.index_document_columns(&app);
164
165        let schema = Schema {
166            app,
167            db: db::Schema {
168                tables: builder.tables,
169            },
170            mapping: builder.mapping,
171        };
172
173        // Verify the schema structure
174        schema.verify()?;
175
176        Ok(schema)
177    }
178}
179
180impl Default for Builder {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl BuildSchema<'_> {
187    /// Populates [`Mapping::document_columns`]: for every `#[document]` field
188    /// — including fields nested inside column-expanded embedded structs and
189    /// embedded enum variants — record the field's app-level type
190    /// (`Type::Model` or `List(Model)`) against the column that stores it.
191    ///
192    /// The column itself is typed by the structural `stmt::Type::Object`
193    /// (columns don't know about models); this index is where the engine
194    /// recovers the embedded-model view of a document column.
195    fn index_document_columns(&mut self, app: &app::Schema) {
196        fn collect_field(
197            app: &app::Schema,
198            field: &app::Field,
199            mapped: &mapping::Field,
200            out: &mut IndexMap<db::ColumnId, stmt::Type>,
201        ) {
202            match (&field.ty, mapped) {
203                (app::FieldTy::Primitive(primitive), mapping::Field::Primitive(p))
204                    if document_embed_id(&primitive.ty).is_some() =>
205                {
206                    out.insert(p.column, primitive.ty.clone());
207                }
208                (app::FieldTy::Embedded(_), mapping::Field::Struct(s)) => {
209                    for (field, mapped) in app.fields(s.id).iter().zip(&s.fields) {
210                        collect_field(app, field, mapped, out);
211                    }
212                }
213                (app::FieldTy::Embedded(embedded), mapping::Field::Enum(e)) => {
214                    let app::Model::EmbeddedEnum(embedded_enum) = app.model(embedded.target) else {
215                        panic!("enum field mapping on a non-enum embed")
216                    };
217                    for (index, variant) in e.variants.iter().enumerate() {
218                        for (field, mapped) in
219                            embedded_enum.variant_fields(index).zip(&variant.fields)
220                        {
221                            collect_field(app, field, mapped, out);
222                        }
223                    }
224                }
225                _ => {}
226            }
227        }
228
229        let mut out = IndexMap::new();
230        for model_mapping in self.mapping.models.values() {
231            for (field, mapped) in app
232                .fields(model_mapping.id)
233                .iter()
234                .zip(&model_mapping.fields)
235            {
236                collect_field(app, field, mapped, &mut out);
237            }
238        }
239        self.mapping.document_columns = out;
240    }
241
242    fn build_model_constraints(&self, model: &mut app::Model) -> Result<()> {
243        let model_name = model.name().to_string();
244
245        // Collect Bool fields used as key/index attributes on backends that
246        // don't support BOOL as a key attribute type (e.g. DynamoDB). These
247        // need db::Type::Integer(1) as their storage type.
248        let mut bool_key_fields: HashSet<app::FieldId> = HashSet::new();
249        if let app::Model::Root(root) = &*model
250            && !self.db.bool_key_type
251        {
252            let index_key_fields: HashSet<app::FieldId> = root
253                .indices
254                .iter()
255                .flat_map(|idx| idx.fields.iter().map(|f| f.field))
256                .collect();
257            for f in &root.fields {
258                if (f.primary_key || index_key_fields.contains(&f.id))
259                    && matches!(&f.ty, app::FieldTy::Primitive(p) if matches!(p.ty, stmt::Type::Bool))
260                {
261                    bool_key_fields.insert(f.id);
262                }
263            }
264        }
265
266        let fields = match model {
267            app::Model::Root(root) => &mut root.fields[..],
268            app::Model::EmbeddedStruct(embedded) => &mut embedded.fields[..],
269            app::Model::EmbeddedEnum(_) => return Ok(()),
270        };
271        for field in fields.iter_mut() {
272            if let app::FieldTy::Primitive(primitive) = &mut field.ty {
273                // On backends that don't support BOOL as a key attribute type,
274                // store Bool key/index fields as Integer(1). The engine's
275                // cast mechanism converts Bool ↔ I8 transparently; the driver
276                // never needs to special-case bools-as-numbers.
277                if bool_key_fields.contains(&field.id) {
278                    primitive.storage_ty = Some(db::Type::Integer(1));
279                }
280
281                // `#[document]` storage covers a bare embedded struct
282                // (`Type::Model`) and a collection of embedded structs
283                // (`Type::List(Model)`); both are gated by the same capability.
284                // A plain `Vec<scalar>` has its own gate.
285                let field_name = || {
286                    field.name.app.as_deref().unwrap_or_else(|| {
287                        panic!(
288                            "model `{model_name}` field has no app-level name; \
289                             expected every primitive field to carry one"
290                        )
291                    })
292                };
293
294                let is_document = document_embed_id(&primitive.ty).is_some();
295
296                if is_document {
297                    if !self.db.document_collections {
298                        return Err(crate::Error::unsupported_feature(format!(
299                            "model `{model_name}` field `{}` uses `#[document]` storage, \
300                             but this backend does not yet support `#[document]` fields.",
301                            field_name()
302                        )));
303                    }
304                    // The embed's structure and leaf types are validated up front
305                    // by `verify_document_types` (it can recurse into nested
306                    // embeds via the schema, which this per-field pass cannot).
307                } else if matches!(&primitive.ty, stmt::Type::List(_)) && !self.db.vec_scalar {
308                    return Err(crate::Error::unsupported_feature(format!(
309                        "model `{model_name}` field `{}` is a `Vec<T>` collection, \
310                         but this backend does not yet support `Vec<scalar>` model fields.",
311                        field_name()
312                    )));
313                }
314
315                let storage_ty = db::Type::from_app(
316                    &primitive.ty,
317                    primitive.storage_ty.as_ref(),
318                    &self.db.storage_types,
319                )?;
320
321                if let db::Type::VarChar(size) = storage_ty {
322                    field
323                        .constraints
324                        .push(app::Constraint::length_less_than(size));
325                }
326            }
327        }
328
329        Ok(())
330    }
331}
332
333/// The name of a `#[document]` leaf scalar type that JSON document storage
334/// cannot represent, or `None` if it is supported. Recurses through list
335/// element types; embeds are walked separately by [`verify_document_embed`].
336/// `Zoned` is rejected because no backend can round-trip its `[IANA]`
337/// annotation; `Bytes` because JSON has no binary representation.
338fn document_unsupported_leaf(ty: &stmt::Type) -> Option<&'static str> {
339    match ty {
340        #[cfg(feature = "jiff")]
341        stmt::Type::Zoned => Some("Zoned"),
342        stmt::Type::Bytes => Some("Vec<u8>"),
343        stmt::Type::List(elem) => document_unsupported_leaf(elem),
344        _ => None,
345    }
346}
347
348/// The embedded-struct id a `#[document]` column stores, if any: `Type::Model`
349/// for a bare embed, `List(Model)` for a collection. `None` for a scalar or
350/// `Vec<scalar>` column.
351fn document_embed_id(ty: &stmt::Type) -> Option<app::ModelId> {
352    match ty {
353        stmt::Type::Model(id) => Some(*id),
354        stmt::Type::List(elem) => match &**elem {
355            stmt::Type::Model(id) => Some(*id),
356            _ => None,
357        },
358        _ => None,
359    }
360}
361
362/// Validate every `#[document]` column's embedded struct. A document column
363/// tracks its shape as `Type::Model(embed_id)` and resolves the embed's fields
364/// on demand from the embedded model, so there is nothing to rewrite — only to
365/// check that the embed is JSON-encodable.
366fn verify_document_types(app: &app::Schema) -> Result<()> {
367    for model in app.models.values() {
368        let fields = match model {
369            app::Model::Root(root) => &root.fields,
370            app::Model::EmbeddedStruct(embedded) => &embedded.fields,
371            app::Model::EmbeddedEnum(_) => continue,
372        };
373
374        for field in fields {
375            if let app::FieldTy::Primitive(primitive) = &field.ty
376                && let Some(embed_id) = document_embed_id(&primitive.ty)
377            {
378                verify_document_embed(app, embed_id)?;
379            }
380        }
381    }
382
383    Ok(())
384}
385
386/// Recursively validate an embedded struct used inside a `#[document]`: every
387/// field must be named, free of a `#[column]` rename, not a relation, and have
388/// a JSON-encodable leaf type. Nested embeds (bare, collection, or
389/// column-expanded) are validated as nested documents.
390fn verify_document_embed(app: &app::Schema, embed_id: app::ModelId) -> Result<()> {
391    let app::Model::EmbeddedStruct(embedded) = app.model(embed_id) else {
392        return Err(crate::Error::unsupported_feature(
393            "#[document] elements must be `#[derive(Embed)]` structs",
394        ));
395    };
396
397    for field in &embedded.fields {
398        let Some(name) = field.name.app.as_deref() else {
399            return Err(crate::Error::unsupported_feature(format!(
400                "embedded struct `{}` has an unnamed field; #[document] storage \
401                 requires named fields",
402                embedded.name
403            )));
404        };
405
406        // A `#[column("...")]` rename has no meaning under document storage —
407        // document keys come from the Rust field name.
408        if field.name.storage.is_some() {
409            return Err(crate::Error::unsupported_feature(format!(
410                "embedded struct `{}` field `{name}` has a `#[column]` rename, \
411                 which is not supported inside a #[document] field",
412                embedded.name
413            )));
414        }
415
416        match &field.ty {
417            app::FieldTy::Primitive(primitive) => {
418                // A nested embed (bare or collection) is itself a nested
419                // document; recurse. Otherwise check the scalar leaf.
420                if let Some(nested) = document_embed_id(&primitive.ty) {
421                    verify_document_embed(app, nested)?;
422                } else if let Some(bad) = document_unsupported_leaf(&primitive.ty) {
423                    return Err(crate::Error::unsupported_feature(format!(
424                        "embedded struct `{}` field `{name}` stores `{bad}` inside a \
425                         `#[document]`, which JSON document storage cannot represent.",
426                        embedded.name
427                    )));
428                }
429            }
430            // A nested column-expanded embed becomes a nested document.
431            app::FieldTy::Embedded(embedded_field) => {
432                verify_document_embed(app, embedded_field.target)?;
433            }
434            app::FieldTy::BelongsTo(_) | app::FieldTy::Has(_) | app::FieldTy::Via(_) => {
435                return Err(crate::Error::unsupported_feature(format!(
436                    "embedded struct `{}` field `{name}` is a relation, which is \
437                     not supported inside a #[document] field",
438                    embedded.name
439                )));
440            }
441        }
442    }
443
444    Ok(())
445}