Skip to main content

umbral_core/orm/
reverse_set.rs

1//! `ReverseSet<C>` — reverse-FK collection field on a parent model.
2//!
3//! Gap #44 / feature #19's remaining open item. Stores no SQL column
4//! on the parent table; the related rows live in `C`'s own table with
5//! a FK column pointing back at the parent. After
6//! `.prefetch_related("comment_set")`, the slot is populated with
7//! every child whose FK matches the parent's PK.
8//!
9//! ## Declaration
10//!
11//! On the parent struct, mark the field `#[sqlx(skip)]` +
12//! `#[serde(skip)]` (no DB column to decode, no JSON shape to emit
13//! by default) and tag it with `#[umbral(reverse_fk = "<fk_col>")]`
14//! naming the FK column on the child that points back:
15//!
16//! ```rust,ignore
17//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
18//! pub struct Post {
19//!     pub id: i64,
20//!     pub title: String,
21//!     /// Comment has `pub post: ForeignKey<Post>` — that's the
22//!     /// "post" the attribute names.
23//!     #[sqlx(skip)]
24//!     #[serde(skip)]
25//!     #[umbral(reverse_fk = "post")]
26//!     pub comment_set: ReverseSet<Comment>,
27//! }
28//! ```
29//!
30//! ## Loading
31//!
32//! ```rust,ignore
33//! let posts = Post::objects()
34//!     .prefetch_related("comment_set")
35//!     .fetch()
36//!     .await?;
37//! for post in &posts {
38//!     for comment in post.comment_set.resolved().unwrap() {
39//!         println!("{}: {}", post.title, comment.body);
40//!     }
41//! }
42//! ```
43//!
44//! Query budget: 1 (parents) + 1 (children) regardless of parent
45//! count. No N+1.
46
47use std::marker::PhantomData;
48
49use serde::{Deserialize, Serialize};
50
51use super::Model;
52
53/// A reverse-FK collection field on a parent model. The framework
54/// fills `resolved` via `.prefetch_related(field_name)`; without that
55/// chain method `resolved()` returns `None` and `set_parent_id` /
56/// `set_fk_column` stay unset (the field is inert).
57#[derive(Debug, Clone)]
58pub struct ReverseSet<C: Model> {
59    /// Cached parent-row PK as a `serde_json::Value` (PK lift — was
60    /// `Option<i64>`). Set by the macro-emitted `set_m2m_parent_ids` hook
61    /// (which post-#44 covers both M2M and reverse-FK slots) after each
62    /// parent row is decoded. Holding the PK shape-agnostically lets a
63    /// `String`/slug- or `Uuid`-PK parent carry a `ReverseSet` field; the
64    /// prefetch loader groups children by the parent's `pk_as_json()`
65    /// regardless of this cache.
66    parent_id: Option<serde_json::Value>,
67    /// Name of the FK column on `C` that points back at the parent.
68    /// Set by the same macro hook from the `#[umbral(reverse_fk =
69    /// "...")]` attribute. The prefetch loader emits
70    /// `WHERE <fk_column> IN (parent_pks)` against `C::TABLE`.
71    fk_column: Option<&'static str>,
72    /// Resolved children. `None` = not loaded
73    /// (`.prefetch_related(...)` wasn't called for this field).
74    /// `Some(vec![])` = loaded but no matching children, distinct
75    /// from "not loaded yet" so callers can branch.
76    resolved: Option<Vec<C>>,
77    _phantom: PhantomData<C>,
78}
79
80/// `Default` is what the `sqlx::FromRow` `#[sqlx(skip)]` path uses
81/// to fill the slot. `HydrateRelated::set_m2m_parent_ids` then seeds
82/// `parent_id` + `fk_column` from the just-decoded parent row.
83impl<C: Model> Default for ReverseSet<C> {
84    fn default() -> Self {
85        Self::empty()
86    }
87}
88
89impl<C: Model> ReverseSet<C> {
90    /// Construct an empty (unloaded, no parent yet) ReverseSet.
91    pub fn empty() -> Self {
92        Self {
93            parent_id: None,
94            fk_column: None,
95            resolved: None,
96            _phantom: PhantomData,
97        }
98    }
99
100    /// Borrow the resolved children as a slice. `None` means
101    /// prefetch wasn't called for this field; the framework never
102    /// silently loads children on first access (no lazy loading by
103    /// design — Rust has no property accessors to intercept and
104    /// hidden round-trips are surprising).
105    pub fn resolved(&self) -> Option<&[C]> {
106        self.resolved.as_deref()
107    }
108
109    /// Set the parent's PK on this slot so the prefetch loader knows
110    /// which `WHERE <fk_column> = parent_pk` bucket to target.
111    /// Called by the macro-emitted `set_m2m_parent_ids` arm with the
112    /// parent's PK as a `serde_json::Value` (PK lift — was `i64`).
113    pub fn set_parent_id(&mut self, id: serde_json::Value) {
114        self.parent_id = Some(id);
115    }
116
117    /// Set the FK column name on the child. Called by the same macro
118    /// hook from the `#[umbral(reverse_fk = "...")]` attribute.
119    pub fn set_fk_column(&mut self, col: &'static str) {
120        self.fk_column = Some(col);
121    }
122
123    /// Read the parent PK + FK column the prefetch loader needs.
124    /// `None` for either means this slot was never wired up (the
125    /// macro didn't see a `#[umbral(reverse_fk = ...)]` attribute, or
126    /// `set_m2m_parent_ids` wasn't called yet). Returns
127    /// `Option<(parent_id, fk_column)>` so the caller can early-exit
128    /// without a separate isset check.
129    pub fn parent_link(&self) -> Option<(&serde_json::Value, &'static str)> {
130        match (&self.parent_id, self.fk_column) {
131            (Some(id), Some(col)) => Some((id, col)),
132            _ => None,
133        }
134    }
135
136    /// Populate the resolved bucket. Called once by the prefetch
137    /// loader after grouping the batched child rows by `fk_column`
138    /// value.
139    pub fn set_resolved(&mut self, rows: Vec<C>) {
140        self.resolved = Some(rows);
141    }
142}
143
144/// Serialize: emit the resolved children (or `[]` if not yet
145/// loaded). Symmetric with `M2M`'s shape so templates / REST
146/// serialisation doesn't surprise users with `null` for unloaded
147/// slots.
148impl<C: Model + Serialize> Serialize for ReverseSet<C> {
149    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
150        match &self.resolved {
151            Some(rows) => rows.serialize(s),
152            None => Vec::<C>::new().serialize(s),
153        }
154    }
155}
156
157/// Deserialize: accepts a JSON array of `C` (the
158/// already-resolved shape). Round-trip support for a prefetched
159/// parent. Tolerates `null` / missing by returning an unloaded
160/// default — the same shape `#[serde(skip)]` would produce.
161impl<'de, C: Model + Deserialize<'de>> Deserialize<'de> for ReverseSet<C> {
162    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
163        let opt = Option::<Vec<C>>::deserialize(d).unwrap_or(None);
164        Ok(Self {
165            parent_id: None,
166            fk_column: None,
167            resolved: opt,
168            _phantom: PhantomData,
169        })
170    }
171}
172
173// =========================================================================
174// sqlx: same "should never run" safety net as M2M<T>. ReverseSet fields
175// must be marked `#[sqlx(skip)]` on the parent struct so FromRow uses
176// the Default impl rather than trying to decode a column that doesn't
177// exist. These impls exist only to keep code that accidentally selects
178// a ReverseSet column from hard-erroring.
179// =========================================================================
180
181impl<C: Model> sqlx::Type<sqlx::Sqlite> for ReverseSet<C> {
182    fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
183        <i64 as sqlx::Type<sqlx::Sqlite>>::type_info()
184    }
185    fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
186        <i64 as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
187    }
188}
189
190impl<C: Model> sqlx::Type<sqlx::Postgres> for ReverseSet<C> {
191    fn type_info() -> sqlx::postgres::PgTypeInfo {
192        <i64 as sqlx::Type<sqlx::Postgres>>::type_info()
193    }
194    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
195        <i64 as sqlx::Type<sqlx::Postgres>>::compatible(ty)
196    }
197}
198
199impl<'r, C: Model> sqlx::Decode<'r, sqlx::Sqlite> for ReverseSet<C> {
200    fn decode(
201        value: sqlx::sqlite::SqliteValueRef<'r>,
202    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
203        let _ = <i64 as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
204        Ok(Self::empty())
205    }
206}
207
208impl<'r, C: Model> sqlx::Decode<'r, sqlx::Postgres> for ReverseSet<C> {
209    fn decode(
210        value: sqlx::postgres::PgValueRef<'r>,
211    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
212        let _ = <i64 as sqlx::Decode<sqlx::Postgres>>::decode(value)?;
213        Ok(Self::empty())
214    }
215}