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 = e.variants.get(*step)?;
208
209                            // Check if there's a field index step after the variant
210                            if let Some(field_step) = steps.next() {
211                                // Two steps: variant disc + field index → field
212                                current_field = e.fields.get(*field_step)?;
213                            } else {
214                                // Single step: variant discriminant only → variant
215                                return Some(Resolved::Variant(variant));
216                            }
217                        }
218                        _ => return None,
219                    }
220                }
221                FieldTy::BelongsTo(belongs_to) => {
222                    current_field = belongs_to.target(self).as_root_unwrap().fields.get(*step)?;
223                }
224                FieldTy::Has(has) => {
225                    current_field = has.target(self).as_root_unwrap().fields.get(*step)?;
226                }
227                FieldTy::Via(via) => {
228                    current_field = via.target(self).as_root_unwrap().fields.get(*step)?;
229                }
230            };
231        }
232
233        Some(Resolved::Field(current_field))
234    }
235
236    /// Resolve a projection to a field, walking through the schema.
237    ///
238    /// Returns `None` if the projection is empty, invalid, or resolves to an
239    /// enum variant rather than a field.
240    pub fn resolve_field<'a>(
241        &'a self,
242        root: &'a Model,
243        projection: &stmt::Projection,
244    ) -> Option<&'a Field> {
245        match self.resolve(root, projection) {
246            Some(Resolved::Field(field)) => Some(field),
247            _ => None,
248        }
249    }
250
251    /// Resolves a [`stmt::Path`] to a [`Field`] by extracting the root model
252    /// from the path and delegating to [`resolve_field`](Schema::resolve_field).
253    pub fn resolve_field_path<'a>(&'a self, path: &stmt::Path) -> Option<&'a Field> {
254        let model = self.model(path.root.as_model_unwrap());
255        self.resolve_field(model, &path.projection)
256    }
257}
258
259impl Builder {
260    pub(crate) fn from_macro(models: impl IntoIterator<Item = Model>) -> Result<Schema> {
261        let mut builder = Self { ..Self::default() };
262
263        for model in models {
264            builder.models.insert(model.id(), model);
265        }
266
267        builder.process_models()?;
268        builder.into_schema()
269    }
270
271    fn into_schema(self) -> Result<Schema> {
272        Ok(Schema {
273            models: self.models,
274        })
275    }
276
277    fn process_models(&mut self) -> Result<()> {
278        // All models have been discovered and initialized at some level, now do
279        // the relation linking.
280        self.link_relations()?;
281        self.resolve_via_targets()?;
282        self.verify_no_eager_load_cycles()?;
283
284        Ok(())
285    }
286
287    /// Resolve the `target` of every scalar-terminal `via` relation.
288    ///
289    /// A relation-terminal via knows its target at macro-expansion time (the
290    /// field's element type). A scalar-terminal via does not — the model that
291    /// owns the projected field is whatever the relation chain reaches — so the
292    /// derive leaves `target` unset and it is computed here by walking the
293    /// chain. Runs after [`link_relations`](Self::link_relations) so every
294    /// `Has`/`BelongsTo` target is final.
295    fn resolve_via_targets(&mut self) -> crate::Result<()> {
296        // Collect first; the walk borrows other models immutably.
297        let mut updates = Vec::new();
298
299        for curr in 0..self.models.len() {
300            if self.models[curr].is_embedded() {
301                continue;
302            }
303            let src = self.models[curr].id();
304            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
305                let field = &self.models[curr].as_root_unwrap().fields[index];
306                let FieldTy::Via(via) = &field.ty else {
307                    continue;
308                };
309                let Some(terminal) = via.terminal else {
310                    continue;
311                };
312
313                // The relation chain is the path minus its terminal field.
314                let projection = via.path.projection.as_slice();
315                let relation_steps = &projection[..projection.len() - 1];
316                let field_name = field.name.app_unwrap().to_string();
317                let target = self.walk_via_relation_chain(src, relation_steps, &field_name)?;
318
319                // The terminal must be a stored scalar on the reached model.
320                let terminal_field = &self.models[&target].as_root_unwrap().fields[terminal];
321                if !matches!(terminal_field.ty, FieldTy::Primitive(_)) {
322                    return Err(crate::Error::invalid_schema(format!(
323                        "the `via` terminal `{}::{}` is not a scalar field",
324                        self.models[&target].name().upper_camel_case(),
325                        terminal_field.name.app_unwrap(),
326                    )));
327                }
328
329                updates.push((curr, index, target));
330            }
331        }
332
333        for (curr, index, target) in updates {
334            if let FieldTy::Via(via) = &mut self.models[curr].as_root_mut_unwrap().fields[index].ty
335            {
336                via.target = target;
337            }
338        }
339
340        Ok(())
341    }
342
343    /// Walk a via relation chain, splicing any nested via's own chain, and
344    /// return the model it reaches. Every step must be a relation.
345    fn walk_via_relation_chain(
346        &self,
347        declaring: ModelId,
348        steps: &[usize],
349        field_name: &str,
350    ) -> crate::Result<ModelId> {
351        let mut current = declaring;
352        let mut queue: Vec<usize> = steps.iter().rev().copied().collect();
353
354        while let Some(idx) = queue.pop() {
355            let field = &self.models[&current].as_root_unwrap().fields[idx];
356            match &field.ty {
357                FieldTy::Has(has) => current = has.target,
358                FieldTy::BelongsTo(belongs_to) => current = belongs_to.target,
359                // A nested via contributes its own relation chain (its terminal,
360                // if scalar, is not part of the path through it).
361                FieldTy::Via(inner) => {
362                    let inner_projection = inner.path.projection.as_slice();
363                    let inner_steps = match inner.terminal {
364                        Some(_) => &inner_projection[..inner_projection.len() - 1],
365                        None => inner_projection,
366                    };
367                    for step in inner_steps.iter().rev() {
368                        queue.push(*step);
369                    }
370                }
371                _ => {
372                    return Err(crate::Error::invalid_schema(format!(
373                        "the `via` path for `{}::{}` traverses `{}`, which is not a relation",
374                        self.models[&declaring].name().upper_camel_case(),
375                        field_name,
376                        field.name.app_unwrap(),
377                    )));
378                }
379            }
380        }
381
382        Ok(current)
383    }
384
385    fn verify_no_eager_load_cycles(&self) -> crate::Result<()> {
386        let mut visited = HashSet::new();
387        let mut model_stack = Vec::new();
388        let mut field_stack = Vec::new();
389
390        for model in self.models.values() {
391            if model.is_embedded() {
392                continue;
393            }
394            self.visit_eager_load_graph(
395                model.id(),
396                &mut visited,
397                &mut model_stack,
398                &mut field_stack,
399            )?;
400        }
401
402        Ok(())
403    }
404
405    fn visit_eager_load_graph(
406        &self,
407        model_id: ModelId,
408        visited: &mut HashSet<ModelId>,
409        model_stack: &mut Vec<ModelId>,
410        field_stack: &mut Vec<FieldId>,
411    ) -> crate::Result<()> {
412        if model_stack.contains(&model_id) {
413            return Ok(());
414        }
415
416        if !visited.insert(model_id) {
417            return Ok(());
418        }
419
420        model_stack.push(model_id);
421
422        let model = self.models[&model_id].as_root_unwrap();
423        for field in &model.fields {
424            let Some(target) = eager_relation_target(field) else {
425                continue;
426            };
427
428            if let Some(pos) = model_stack.iter().position(|id| *id == target) {
429                let mut cycle = field_stack[pos..].to_vec();
430                cycle.push(field.id);
431                return Err(crate::Error::invalid_schema(format!(
432                    "eager relation cycle detected: {}",
433                    self.format_eager_load_cycle(&cycle, target)
434                )));
435            }
436
437            field_stack.push(field.id);
438            self.visit_eager_load_graph(target, visited, model_stack, field_stack)?;
439            field_stack.pop();
440        }
441
442        model_stack.pop();
443        Ok(())
444    }
445
446    fn format_eager_load_cycle(&self, fields: &[FieldId], target: ModelId) -> String {
447        let mut parts = Vec::new();
448        for field_id in fields {
449            let model = &self.models[&field_id.model];
450            let field = &model.as_root_unwrap().fields[field_id.index];
451            parts.push(format!(
452                "{}::{}",
453                model.name().upper_camel_case(),
454                field.name.app_unwrap()
455            ));
456        }
457        parts.push(self.models[&target].name().upper_camel_case());
458        parts.join(" -> ")
459    }
460
461    /// Go through all relations and link them to their pairs
462    fn link_relations(&mut self) -> crate::Result<()> {
463        // Because arbitrary models will be mutated throughout the linking
464        // process, models cannot be iterated as that would hold a reference to
465        // `self`. Instead, we use index based iteration.
466
467        // First, link all has-many relations. Has-manys are linked first because
468        // linking them may result in converting has-one relations to BelongTo.
469        // We need this conversion to happen before any of the other processing.
470        for curr in 0..self.models.len() {
471            if self.models[curr].is_embedded() {
472                continue;
473            }
474            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
475                let model = &self.models[curr];
476                let src = model.id();
477                let field = &model.as_root_unwrap().fields[index];
478
479                if let FieldTy::Has(has) = &field.ty
480                    && has.is_many()
481                {
482                    let target = has.target;
483                    let field_name = field.name.app_unwrap().to_string();
484                    let pair = if has.pair_id.is_placeholder() {
485                        self.find_has_many_pair(src, target, &field_name)?
486                    } else {
487                        self.validate_pair(src, target, &field_name, has.pair_id)?;
488                        has.pair_id
489                    };
490                    self.models[curr].as_root_mut_unwrap().fields[index]
491                        .ty
492                        .as_has_mut_unwrap()
493                        .pair_id = pair;
494                }
495            }
496        }
497
498        // Link has-one relations and compute BelongsTo foreign keys
499        for curr in 0..self.models.len() {
500            if self.models[curr].is_embedded() {
501                continue;
502            }
503            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
504                let model = &self.models[curr];
505                let src = model.id();
506                let field = &model.as_root_unwrap().fields[index];
507
508                match &field.ty {
509                    FieldTy::Has(has) if has.is_one() => {
510                        let target = has.target;
511                        let field_name = field.name.app_unwrap().to_string();
512                        let pair = if has.pair_id.is_placeholder() {
513                            match self.find_belongs_to_pair(src, target, &field_name)? {
514                                Some(pair) => pair,
515                                None => {
516                                    return Err(crate::Error::invalid_schema(format!(
517                                        "field `{}::{}` has no matching `BelongsTo` relation on the target model",
518                                        self.models[curr].name().upper_camel_case(),
519                                        field_name,
520                                    )));
521                                }
522                            }
523                        } else {
524                            self.validate_pair(src, target, &field_name, has.pair_id)?;
525                            has.pair_id
526                        };
527
528                        self.models[curr].as_root_mut_unwrap().fields[index]
529                            .ty
530                            .as_has_mut_unwrap()
531                            .pair_id = pair;
532                    }
533                    FieldTy::BelongsTo(belongs_to) => {
534                        assert!(!belongs_to.foreign_key.is_placeholder());
535                        continue;
536                    }
537                    _ => {}
538                }
539            }
540        }
541
542        // Finally, link BelongsTo relations with their pairs
543        for curr in 0..self.models.len() {
544            if self.models[curr].is_embedded() {
545                continue;
546            }
547            for index in 0..self.models[curr].as_root_unwrap().fields.len() {
548                let model = &self.models[curr];
549                let field_id = model.as_root_unwrap().fields[index].id;
550
551                let pair = match &self.models[curr].as_root_unwrap().fields[index].ty {
552                    FieldTy::BelongsTo(belongs_to) => {
553                        let mut pair = None;
554                        let target = match self.models.get_index_of(&belongs_to.target) {
555                            Some(target) => target,
556                            None => {
557                                let model = &self.models[curr];
558                                return Err(crate::Error::invalid_schema(format!(
559                                    "field `{}::{}` references a model that was not registered \
560                                     with the schema; did you forget to register it with `Db::builder()`?",
561                                    model.name().upper_camel_case(),
562                                    model.as_root_unwrap().fields[index].name(),
563                                )));
564                            }
565                        };
566
567                        for target_index in 0..self.models[target].as_root_unwrap().fields.len() {
568                            pair = match &self.models[target].as_root_unwrap().fields[target_index]
569                                .ty
570                            {
571                                FieldTy::Has(has) if has.pair_id == field_id => {
572                                    assert!(pair.is_none());
573                                    Some(
574                                        self.models[target].as_root_unwrap().fields[target_index]
575                                            .id,
576                                    )
577                                }
578                                _ => continue,
579                            }
580                        }
581
582                        if pair.is_none() {
583                            continue;
584                        }
585
586                        pair
587                    }
588                    _ => continue,
589                };
590
591                self.models[curr].as_root_mut_unwrap().fields[index]
592                    .ty
593                    .as_belongs_to_mut_unwrap()
594                    .pair = pair;
595            }
596        }
597
598        Ok(())
599    }
600
601    fn find_belongs_to_pair(
602        &self,
603        src: ModelId,
604        target: ModelId,
605        field_name: &str,
606    ) -> crate::Result<Option<FieldId>> {
607        let src_model = &self.models[&src];
608
609        let target = match self.models.get(&target) {
610            Some(target) => target,
611            None => {
612                return Err(crate::Error::invalid_schema(format!(
613                    "field `{}::{}` references a model that was not registered with the schema; \
614                     did you forget to register it with `Db::builder()`?",
615                    src_model.name().upper_camel_case(),
616                    field_name,
617                )));
618            }
619        };
620
621        // Find all BelongsTo relations that reference the model
622        let belongs_to: Vec<_> = target
623            .as_root_unwrap()
624            .fields
625            .iter()
626            .filter(|field| match &field.ty {
627                FieldTy::BelongsTo(rel) => rel.target == src,
628                _ => false,
629            })
630            .collect();
631
632        match &belongs_to[..] {
633            [field] => Ok(Some(field.id)),
634            [] => Ok(None),
635            _ => Err(crate::Error::invalid_schema(format!(
636                "model `{}` has more than one `BelongsTo` relation targeting `{}`; \
637                 disambiguate by adding `pair = <field>` on the paired `has_many`/`has_one` \
638                 field",
639                target.name().upper_camel_case(),
640                src_model.name().upper_camel_case(),
641            ))),
642        }
643    }
644
645    fn find_has_many_pair(
646        &mut self,
647        src: ModelId,
648        target: ModelId,
649        field_name: &str,
650    ) -> crate::Result<FieldId> {
651        if let Some(field_id) = self.find_belongs_to_pair(src, target, field_name)? {
652            return Ok(field_id);
653        }
654
655        Err(crate::Error::invalid_schema(format!(
656            "field `{}::{}` has no matching `BelongsTo` relation on the target model",
657            self.models[&src].name().upper_camel_case(),
658            field_name,
659        )))
660    }
661
662    /// Verify that `pair` — resolved from `#[has_many(pair = <field>)]` or
663    /// `#[has_one(pair = <field>)]` via `field_name_to_id` on the target —
664    /// names a `BelongsTo` field on `target` that points back at `src`.
665    fn validate_pair(
666        &self,
667        src: ModelId,
668        target: ModelId,
669        field_name: &str,
670        pair: FieldId,
671    ) -> crate::Result<()> {
672        let src_model = &self.models[&src];
673
674        let target_model = match self.models.get(&target) {
675            Some(target) => target,
676            None => {
677                return Err(crate::Error::invalid_schema(format!(
678                    "field `{}::{}` references a model that was not registered with the schema; \
679                     did you forget to register it with `Db::builder()`?",
680                    src_model.name().upper_camel_case(),
681                    field_name,
682                )));
683            }
684        };
685
686        if pair.model != target {
687            return Err(crate::Error::invalid_schema(format!(
688                "field `{}::{}` specifies a `pair` on a model other than its target `{}`",
689                src_model.name().upper_camel_case(),
690                field_name,
691                target_model.name().upper_camel_case(),
692            )));
693        }
694
695        let paired = &target_model.as_root_unwrap().fields[pair.index];
696        match &paired.ty {
697            FieldTy::BelongsTo(rel) if rel.target == src => Ok(()),
698            _ => Err(crate::Error::invalid_schema(format!(
699                "field `{}::{}` specifies `pair = {}`, but `{}::{}` is not a `BelongsTo` \
700                 targeting `{}`",
701                src_model.name().upper_camel_case(),
702                field_name,
703                paired.name.app_unwrap(),
704                target_model.name().upper_camel_case(),
705                paired.name.app_unwrap(),
706                src_model.name().upper_camel_case(),
707            ))),
708        }
709    }
710}
711
712fn eager_relation_target(field: &Field) -> Option<ModelId> {
713    if field.deferred {
714        return None;
715    }
716
717    field.relation_target_id()
718}