Skip to main content

autumn_web/
preload.rs

1//! Eager-loading ("preload") runtime for `#[model]` associations.
2//!
3//! Autumn stays explicit about database work: there is no implicit lazy
4//! loading. You declare relationships on a `#[model]` with
5//! `#[belongs_to(...)]`, `#[has_many(...)]`, and `#[has_one(...)]`, then ask a
6//! `#[repository]` finder to [`preload`](#preload) them in a bounded number of
7//! batched `WHERE ... IN (...)` queries. Accessing an association that was not
8//! preloaded returns a typed [`NotLoaded`] error instead of silently issuing
9//! SQL.
10//!
11//! # The pieces
12//!
13//! * [`Preloaded<T>`] wraps a record and carries its loaded associations. It
14//!   derefs to `T`, so field access (`post.title`) keeps working; generated
15//!   accessor traits add `post.author()` / `post.comments()`.
16//! * [`Associations`](crate::preload::Associations) is the type-erased store
17//!   behind a [`Preloaded`].
18//! * [`NotLoaded`] is returned by an accessor when its association was not part
19//!   of the preload set.
20//! * [`Preloadable`](crate::preload::Preloadable) (db-only) is implemented by
21//!   `#[model]` for each record type and drives the batched loading, including
22//!   nested preload paths.
23//!
24//! See `docs/adr/0008-associations-and-eager-loading.md` for the design
25//! rationale, including how preload interacts with the primary/replica
26//! topology and with cursor pagination.
27
28use std::any::Any;
29use std::collections::HashMap;
30use std::ops::{Deref, DerefMut};
31
32use thiserror::Error;
33
34/// Returned when a preloadable association is accessed without first being
35/// preloaded.
36///
37/// Autumn never lazy-loads: if you call `post.author()` on a record that was
38/// not loaded with `.preload(...)`, you get this error rather than a hidden
39/// SQL round trip.
40#[derive(Debug, Clone, PartialEq, Eq, Error)]
41#[error(
42    "association `{association}` on `{model}` was accessed but not preloaded; \
43     add it to the `.preload(...)` set on the finder query"
44)]
45pub struct NotLoaded {
46    /// The model whose association was accessed, e.g. `"Post"`.
47    pub model: &'static str,
48    /// The association name that was accessed, e.g. `"author"`.
49    pub association: &'static str,
50}
51
52impl NotLoaded {
53    /// Construct a [`NotLoaded`] for a model/association pair.
54    #[must_use]
55    pub const fn new(model: &'static str, association: &'static str) -> Self {
56        Self { model, association }
57    }
58}
59
60/// Type-erased store of preloaded associations attached to a [`Preloaded`]
61/// record.
62///
63/// Keys are the association names declared on the `#[model]`. Values are the
64/// loaded records, boxed as `dyn Any`:
65///
66/// * `belongs_to` / `has_one` store an `Option<Arc<Preloaded<Target>>>`
67///   (`Arc` because several parents may share one related record).
68/// * `has_many` stores a `Vec<Preloaded<Target>>`.
69///
70/// Generated accessors downcast back to the concrete type. A missing key means
71/// "not preloaded" and yields [`NotLoaded`].
72#[derive(Default)]
73pub struct Associations {
74    map: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
75}
76
77impl Associations {
78    /// Create an empty association store.
79    #[must_use]
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Record a loaded association under `key`.
85    pub fn insert<T: Any + Send + Sync>(&mut self, key: &'static str, value: T) {
86        self.map.insert(key, Box::new(value));
87    }
88
89    /// Borrow a loaded association as `T`, or `None` if the key was never
90    /// preloaded (or the stored type does not match `T`).
91    #[must_use]
92    pub fn get<T: Any + Send + Sync>(&self, key: &'static str) -> Option<&T> {
93        self.map.get(key).and_then(|b| b.downcast_ref::<T>())
94    }
95
96    /// Mutably borrow a loaded association as `T`. Used by nested preloading to
97    /// recurse into already-loaded children.
98    #[must_use]
99    pub fn get_mut<T: Any + Send + Sync>(&mut self, key: &'static str) -> Option<&mut T> {
100        self.map.get_mut(key).and_then(|b| b.downcast_mut::<T>())
101    }
102
103    /// Whether `key` has been preloaded.
104    #[must_use]
105    pub fn contains(&self, key: &'static str) -> bool {
106        self.map.contains_key(key)
107    }
108
109    /// Number of preloaded associations.
110    #[must_use]
111    pub fn len(&self) -> usize {
112        self.map.len()
113    }
114
115    /// Whether no associations have been preloaded.
116    #[must_use]
117    pub fn is_empty(&self) -> bool {
118        self.map.is_empty()
119    }
120}
121
122impl std::fmt::Debug for Associations {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        let mut keys: Vec<&&str> = self.map.keys().collect();
125        keys.sort_unstable();
126        f.debug_struct("Associations")
127            .field("loaded", &keys)
128            .finish()
129    }
130}
131
132/// A record paired with its preloaded associations.
133///
134/// `Preloaded<T>` [`Deref`]s to `T`, so all of the record's own fields and
135/// inherent methods are available directly (`post.title`, `post.id`).
136/// Generated association accessor traits — for example `PostAssociations` with
137/// `author()` and `comments()` — are implemented for `Preloaded<Post>`.
138///
139/// ```rust
140/// use autumn_web::preload::Preloaded;
141///
142/// #[derive(Debug)]
143/// struct Post { id: i64, title: String }
144///
145/// let post = Preloaded::new(Post { id: 1, title: "hi".into() });
146/// // Deref: the record's own fields are reachable.
147/// assert_eq!(post.id, 1);
148/// assert_eq!(post.title, "hi");
149/// // Nothing has been preloaded yet, so the association store is empty.
150/// assert!(post.associations().is_empty());
151/// ```
152#[derive(Debug)]
153pub struct Preloaded<T> {
154    inner: T,
155    associations: Associations,
156}
157
158impl<T> Preloaded<T> {
159    /// Wrap a record with an empty association store.
160    #[must_use]
161    pub fn new(inner: T) -> Self {
162        Self {
163            inner,
164            associations: Associations::new(),
165        }
166    }
167
168    /// Borrow the wrapped record.
169    #[must_use]
170    pub const fn inner(&self) -> &T {
171        &self.inner
172    }
173
174    /// Consume the wrapper and return the bare record, dropping associations.
175    #[must_use]
176    pub fn into_inner(self) -> T {
177        self.inner
178    }
179
180    /// Borrow the association store (read-only).
181    #[must_use]
182    pub const fn associations(&self) -> &Associations {
183        &self.associations
184    }
185
186    /// Mutably borrow the association store. Used by generated loaders to
187    /// attach freshly loaded records.
188    pub const fn associations_mut(&mut self) -> &mut Associations {
189        &mut self.associations
190    }
191}
192
193impl<T> Deref for Preloaded<T> {
194    type Target = T;
195
196    fn deref(&self) -> &T {
197        &self.inner
198    }
199}
200
201impl<T> DerefMut for Preloaded<T> {
202    fn deref_mut(&mut self) -> &mut T {
203        &mut self.inner
204    }
205}
206
207impl<T> From<T> for Preloaded<T> {
208    fn from(inner: T) -> Self {
209        Self::new(inner)
210    }
211}
212
213impl<T: serde::Serialize> serde::Serialize for Preloaded<T> {
214    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
215        // Serialize transparently as the inner record. Associations are a
216        // server-side loading concern, not part of the record's wire shape.
217        self.inner.serialize(serializer)
218    }
219}
220
221/// The empty preload specification, used as `Preloadable::Spec` for models that
222/// declare no associations (or for manual models that opt in without nested
223/// preload support).
224#[derive(Debug, Clone, Copy, Default)]
225pub struct NoPreload;
226
227/// Reports a model's *repository* read-scoping so that `preload`'s in-memory
228/// retain can match what generated finders do.
229///
230/// The decision to apply tenant isolation or soft-delete filtering lives on
231/// `#[repository(..., tenant_scoped, soft_delete)]`, which the `#[model]` macro
232/// (the one that generates the retain) cannot see. So the `#[repository]` macro
233/// overrides these — as *inherent* associated fns on the model, which take
234/// priority over this blanket default — for the flags it enables. Models with
235/// no repository (or a non-scoped one) keep the `false` defaults, so preload
236/// applies no scoping they wouldn't get from a finder.
237///
238/// This is framework plumbing, not a public API.
239#[doc(hidden)]
240pub trait AutumnPreloadScopeExt {
241    /// Whether the model's repository is `soft_delete`.
242    #[must_use]
243    fn __autumn_repo_soft_delete_scope() -> bool {
244        false
245    }
246    /// Whether the model's repository is `tenant_scoped`.
247    #[must_use]
248    fn __autumn_repo_tenant_scope() -> bool {
249        false
250    }
251}
252
253impl<T: ?Sized> AutumnPreloadScopeExt for T {}
254
255/// Normalizes a `#[has_many]` child's foreign-key field to `Option<i64>` so the
256/// preload loader can group children uniformly whether the FK column is `NOT
257/// NULL` (`i64`) or nullable (`Option<i64>`, as every `dependent = nullify`
258/// child has).
259///
260/// The `#[has_many]` preload loader groups children into a `HashMap<i64, _>`
261/// keyed by the child's FK field, but the `#[model]` macro has no type
262/// information for that field at expansion time. Both FK shapes implement this
263/// trait, so the generated grouping can call [`FkKey::autumn_fk_key`] and skip
264/// `None` (orphan / detached) children without the macro needing to know the
265/// field's Rust type.
266///
267/// This is framework plumbing, not a public API.
268#[doc(hidden)]
269pub trait FkKey {
270    /// The grouping key, or `None` for a detached (nullified) child that
271    /// belongs to no parent.
272    fn autumn_fk_key(&self) -> ::core::option::Option<i64>;
273}
274
275impl FkKey for i64 {
276    fn autumn_fk_key(&self) -> ::core::option::Option<i64> {
277        ::core::option::Option::Some(*self)
278    }
279}
280
281impl FkKey for ::core::option::Option<i64> {
282    fn autumn_fk_key(&self) -> ::core::option::Option<i64> {
283        *self
284    }
285}
286
287#[cfg(feature = "db")]
288tokio::task_local! {
289    /// Set by a repository's `preload` to the repository's `across_tenants`
290    /// choice, so the (recursive) target tenant-retain can skip the tenant
291    /// predicate for cross-tenant admin/reporting loads — the same way
292    /// `across_tenants()` makes finders skip it. Defaults to `false` (scoped).
293    #[doc(hidden)]
294    pub static PRELOAD_ACROSS_TENANTS: bool;
295}
296
297/// Whether the current preload should bypass tenant scoping (i.e. it was
298/// started from a repository pinned with `across_tenants()`). `false` outside a
299/// preload or when not pinned. Framework plumbing, not a public API.
300#[cfg(feature = "db")]
301#[doc(hidden)]
302#[must_use]
303pub fn preload_across_tenants() -> bool {
304    PRELOAD_ACROSS_TENANTS.try_with(|v| *v).unwrap_or(false)
305}
306
307/// Implement [`Preloadable`] for a hand-written model as a leaf association
308/// target.
309///
310/// `#[model]` implements [`Preloadable`] automatically. Use this macro for
311/// manually-defined models (those not using `#[model]`) that need to appear as
312/// the *target* of a `#[belongs_to]` / `#[has_one]` / `#[has_many]` on another
313/// model. A leaf target loads no associations of its own (its `Spec` is
314/// [`NoPreload`]), so it can be preloaded and wrapped in [`Preloaded`] but not
315/// nested into.
316///
317/// The type must implement `diesel::Queryable`/`Selectable` for its table.
318///
319/// ```ignore
320/// autumn_web::impl_preloadable_leaf!(User);
321/// ```
322#[cfg(feature = "db")]
323#[macro_export]
324macro_rules! impl_preloadable_leaf {
325    ($ty:ty) => {
326        impl $ty {
327            /// Identity scoping for a hand-written preload target: rows are
328            /// returned unchanged (no tenant/soft-delete filtering is applied
329            /// for manually-defined models). See [`Preloaded`].
330            #[doc(hidden)]
331            pub fn __autumn_preload_retain(
332                rows: ::std::vec::Vec<Self>,
333            ) -> $crate::AutumnResult<::std::vec::Vec<Self>> {
334                ::core::result::Result::Ok(rows)
335            }
336
337            /// Identity per-row scoping for a hand-written preload target. See
338            /// [`Preloaded`] and the per-row sibling of `__autumn_preload_retain`
339            /// used by many-to-many (`through =`) preload loaders.
340            #[doc(hidden)]
341            pub fn __autumn_preload_keep(
342                row: Self,
343            ) -> $crate::AutumnResult<::core::option::Option<Self>> {
344                ::core::result::Result::Ok(::core::option::Option::Some(row))
345            }
346        }
347
348        impl $crate::preload::Preloadable for $ty {
349            type Spec = $crate::preload::NoPreload;
350            fn load_associations<'__a>(
351                _records: &'__a mut [$crate::preload::Preloaded<Self>],
352                _spec: &'__a Self::Spec,
353                _conn: &'__a mut $crate::RuntimeConnection,
354            ) -> $crate::preload::PreloadFuture<'__a> {
355                ::std::boxed::Box::pin(async move { ::core::result::Result::Ok(()) })
356            }
357        }
358    };
359}
360
361#[cfg(feature = "db")]
362pub use db_support::{PreloadFuture, Preloadable};
363
364#[cfg(feature = "db")]
365mod db_support {
366    use super::Preloaded;
367    use crate::AutumnResult;
368    use std::future::Future;
369    use std::pin::Pin;
370
371    /// Boxed future returned by [`Preloadable::load_associations`]. Boxing
372    /// breaks the recursion in nested preload paths (a loader that loads
373    /// children then asks the children's loader to run).
374    pub type PreloadFuture<'a> = Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'a>>;
375
376    /// Implemented by every `#[model]` to drive batched eager loading.
377    ///
378    /// `#[model]` generates the implementation; you rarely implement this by
379    /// hand. A manual implementation is only needed for hand-written models
380    /// that appear as the *target* of an association declared elsewhere (so
381    /// they can be wrapped in [`Preloaded`] and, optionally, nested into).
382    pub trait Preloadable: Sized + Send + Sync + 'static {
383        /// The builder describing which associations (and nested associations)
384        /// to load. `#[model]` generates a `{Model}Preload` type;
385        /// association-free models use [`super::NoPreload`].
386        type Spec: Default + Send + Sync + 'static;
387
388        /// Load every association named in `spec` for `records`, issuing at
389        /// most one batched query per association level, then recurse into any
390        /// nested specs. All queries run on `conn` so preloads share the read
391        /// role of the parent query.
392        fn load_associations<'a>(
393            records: &'a mut [Preloaded<Self>],
394            spec: &'a Self::Spec,
395            conn: &'a mut crate::db::RuntimeConnection,
396        ) -> PreloadFuture<'a>;
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use std::sync::Arc;
404
405    #[derive(Debug, PartialEq)]
406    struct User {
407        id: i64,
408        name: String,
409    }
410
411    #[derive(Debug, PartialEq)]
412    struct Post {
413        id: i64,
414        author_id: i64,
415        title: String,
416    }
417
418    #[derive(Debug, PartialEq)]
419    struct Comment {
420        id: i64,
421        post_id: i64,
422        body: String,
423    }
424
425    #[test]
426    fn deref_exposes_inner_fields() {
427        let post = Preloaded::new(Post {
428            id: 1,
429            author_id: 7,
430            title: "hello".into(),
431        });
432        assert_eq!(post.id, 1);
433        assert_eq!(post.title, "hello");
434    }
435
436    #[test]
437    fn belongs_to_happy_path_returns_loaded_parent() {
438        let mut post = Preloaded::new(Post {
439            id: 1,
440            author_id: 7,
441            title: "t".into(),
442        });
443        let author = Arc::new(Preloaded::new(User {
444            id: 7,
445            name: "ada".into(),
446        }));
447        post.associations_mut()
448            .insert::<Option<Arc<Preloaded<User>>>>("author", Some(author));
449
450        let got = post
451            .associations()
452            .get::<Option<Arc<Preloaded<User>>>>("author")
453            .expect("author preloaded")
454            .as_ref()
455            .expect("author present");
456        assert_eq!(got.name, "ada");
457    }
458
459    #[test]
460    fn belongs_to_missing_parent_is_some_none() {
461        let mut post = Preloaded::new(Post {
462            id: 1,
463            author_id: 99,
464            title: "t".into(),
465        });
466        // Preloaded, but no matching parent row exists.
467        post.associations_mut()
468            .insert::<Option<Arc<Preloaded<User>>>>("author", None);
469
470        // Key present => preloaded; value None => parent genuinely missing.
471        assert!(post.associations().contains("author"));
472        let got = post
473            .associations()
474            .get::<Option<Arc<Preloaded<User>>>>("author")
475            .unwrap();
476        assert!(got.is_none());
477    }
478
479    #[test]
480    fn has_many_groups_children() {
481        let mut post = Preloaded::new(Post {
482            id: 1,
483            author_id: 7,
484            title: "t".into(),
485        });
486        let comments = vec![
487            Preloaded::new(Comment {
488                id: 10,
489                post_id: 1,
490                body: "a".into(),
491            }),
492            Preloaded::new(Comment {
493                id: 11,
494                post_id: 1,
495                body: "b".into(),
496            }),
497        ];
498        post.associations_mut()
499            .insert::<Vec<Preloaded<Comment>>>("comments", comments);
500
501        let got = post
502            .associations()
503            .get::<Vec<Preloaded<Comment>>>("comments")
504            .unwrap();
505        assert_eq!(got.len(), 2);
506        assert_eq!(got[0].body, "a");
507    }
508
509    #[test]
510    fn has_many_empty_children_is_empty_vec() {
511        let mut post = Preloaded::new(Post {
512            id: 1,
513            author_id: 7,
514            title: "t".into(),
515        });
516        post.associations_mut()
517            .insert::<Vec<Preloaded<Comment>>>("comments", Vec::new());
518
519        let got = post
520            .associations()
521            .get::<Vec<Preloaded<Comment>>>("comments")
522            .unwrap();
523        assert!(got.is_empty());
524    }
525
526    #[test]
527    fn not_preloaded_key_is_absent() {
528        let post = Preloaded::new(Post {
529            id: 1,
530            author_id: 7,
531            title: "t".into(),
532        });
533        assert!(!post.associations().contains("author"));
534        assert!(
535            post.associations()
536                .get::<Option<Arc<Preloaded<User>>>>("author")
537                .is_none()
538        );
539    }
540
541    #[test]
542    fn not_loaded_error_carries_model_and_association() {
543        let err = NotLoaded::new("Post", "author");
544        assert_eq!(err.model, "Post");
545        assert_eq!(err.association, "author");
546        let msg = err.to_string();
547        assert!(msg.contains("Post"));
548        assert!(msg.contains("author"));
549        assert!(msg.contains("not preloaded"));
550    }
551
552    #[test]
553    fn shared_parent_via_arc_is_cheap_to_clone() {
554        let author = Arc::new(Preloaded::new(User {
555            id: 7,
556            name: "ada".into(),
557        }));
558        let mut p1 = Preloaded::new(Post {
559            id: 1,
560            author_id: 7,
561            title: "t1".into(),
562        });
563        let mut p2 = Preloaded::new(Post {
564            id: 2,
565            author_id: 7,
566            title: "t2".into(),
567        });
568        p1.associations_mut()
569            .insert::<Option<Arc<Preloaded<User>>>>("author", Some(Arc::clone(&author)));
570        p2.associations_mut()
571            .insert::<Option<Arc<Preloaded<User>>>>("author", Some(author));
572
573        let a1 = p1
574            .associations()
575            .get::<Option<Arc<Preloaded<User>>>>("author")
576            .unwrap()
577            .as_ref()
578            .unwrap();
579        let a2 = p2
580            .associations()
581            .get::<Option<Arc<Preloaded<User>>>>("author")
582            .unwrap()
583            .as_ref()
584            .unwrap();
585        assert!(Arc::ptr_eq(a1, a2));
586    }
587}