umbral_core/orm/reverse_accessor.rs
1//! gaps2 #45 (accessor half) — the zero-declaration instance
2//! reverse-relation accessor: `post.comment_set.all()` as a
3//! generic runtime method available on EVERY model instance, with no
4//! `ReverseSet` field declared on the parent.
5//!
6//! Why a runtime accessor (not a derive-emitted method): Rust
7//! proc-macros can't enumerate a parent's children at the parent's
8//! derive site — the children live in other modules / crates and the
9//! macro only sees the struct it's expanding. So the child type `C` is
10//! named at the CALL site, and the FK column on `C` that points back at
11//! `Self` is discovered at runtime from `C::FIELDS`:
12//!
13//! ```ignore
14//! let kids = parent.reverse::<Comment>()?.fetch().await?;
15//! let recent = parent.reverse::<Comment>()?
16//! .filter(comment::CREATED.gt(cutoff))
17//! .order_by(comment::CREATED.desc())
18//! .fetch().await?;
19//! // Disambiguate when C has more than one FK to this parent:
20//! let via = parent.reverse_via::<Comment>("author")?.fetch().await?;
21//! ```
22//!
23//! The return type is a real chainable [`QuerySet<C>`] — every
24//! `.filter()/.order_by()/.exclude()/.fetch()/.count()/.exists()`
25//! terminal works on it, exactly like `C::objects().filter(...)`. The
26//! discovery + parent-PK read are synchronous and fallible, so the
27//! accessor returns `Result<QuerySet<C>, ReverseError>` up front; the
28//! QuerySet itself stays lazy and awaitable.
29//!
30//! Parent PKs are bound through the same JSON-to-SQL coercion as the
31//! rest of the ORM relation machinery, so i64, String, and UUID PKs all
32//! work.
33
34use sea_query::{Alias, Expr};
35
36use super::Predicate;
37use super::model::{HydrateRelated, Model};
38use super::queryset::{Manager, QuerySet};
39
40/// Why an instance reverse accessor couldn't build its QuerySet. All
41/// variants carry the names involved so the message is actionable
42/// (which models, which columns, which path to take instead).
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ReverseError {
45 /// `C` declares no `ForeignKey<_>` whose target table is this
46 /// parent's table — there's nothing to reverse from.
47 NoForeignKey {
48 child: &'static str,
49 parent_table: &'static str,
50 },
51 /// `C` declares MORE THAN ONE FK to this parent. The call is
52 /// ambiguous; `reverse_via::<C>("<col>")` picks one explicitly.
53 Ambiguous {
54 child: &'static str,
55 parent_table: &'static str,
56 candidates: Vec<&'static str>,
57 },
58 /// `reverse_via` was given a column that doesn't exist on `C`.
59 UnknownColumn { child: &'static str, column: String },
60 /// `reverse_via` was given a column that exists but isn't a FK to
61 /// this parent's table.
62 NotAForeignKey {
63 child: &'static str,
64 column: String,
65 parent_table: &'static str,
66 },
67 /// This instance's PK could not be read or bound into the child FK
68 /// predicate.
69 NonI64Pk { parent: &'static str },
70}
71
72impl std::fmt::Display for ReverseError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 ReverseError::NoForeignKey {
76 child,
77 parent_table,
78 } => write!(
79 f,
80 "umbral::orm::reverse: `{child}` has no foreign key to `{parent_table}` \
81 — there is no reverse relation to follow"
82 ),
83 ReverseError::Ambiguous {
84 child,
85 parent_table,
86 candidates,
87 } => write!(
88 f,
89 "umbral::orm::reverse: `{child}` has multiple foreign keys to `{parent_table}` \
90 ({}). Disambiguate with `reverse_via::<{child}>(\"<column>\")`",
91 candidates.join(", ")
92 ),
93 ReverseError::UnknownColumn { child, column } => write!(
94 f,
95 "umbral::orm::reverse_via: `{child}` has no column `{column}`"
96 ),
97 ReverseError::NotAForeignKey {
98 child,
99 column,
100 parent_table,
101 } => write!(
102 f,
103 "umbral::orm::reverse_via: column `{column}` on `{child}` is not a foreign key \
104 to `{parent_table}`"
105 ),
106 ReverseError::NonI64Pk { parent } => write!(
107 f,
108 "umbral::orm::reverse: `{parent}` primary key could not be bound into the \
109 reverse relation predicate"
110 ),
111 }
112 }
113}
114
115impl std::error::Error for ReverseError {}
116
117/// Generic instance reverse-relation accessors. Blanket-implemented for
118/// every model (anything that is both [`Model`] and [`HydrateRelated`],
119/// which is every `#[derive(Model)]` type), so a fetched instance of
120/// any model gets `.reverse::<C>()` / `.reverse_via::<C>(col)` for free.
121pub trait ReverseRelations: Model + HydrateRelated {
122 /// Build a chainable [`QuerySet<C>`] of the children of type `C`
123 /// whose foreign key points at THIS instance. The FK column is
124 /// discovered from `C::FIELDS` (the field whose `fk_target ==
125 /// Self::TABLE`):
126 ///
127 /// - exactly one → that column is used,
128 /// - zero → [`ReverseError::NoForeignKey`],
129 /// - two or more → [`ReverseError::Ambiguous`] (use [`reverse_via`]).
130 ///
131 /// [`reverse_via`]: ReverseRelations::reverse_via
132 fn reverse<C: Model + HydrateRelated>(&self) -> Result<QuerySet<C>, ReverseError> {
133 let fk_col = discover_single_fk::<Self, C>()?;
134 self.reverse_on::<C>(fk_col)
135 }
136
137 /// Like [`reverse`], but with the FK column on `C` named explicitly
138 /// — the escape hatch for when `C` has more than one FK to this
139 /// parent. The column is validated to exist AND to be an FK to
140 /// `Self::TABLE`.
141 ///
142 /// [`reverse`]: ReverseRelations::reverse
143 fn reverse_via<C: Model + HydrateRelated>(
144 &self,
145 fk_col: &str,
146 ) -> Result<QuerySet<C>, ReverseError> {
147 let spec = C::FIELDS.iter().find(|f| f.name == fk_col).ok_or_else(|| {
148 ReverseError::UnknownColumn {
149 child: C::NAME,
150 column: fk_col.to_string(),
151 }
152 })?;
153 if spec.fk_target != Some(Self::TABLE) {
154 return Err(ReverseError::NotAForeignKey {
155 child: C::NAME,
156 column: fk_col.to_string(),
157 parent_table: Self::TABLE,
158 });
159 }
160 self.reverse_on::<C>(spec.name)
161 }
162
163 /// Shared tail: read this instance's PK and build
164 /// `C::objects().filter(<fk_col> = pk)`.
165 #[doc(hidden)]
166 fn reverse_on<C: Model + HydrateRelated>(
167 &self,
168 fk_col: &'static str,
169 ) -> Result<QuerySet<C>, ReverseError> {
170 let pk = self
171 .pk_as_json()
172 .ok_or(ReverseError::NonI64Pk { parent: Self::NAME })?;
173 let spec = C::FIELDS.iter().find(|f| f.name == fk_col).ok_or_else(|| {
174 ReverseError::UnknownColumn {
175 child: C::NAME,
176 column: fk_col.to_string(),
177 }
178 })?;
179 let parent_pk_ty = Self::FIELDS.iter().find(|f| f.primary_key).map(|f| f.ty);
180 let pk_value =
181 crate::orm::write::json_to_sea_value(spec.ty, &pk, false, fk_col, parent_pk_ty)
182 .map_err(|_| ReverseError::NonI64Pk { parent: Self::NAME })?;
183 // Build the predicate from a runtime column name + the parent
184 // PK. `Predicate::new` is crate-internal, which is exactly why
185 // this accessor lives in umbral-core rather than in a plugin:
186 // turning a runtime column name into a typed `Predicate<C>`
187 // needs the crate-private constructor.
188 let predicate: Predicate<C> = Predicate::new(Expr::col(Alias::new(fk_col)).eq(pk_value));
189 Ok(Manager::<C>::new().filter(predicate))
190 }
191}
192
193impl<T: Model + HydrateRelated> ReverseRelations for T {}
194
195/// Scan `C::FIELDS` for the single FK whose target table is `P::TABLE`.
196/// Zero → `NoForeignKey`; two+ → `Ambiguous` (names every candidate).
197fn discover_single_fk<P: Model, C: Model>() -> Result<&'static str, ReverseError> {
198 let candidates: Vec<&'static str> = C::FIELDS
199 .iter()
200 .filter(|f| f.fk_target == Some(P::TABLE))
201 .map(|f| f.name)
202 .collect();
203 match candidates.len() {
204 1 => Ok(candidates[0]),
205 0 => Err(ReverseError::NoForeignKey {
206 child: C::NAME,
207 parent_table: P::TABLE,
208 }),
209 _ => Err(ReverseError::Ambiguous {
210 child: C::NAME,
211 parent_table: P::TABLE,
212 candidates,
213 }),
214 }
215}