Skip to main content

toasty_core/schema/app/
schema.rs

1use super::{EnumVariant, Field, FieldId, FieldPrimitive, FieldTy, Model, ModelId};
2
3use crate::{Result, stmt};
4use indexmap::IndexMap;
5use std::collections::HashSet;
6
7/// The result of resolving a [`stmt::Projection`] through the application
8/// schema.
9///
10/// A projection can resolve to either a concrete [`Field`] or an
11/// [`EnumVariant`] (when the projection stops at a variant discriminant
12/// without descending into the variant's data fields).
13///
14/// # Examples
15///
16/// ```ignore
17/// use toasty_core::schema::app::Resolved;
18///
19/// match schema.resolve(root_model, &projection) {
20///     Some(Resolved::Field(f)) => println!("field: {}", f.name),
21///     Some(Resolved::Variant(v)) => println!("variant: {}", v.discriminant),
22///     None => println!("could not resolve"),
23/// }
24/// ```
25#[derive(Debug)]
26pub enum Resolved<'a> {
27    /// The projection resolved to a concrete field.
28    Field(&'a Field),
29    /// The projection resolved to an enum variant (discriminant-only access).
30    Variant(&'a EnumVariant),
31}
32
33/// The top-level application schema, containing all registered models.
34///
35/// `Schema` is the entry point for looking up models, fields, and variants by
36/// their IDs, and for resolving projections through the model graph.
37///
38/// Schemas are typically constructed via `Schema::from_macro` (called by the
39/// `#[derive(Model)]` proc macro) or built manually for testing.
40///
41/// # Examples
42///
43/// ```
44/// use toasty_core::schema::app::Schema;
45///
46/// let schema = Schema::default();
47/// assert_eq!(schema.models().count(), 0);
48/// ```
49#[derive(Debug, Default)]
50pub struct Schema {
51    /// All models in the schema, keyed by [`ModelId`].
52    pub models: IndexMap<ModelId, Model>,
53}
54
55#[derive(Default)]
56struct Builder {
57    models: IndexMap<ModelId, Model>,
58}
59
60impl Schema {
61    /// Builds a `Schema` from a slice of models, linking relations and
62    /// validating consistency.
63    ///
64    /// This is the primary constructor used by the derive macro infrastructure.
65    pub fn from_macro(models: impl IntoIterator<Item = Model>) -> Result<Self> {
66        Builder::from_macro(models)
67    }
68
69    /// Returns a reference to the [`Field`] identified by `id`.
70    ///
71    /// # Panics
72    ///
73    /// Panics if the model or field index is invalid.
74    pub fn field(&self, id: FieldId) -> &Field {
75        self.model(id.model)
76            .fields()
77            .get(id.index)
78            .expect("invalid field ID")
79    }
80
81    /// Returns an iterator over all models in the schema.
82    pub fn models(&self) -> impl Iterator<Item = &Model> {
83        self.models.values()
84    }
85
86    /// Try to get a model by ID, returning `None` if not found.
87    pub fn get_model(&self, id: impl Into<ModelId>) -> Option<&Model> {
88        self.models.get(&id.into())
89    }
90
91    /// Returns a reference to the [`Model`] identified by `id`.
92    ///
93    /// # Panics
94    ///
95    /// Panics if no model with the given ID exists in the schema.
96    pub fn model(&self, id: impl Into<ModelId>) -> &Model {
97        self.models.get(&id.into()).expect("invalid model ID")
98    }
99
100    /// The fields of the model `id`, in declaration order.
101    ///
102    /// An embedded struct backs a `#[document]` column, so this doubles as a
103    /// document column's field layout — the embed is the single source of
104    /// truth for its shape. Callers map each [`Field`] to what they need (its
105    /// [`name`](Field::name), its [`expr_ty`](Field::expr_ty)); a field typed
106    /// `Type::Model` (or `List(Model)`) signals a nested document to recurse
107    /// into.
108    ///
109    /// Panics if no model has the given ID.
110    pub fn fields(&self, id: impl Into<ModelId>) -> &[Field] {
111        self.model(id).fields()
112    }
113
114    /// Walks a positional `projection` through `model`'s fields, descending
115    /// into the nested model whenever a step lands on a model-typed field
116    /// (`Type::Model`). Yields the [`Field`] at each step, in order — the last
117    /// one is the projection's leaf. The caller takes whatever it needs from
118    /// each field (its name, its type).
119    ///
120    /// Iteration stops short of `projection.len()` when a step cannot be
121    /// taken: its index is out of range, or it descends past a field that is
122    /// not model-typed. A projection therefore resolves fully iff the iterator
123    /// yields exactly `projection.len()` fields. Callers that read the leaf
124    /// (the engine's JSON-path lowering, the DynamoDB driver's document-path
125    /// rendering) work from projections already validated by
126    /// [`resolve`](Self::resolve).
127    ///
128    /// Nothing constrains `model` to a `#[document]` embed — the walk follows
129    /// any model-typed field — though document paths are its only use today.
130    pub fn project_fields<'s>(
131        &'s self,
132        model: ModelId,
133        projection: &[usize],
134    ) -> impl Iterator<Item = &'s Field> {
135        let mut current = Some(model);
136        let mut steps = projection.iter();
137
138        std::iter::from_fn(move || {
139            let &index = steps.next()?;
140            let field = self.get_model(current?)?.fields().get(index)?;
141            current = match field.expr_ty() {
142                stmt::Type::Model(nested) => Some(*nested),
143                _ => None,
144            };
145            Some(field)
146        })
147    }
148
149    /// Resolve a projection through the schema, returning either a field or
150    /// an enum variant.
151    ///
152    /// Starting from the root model, walks through each step of the projection,
153    /// resolving fields, following relations/embedded types, and recognizing
154    /// enum variant discriminant access.
155    ///
156    /// Returns `None` if:
157    /// - The projection is empty
158    /// - Any step references an invalid field/variant index
159    /// - A step tries to project through a primitive type
160    pub fn resolve<'a>(
161        &'a self,
162        root: &'a Model,
163        projection: &stmt::Projection,
164    ) -> Option<Resolved<'a>> {
165        let [first, rest @ ..] = projection.as_slice() else {
166            return None;
167        };
168
169        // Get the first field from the root model
170        let mut current_field = root.as_root_unwrap().fields.get(*first)?;
171
172        // Walk through remaining steps. Uses a manual iterator because
173        // embedded enums consume two steps (variant discriminant + field index).
174        let mut steps = rest.iter();
175        while let Some(step) = steps.next() {
176            match &current_field.ty {
177                // A `#[document]` embed stores as one column whose sub-fields
178                // live in the document type rather than as `app::Field`s. The
179                // remaining steps index into the document; validate them and
180                // resolve to the document field itself (the leaf has no
181                // `app::Field`). The path was already type-checked by the
182                // generated accessors.
183                FieldTy::Primitive(FieldPrimitive {
184                    ty: stmt::Type::Model(embed_id),
185                    ..
186                }) => {
187                    // `step` and the remaining `steps` are a contiguous tail
188                    // of `rest`; the steps consumed so far (including `step`)
189                    // place `step` at index `consumed - 1`. The document path
190                    // is that tail, valid iff every step resolves to a field.
191                    let consumed = rest.len() - steps.as_slice().len();
192                    let doc_path = &rest[consumed - 1..];
193                    return (self.project_fields(*embed_id, doc_path).count() == doc_path.len())
194                        .then_some(Resolved::Field(current_field));
195                }
196                FieldTy::Primitive(..) => {
197                    // Cannot project through primitive fields
198                    return None;
199                }
200                FieldTy::Embedded(embedded) => {
201                    let target = self.model(embedded.target);
202                    match target {
203                        Model::EmbeddedStruct(s) => {
204                            current_field = s.fields.get(*step)?;
205                        }
206                        Model::EmbeddedEnum(e) => {
207                            let variant_index = *step;
208                            let variant = e.variants.get(variant_index)?;
209
210                            // Check if there's a field index step after the variant
211                            if let Some(field_step) = steps.next() {
212                                // Local index within the variant, not into
213                                // `EmbeddedEnum::fields`.
214                                current_field = e.variant_fields(variant_index).get(*field_step)?;
215                            } else {
216                                // Single step: variant discriminant only → variant
217                                return Some(Resolved::Variant(variant));
218                            }
219                        }
220                        _ => return None,
221                    }
222                }
223                FieldTy::BelongsTo(belongs_to) => {
224                    current_field = belongs_to.target(self).as_root_unwrap().fields.get(*step)?;
225                }
226                FieldTy::Has(has) => {
227                    current_field = has.target(self).as_root_unwrap().fields.get(*step)?;
228                }
229                FieldTy::Via(via) => {
230                    current_field = via.target(self).as_root_unwrap().fields.get(*step)?;
231                }
232            };
233        }
234
235        Some(Resolved::Field(current_field))
236    }
237
238    /// Resolve a projection to a field, walking through the schema.
239    ///
240    /// Returns `None` if the projection is empty, invalid, or resolves to an
241    /// enum variant rather than a field.
242    pub fn resolve_field<'a>(
243        &'a self,
244        root: &'a Model,
245        projection: &stmt::Projection,
246    ) -> Option<&'a Field> {
247        match self.resolve(root, projection) {
248            Some(Resolved::Field(field)) => Some(field),
249            _ => None,
250        }
251    }
252
253    /// Resolves a [`stmt::Path`] to a [`Field`] by extracting the root model
254    /// from the path and delegating to [`resolve_field`](Schema::resolve_field).
255    pub fn resolve_field_path<'a>(&'a self, path: &stmt::Path) -> Option<&'a Field> {
256        let model = self.model(path.root.as_model_unwrap());
257        self.resolve_field(model, &path.projection)
258    }
259}
260
261impl Builder {
262    pub(crate) fn from_macro(models: impl IntoIterator<Item = Model>) -> Result<Schema> {
263        let mut builder = Self { ..Self::default() };
264
265        for model in models {
266            builder.models.insert(model.id(), model);
267        }
268
269        builder.process_models()?;
270        builder.into_schema()
271    }
272
273    fn into_schema(self) -> Result<Schema> {
274        Ok(Schema {
275            models: self.models,
276        })
277    }
278
279    fn process_models(&mut self) -> Result<()> {
280        // All models have been discovered and initialized at some level, now do
281        // the relation linking.
282        self.link_relations()?;
283        self.resolve_via_targets()?;
284        self.verify_no_eager_load_cycles()?;
285
286        Ok(())
287    }
288
289    /// Resolve the `target` of every scalar-terminal `via` relation.
290    ///
291    /// A relation-terminal via knows its target at macro-expansion time (the
292    /// field's element type). A scalar-terminal via does not — the model that
293    /// owns the projected field is whatever the relation chain reaches — so the
294    /// derive leaves `target` unset and it is computed here by walking the
295    /// chain. Runs after [`link_relations`](Self::link_relations) so every
296    /// `Has`/`BelongsTo` target is final.
297    fn resolve_via_targets(&mut self) -> crate::Result<()> {
298        // Collect first; the walk borrows other models immutably.
299        let mut updates = Vec::new();
300
301        for curr in 0..self.models.len() {
302            if self.models[curr].is_embedded() {
303                continue;
304            }
305            let src = self.models[curr].id();
306            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
307                let field = &self.models[curr].as_root_unwrap().fields[index];
308                let FieldTy::Via(via) = &field.ty else {
309                    continue;
310                };
311                let Some(terminal) = via.terminal else {
312                    continue;
313                };
314
315                // The relation chain is the path minus its terminal field.
316                let projection = via.path.projection.as_slice();
317                let relation_steps = &projection[..projection.len() - 1];
318                let field_name = field.name.app_unwrap().to_string();
319                let target = self.walk_via_relation_chain(src, relation_steps, &field_name)?;
320
321                // The terminal must be a stored scalar on the reached model.
322                let terminal_field = &self.models[&target].as_root_unwrap().fields[terminal];
323                if !matches!(terminal_field.ty, FieldTy::Primitive(_)) {
324                    return Err(crate::Error::invalid_schema(format!(
325                        "the `via` terminal `{}::{}` is not a scalar field",
326                        self.models[&target].name().upper_camel_case(),
327                        terminal_field.name.app_unwrap(),
328                    )));
329                }
330
331                updates.push((curr, index, target));
332            }
333        }
334
335        for (curr, index, target) in updates {
336            if let FieldTy::Via(via) = &mut self.models[curr].as_root_mut_unwrap().fields[index].ty
337            {
338                via.target = target;
339            }
340        }
341
342        Ok(())
343    }
344
345    /// Walk a via relation chain, splicing any nested via's own chain, and
346    /// return the model it reaches. Every step must be a relation.
347    fn walk_via_relation_chain(
348        &self,
349        declaring: ModelId,
350        steps: &[usize],
351        field_name: &str,
352    ) -> crate::Result<ModelId> {
353        let mut current = declaring;
354        let mut queue: Vec<usize> = steps.iter().rev().copied().collect();
355
356        while let Some(idx) = queue.pop() {
357            let field = &self.models[&current].as_root_unwrap().fields[idx];
358            match &field.ty {
359                FieldTy::Has(has) => current = has.target,
360                FieldTy::BelongsTo(belongs_to) => current = belongs_to.target,
361                // A nested via contributes its own relation chain (its terminal,
362                // if scalar, is not part of the path through it).
363                FieldTy::Via(inner) => {
364                    let inner_projection = inner.path.projection.as_slice();
365                    let inner_steps = match inner.terminal {
366                        Some(_) => &inner_projection[..inner_projection.len() - 1],
367                        None => inner_projection,
368                    };
369                    for step in inner_steps.iter().rev() {
370                        queue.push(*step);
371                    }
372                }
373                _ => {
374                    return Err(crate::Error::invalid_schema(format!(
375                        "the `via` path for `{}::{}` traverses `{}`, which is not a relation",
376                        self.models[&declaring].name().upper_camel_case(),
377                        field_name,
378                        field.name.app_unwrap(),
379                    )));
380                }
381            }
382        }
383
384        Ok(current)
385    }
386
387    fn verify_no_eager_load_cycles(&self) -> crate::Result<()> {
388        let mut visited = HashSet::new();
389        let mut model_stack = Vec::new();
390        let mut field_stack = Vec::new();
391
392        for model in self.models.values() {
393            if model.is_embedded() {
394                continue;
395            }
396            self.visit_eager_load_graph(
397                model.id(),
398                &mut visited,
399                &mut model_stack,
400                &mut field_stack,
401            )?;
402        }
403
404        Ok(())
405    }
406
407    fn visit_eager_load_graph(
408        &self,
409        model_id: ModelId,
410        visited: &mut HashSet<ModelId>,
411        model_stack: &mut Vec<ModelId>,
412        field_stack: &mut Vec<FieldId>,
413    ) -> crate::Result<()> {
414        if model_stack.contains(&model_id) {
415            return Ok(());
416        }
417
418        if !visited.insert(model_id) {
419            return Ok(());
420        }
421
422        model_stack.push(model_id);
423
424        let model = self.models[&model_id].as_root_unwrap();
425        for field in &model.fields {
426            let Some(target) = eager_relation_target(field) else {
427                continue;
428            };
429
430            if let Some(pos) = model_stack.iter().position(|id| *id == target) {
431                let mut cycle = field_stack[pos..].to_vec();
432                cycle.push(field.id);
433                return Err(crate::Error::invalid_schema(format!(
434                    "eager relation cycle detected: {}",
435                    self.format_eager_load_cycle(&cycle, target)
436                )));
437            }
438
439            field_stack.push(field.id);
440            self.visit_eager_load_graph(target, visited, model_stack, field_stack)?;
441            field_stack.pop();
442        }
443
444        model_stack.pop();
445        Ok(())
446    }
447
448    fn format_eager_load_cycle(&self, fields: &[FieldId], target: ModelId) -> String {
449        let mut parts = Vec::new();
450        for field_id in fields {
451            let model = &self.models[&field_id.model];
452            let field = &model.as_root_unwrap().fields[field_id.index];
453            parts.push(format!(
454                "{}::{}",
455                model.name().upper_camel_case(),
456                field.name.app_unwrap()
457            ));
458        }
459        parts.push(self.models[&target].name().upper_camel_case());
460        parts.join(" -> ")
461    }
462
463    /// Go through all relations and link them to their pairs
464    fn link_relations(&mut self) -> crate::Result<()> {
465        // Because arbitrary models will be mutated throughout the linking
466        // process, models cannot be iterated as that would hold a reference to
467        // `self`. Instead, we use index based iteration.
468
469        // First, link all has-many relations. Has-manys are linked first because
470        // linking them may result in converting has-one relations to BelongTo.
471        // We need this conversion to happen before any of the other processing.
472        for curr in 0..self.models.len() {
473            if self.models[curr].is_embedded() {
474                continue;
475            }
476            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
477                let model = &self.models[curr];
478                let src = model.id();
479                let field = &model.as_root_unwrap().fields[index];
480
481                if let FieldTy::Has(has) = &field.ty
482                    && has.is_many()
483                {
484                    let target = has.target;
485                    let field_name = field.name.app_unwrap().to_string();
486                    let pair = if has.pair_id.is_placeholder() {
487                        self.find_has_many_pair(src, target, &field_name)?
488                    } else {
489                        self.validate_pair(src, target, &field_name, has.pair_id)?;
490                        has.pair_id
491                    };
492                    self.models[curr].as_root_mut_unwrap().fields[index]
493                        .ty
494                        .as_has_mut_unwrap()
495                        .pair_id = pair;
496                }
497            }
498        }
499
500        // Link has-one relations and compute BelongsTo foreign keys
501        for curr in 0..self.models.len() {
502            if self.models[curr].is_embedded() {
503                continue;
504            }
505            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
506                let model = &self.models[curr];
507                let src = model.id();
508                let field = &model.as_root_unwrap().fields[index];
509
510                match &field.ty {
511                    FieldTy::Has(has) if has.is_one() => {
512                        let target = has.target;
513                        let field_name = field.name.app_unwrap().to_string();
514                        let pair = if has.pair_id.is_placeholder() {
515                            match self.find_belongs_to_pair(src, target, &field_name)? {
516                                Some(pair) => pair,
517                                None => {
518                                    return Err(crate::Error::invalid_schema(format!(
519                                        "field `{}::{}` has no matching `BelongsTo` relation on the target model",
520                                        self.models[curr].name().upper_camel_case(),
521                                        field_name,
522                                    )));
523                                }
524                            }
525                        } else {
526                            self.validate_pair(src, target, &field_name, has.pair_id)?;
527                            has.pair_id
528                        };
529
530                        self.models[curr].as_root_mut_unwrap().fields[index]
531                            .ty
532                            .as_has_mut_unwrap()
533                            .pair_id = pair;
534                    }
535                    FieldTy::BelongsTo(belongs_to) => {
536                        assert!(!belongs_to.foreign_key.is_placeholder());
537                        continue;
538                    }
539                    _ => {}
540                }
541            }
542        }
543
544        // Finally, link BelongsTo relations with their pairs
545        for curr in 0..self.models.len() {
546            if self.models[curr].is_embedded() {
547                continue;
548            }
549            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
550                let model = &self.models[curr];
551                let field_id = model.as_root_unwrap().fields[index].id;
552
553                let pair = match &self.models[curr].as_root_unwrap().fields[index].ty {
554                    FieldTy::BelongsTo(belongs_to) => {
555                        let mut pair = None;
556                        let target = match self.models.get_index_of(&belongs_to.target) {
557                            Some(target) => target,
558                            None => {
559                                let model = &self.models[curr];
560                                return Err(crate::Error::invalid_schema(format!(
561                                    "field `{}::{}` references a model that was not registered \
562                                     with the schema; did you forget to register it with `Db::builder()`?",
563                                    model.name().upper_camel_case(),
564                                    model.as_root_unwrap().fields[index].name(),
565                                )));
566                            }
567                        };
568
569                        for target_index in 0..self.models[target].as_root_unwrap().fields.len() {
570                            pair = match &self.models[target].as_root_unwrap().fields[target_index]
571                                .ty
572                            {
573                                FieldTy::Has(has) if has.pair_id == field_id => {
574                                    assert!(pair.is_none());
575                                    Some(
576                                        self.models[target].as_root_unwrap().fields[target_index]
577                                            .id,
578                                    )
579                                }
580                                _ => continue,
581                            }
582                        }
583
584                        if pair.is_none() {
585                            continue;
586                        }
587
588                        pair
589                    }
590                    _ => continue,
591                };
592
593                self.models[curr].as_root_mut_unwrap().fields[index]
594                    .ty
595                    .as_belongs_to_mut_unwrap()
596                    .pair = pair;
597            }
598        }
599
600        Ok(())
601    }
602
603    fn find_belongs_to_pair(
604        &self,
605        src: ModelId,
606        target: ModelId,
607        field_name: &str,
608    ) -> crate::Result<Option<FieldId>> {
609        let src_model = &self.models[&src];
610
611        let target = match self.models.get(&target) {
612            Some(target) => target,
613            None => {
614                return Err(crate::Error::invalid_schema(format!(
615                    "field `{}::{}` references a model that was not registered with the schema; \
616                     did you forget to register it with `Db::builder()`?",
617                    src_model.name().upper_camel_case(),
618                    field_name,
619                )));
620            }
621        };
622
623        // Find all BelongsTo relations that reference the model
624        let belongs_to: Vec<_> = target
625            .as_root_unwrap()
626            .fields
627            .iter()
628            .filter(|field| match &field.ty {
629                FieldTy::BelongsTo(rel) => rel.target == src,
630                _ => false,
631            })
632            .collect();
633
634        match &belongs_to[..] {
635            [field] => Ok(Some(field.id)),
636            [] => Ok(None),
637            _ => Err(crate::Error::invalid_schema(format!(
638                "model `{}` has more than one `BelongsTo` relation targeting `{}`; \
639                 disambiguate by adding `pair = <field>` on the paired `has_many`/`has_one` \
640                 field",
641                target.name().upper_camel_case(),
642                src_model.name().upper_camel_case(),
643            ))),
644        }
645    }
646
647    fn find_has_many_pair(
648        &mut self,
649        src: ModelId,
650        target: ModelId,
651        field_name: &str,
652    ) -> crate::Result<FieldId> {
653        if let Some(field_id) = self.find_belongs_to_pair(src, target, field_name)? {
654            return Ok(field_id);
655        }
656
657        Err(crate::Error::invalid_schema(format!(
658            "field `{}::{}` has no matching `BelongsTo` relation on the target model",
659            self.models[&src].name().upper_camel_case(),
660            field_name,
661        )))
662    }
663
664    /// Verify that `pair` — resolved from `#[has_many(pair = <field>)]` or
665    /// `#[has_one(pair = <field>)]` via `field_name_to_id` on the target —
666    /// names a `BelongsTo` field on `target` that points back at `src`.
667    fn validate_pair(
668        &self,
669        src: ModelId,
670        target: ModelId,
671        field_name: &str,
672        pair: FieldId,
673    ) -> crate::Result<()> {
674        let src_model = &self.models[&src];
675
676        let target_model = match self.models.get(&target) {
677            Some(target) => target,
678            None => {
679                return Err(crate::Error::invalid_schema(format!(
680                    "field `{}::{}` references a model that was not registered with the schema; \
681                     did you forget to register it with `Db::builder()`?",
682                    src_model.name().upper_camel_case(),
683                    field_name,
684                )));
685            }
686        };
687
688        if pair.model != target {
689            return Err(crate::Error::invalid_schema(format!(
690                "field `{}::{}` specifies a `pair` on a model other than its target `{}`",
691                src_model.name().upper_camel_case(),
692                field_name,
693                target_model.name().upper_camel_case(),
694            )));
695        }
696
697        let paired = &target_model.as_root_unwrap().fields[pair.index];
698        match &paired.ty {
699            FieldTy::BelongsTo(rel) if rel.target == src => Ok(()),
700            _ => Err(crate::Error::invalid_schema(format!(
701                "field `{}::{}` specifies `pair = {}`, but `{}::{}` is not a `BelongsTo` \
702                 targeting `{}`",
703                src_model.name().upper_camel_case(),
704                field_name,
705                paired.name.app_unwrap(),
706                target_model.name().upper_camel_case(),
707                paired.name.app_unwrap(),
708                src_model.name().upper_camel_case(),
709            ))),
710        }
711    }
712}
713
714fn eager_relation_target(field: &Field) -> Option<ModelId> {
715    if field.deferred {
716        return None;
717    }
718
719    field.relation_target_id()
720}