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 embedded_enum
219 .variant_fields(index)
220 .iter()
221 .zip(&variant.fields)
222 {
223 collect_field(app, field, mapped, out);
224 }
225 }
226 }
227 _ => {}
228 }
229 }
230
231 let mut out = IndexMap::new();
232 for model_mapping in self.mapping.models.values() {
233 for (field, mapped) in app
234 .fields(model_mapping.id)
235 .iter()
236 .zip(&model_mapping.fields)
237 {
238 collect_field(app, field, mapped, &mut out);
239 }
240 }
241 self.mapping.document_columns = out;
242 }
243
244 fn build_model_constraints(&self, model: &mut app::Model) -> Result<()> {
245 let model_name = model.name().to_string();
246
247 // Collect Bool fields used as key/index attributes on backends that
248 // don't support BOOL as a key attribute type (e.g. DynamoDB). These
249 // need db::Type::Integer(1) as their storage type.
250 let mut bool_key_fields: HashSet<app::FieldId> = HashSet::new();
251 if let app::Model::Root(root) = &*model
252 && !self.db.bool_key_type
253 {
254 let index_key_fields: HashSet<app::FieldId> = root
255 .indices
256 .iter()
257 .flat_map(|idx| idx.fields.iter().map(|f| f.field))
258 .collect();
259 for f in &root.fields {
260 if (f.primary_key || index_key_fields.contains(&f.id))
261 && matches!(&f.ty, app::FieldTy::Primitive(p) if matches!(p.ty, stmt::Type::Bool))
262 {
263 bool_key_fields.insert(f.id);
264 }
265 }
266 }
267
268 let fields = match model {
269 app::Model::Root(root) => &mut root.fields[..],
270 app::Model::EmbeddedStruct(embedded) => &mut embedded.fields[..],
271 app::Model::EmbeddedEnum(_) => return Ok(()),
272 };
273 for field in fields.iter_mut() {
274 if let app::FieldTy::Primitive(primitive) = &mut field.ty {
275 // On backends that don't support BOOL as a key attribute type,
276 // store Bool key/index fields as Integer(1). The engine's
277 // cast mechanism converts Bool ↔ I8 transparently; the driver
278 // never needs to special-case bools-as-numbers.
279 if bool_key_fields.contains(&field.id) {
280 primitive.storage_ty = Some(db::Type::Integer(1));
281 }
282
283 // `#[document]` storage covers a bare embedded struct
284 // (`Type::Model`) and a collection of embedded structs
285 // (`Type::List(Model)`); both are gated by the same capability.
286 // A plain `Vec<scalar>` has its own gate.
287 let field_name = || {
288 field.name.app.as_deref().unwrap_or_else(|| {
289 panic!(
290 "model `{model_name}` field has no app-level name; \
291 expected every primitive field to carry one"
292 )
293 })
294 };
295
296 let is_document = document_embed_id(&primitive.ty).is_some();
297
298 if is_document {
299 if !self.db.document_collections {
300 return Err(crate::Error::unsupported_feature(format!(
301 "model `{model_name}` field `{}` uses `#[document]` storage, \
302 but this backend does not yet support `#[document]` fields.",
303 field_name()
304 )));
305 }
306 // The embed's structure and leaf types are validated up front
307 // by `verify_document_types` (it can recurse into nested
308 // embeds via the schema, which this per-field pass cannot).
309 } else if matches!(&primitive.ty, stmt::Type::List(_)) && !self.db.vec_scalar {
310 return Err(crate::Error::unsupported_feature(format!(
311 "model `{model_name}` field `{}` is a `Vec<T>` collection, \
312 but this backend does not yet support `Vec<scalar>` model fields.",
313 field_name()
314 )));
315 }
316
317 let storage_ty = db::Type::from_app(
318 &primitive.ty,
319 primitive.storage_ty.as_ref(),
320 &self.db.storage_types,
321 )?;
322
323 if let db::Type::VarChar(size) = storage_ty {
324 field
325 .constraints
326 .push(app::Constraint::length_less_than(size));
327 }
328 }
329 }
330
331 Ok(())
332 }
333}
334
335/// The name of a `#[document]` leaf scalar type that JSON document storage
336/// cannot represent, or `None` if it is supported. Recurses through list
337/// element types; embeds are walked separately by [`verify_document_embed`].
338/// `Zoned` is rejected because no backend can round-trip its `[IANA]`
339/// annotation; `Bytes` because JSON has no binary representation.
340fn document_unsupported_leaf(ty: &stmt::Type) -> Option<&'static str> {
341 match ty {
342 #[cfg(feature = "jiff")]
343 stmt::Type::Zoned => Some("Zoned"),
344 stmt::Type::Bytes => Some("Vec<u8>"),
345 stmt::Type::List(elem) => document_unsupported_leaf(elem),
346 _ => None,
347 }
348}
349
350/// The embedded-struct id a `#[document]` column stores, if any: `Type::Model`
351/// for a bare embed, `List(Model)` for a collection. `None` for a scalar or
352/// `Vec<scalar>` column.
353fn document_embed_id(ty: &stmt::Type) -> Option<app::ModelId> {
354 match ty {
355 stmt::Type::Model(id) => Some(*id),
356 stmt::Type::List(elem) => match &**elem {
357 stmt::Type::Model(id) => Some(*id),
358 _ => None,
359 },
360 _ => None,
361 }
362}
363
364/// Validate every `#[document]` column's embedded struct. A document column
365/// tracks its shape as `Type::Model(embed_id)` and resolves the embed's fields
366/// on demand from the embedded model, so there is nothing to rewrite — only to
367/// check that the embed is JSON-encodable.
368fn verify_document_types(app: &app::Schema) -> Result<()> {
369 for model in app.models.values() {
370 let fields = match model {
371 app::Model::Root(root) => &root.fields,
372 app::Model::EmbeddedStruct(embedded) => &embedded.fields,
373 app::Model::EmbeddedEnum(_) => continue,
374 };
375
376 for field in fields {
377 if let app::FieldTy::Primitive(primitive) = &field.ty
378 && let Some(embed_id) = document_embed_id(&primitive.ty)
379 {
380 verify_document_embed(app, embed_id)?;
381 }
382 }
383 }
384
385 Ok(())
386}
387
388/// Recursively validate an embedded struct used inside a `#[document]`: every
389/// field must be named, free of a `#[column]` rename, not a relation, and have
390/// a JSON-encodable leaf type. Nested embeds (bare, collection, or
391/// column-expanded) are validated as nested documents.
392fn verify_document_embed(app: &app::Schema, embed_id: app::ModelId) -> Result<()> {
393 let app::Model::EmbeddedStruct(embedded) = app.model(embed_id) else {
394 return Err(crate::Error::unsupported_feature(
395 "#[document] elements must be `#[derive(Embed)]` structs",
396 ));
397 };
398
399 for field in &embedded.fields {
400 let Some(name) = field.name.app.as_deref() else {
401 return Err(crate::Error::unsupported_feature(format!(
402 "embedded struct `{}` has an unnamed field; #[document] storage \
403 requires named fields",
404 embedded.name
405 )));
406 };
407
408 // A `#[column("...")]` rename has no meaning under document storage —
409 // document keys come from the Rust field name.
410 if field.name.storage.is_some() {
411 return Err(crate::Error::unsupported_feature(format!(
412 "embedded struct `{}` field `{name}` has a `#[column]` rename, \
413 which is not supported inside a #[document] field",
414 embedded.name
415 )));
416 }
417
418 match &field.ty {
419 app::FieldTy::Primitive(primitive) => {
420 // A nested embed (bare or collection) is itself a nested
421 // document; recurse. Otherwise check the scalar leaf.
422 if let Some(nested) = document_embed_id(&primitive.ty) {
423 verify_document_embed(app, nested)?;
424 } else if let Some(bad) = document_unsupported_leaf(&primitive.ty) {
425 return Err(crate::Error::unsupported_feature(format!(
426 "embedded struct `{}` field `{name}` stores `{bad}` inside a \
427 `#[document]`, which JSON document storage cannot represent.",
428 embedded.name
429 )));
430 }
431 }
432 // A nested column-expanded embed becomes a nested document.
433 app::FieldTy::Embedded(embedded_field) => {
434 verify_document_embed(app, embedded_field.target)?;
435 }
436 app::FieldTy::BelongsTo(_) | app::FieldTy::Has(_) | app::FieldTy::Via(_) => {
437 return Err(crate::Error::unsupported_feature(format!(
438 "embedded struct `{}` field `{name}` is a relation, which is \
439 not supported inside a #[document] field",
440 embedded.name
441 )));
442 }
443 }
444 }
445
446 Ok(())
447}