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}