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}