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}