rustio_admin/admin/relations.rs
1//! Relation Intelligence Layer — runtime registry.
2//!
3//! The admin renders, filters, and guards deletes on foreign keys by
4//! consulting a `RelationRegistry` built pure-functionally from the
5//! current `[AdminEntry]` list. The registry itself is data; it does
6//! no I/O and holds no connections.
7//!
8//! Tier 1 supports only `BelongsTo` relations (declared via
9//! `#[rustio(belongs_to = "Target")]` on the struct field). The
10//! legacy schema-driven variant (built from `crate::schema::Schema`)
11//! has been replaced with the AdminEntry-driven build below — Tier 1
12//! has no separate schema layer.
13//!
14//! ## Two lookup tables, computed once per Admin reload
15//!
16//! - `belongs_to[(model, field)] → ResolvedRelation` — forward direction.
17//! - `has_many[model] → Vec<InverseRelation>` — every incoming edge
18//! into `model`, used by the inverse-panel renderer and the delete
19//! guard.
20
21use std::collections::HashMap;
22
23use super::types::AdminEntry;
24
25// public:
26/// Soft cap on the number of rows a relation filter will expose as a
27/// `<select>` dropdown. Above this threshold the admin renders a
28/// numeric-id input instead. 500 was chosen to fit comfortably in
29/// one HTTP round-trip.
30pub const RELATION_FILTER_DROPDOWN_CAP: usize = 500;
31
32// public:
33/// One forward (`BelongsTo`) relation resolved against the current
34/// admin registration.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ResolvedRelation {
37 /// Model name holding the FK column, e.g. `"Appointment"`.
38 pub source_model: String,
39 /// Field on the source carrying the id, e.g. `"patient_id"`.
40 pub source_field: String,
41 /// Target model name, e.g. `"Patient"`.
42 pub target_model: String,
43 /// Target model's SQL table.
44 pub target_table: String,
45 /// Target model's admin slug (`/admin/<slug>/<id>`).
46 pub target_admin_name: String,
47 /// Column on the target whose value is rendered as the human
48 /// label. `None` means the admin renders `#<id>` and does NOT
49 /// infer a column.
50 pub target_display_field: Option<String>,
51}
52
53// public:
54/// One reverse (`HasMany`) relation — an incoming edge pointing at a
55/// given target model. Produced by inverting every stored `BelongsTo`
56/// at registry-build time.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct InverseRelation {
59 /// Source model holding the FK, e.g. `"Appointment"`.
60 pub source_model: String,
61 /// Source model's SQL table.
62 pub source_table: String,
63 /// Source model's admin slug for filter links.
64 pub source_admin_name: String,
65 /// Source model's display name (plural) — used as the panel heading.
66 pub source_display_name: String,
67 /// Field on the source pointing at `target_model.id`.
68 pub source_field: String,
69 /// Target model name — supplied for symmetry with [`ResolvedRelation`].
70 pub target_model: String,
71}
72
73// public:
74/// Why a [`RelationRegistry`] declaration was rejected.
75#[non_exhaustive]
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RegistryError {
78 /// `#[rustio(belongs_to = "X")]` on `<model>.<field>` but `X`
79 /// isn't a registered admin entry.
80 UnknownTarget {
81 model: String,
82 field: String,
83 target: String,
84 },
85 /// `display = "col"` but `col` isn't a field on the target model.
86 UnknownDisplayField {
87 model: String,
88 field: String,
89 target: String,
90 display: String,
91 },
92}
93
94impl std::fmt::Display for RegistryError {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 Self::UnknownTarget {
98 model,
99 field,
100 target,
101 } => write!(
102 f,
103 "`{model}.{field}` declares `belongs_to = \"{target}\"`, \
104 but no admin entry named `{target}` is registered"
105 ),
106 Self::UnknownDisplayField {
107 model,
108 field,
109 target,
110 display,
111 } => write!(
112 f,
113 "`{model}.{field}` declares `display = \"{display}\"` against `{target}`, \
114 but `{target}` has no field named `{display}`"
115 ),
116 }
117 }
118}
119
120impl std::error::Error for RegistryError {}
121
122// public:
123/// Relation lookup tables for one snapshot of the admin registration.
124#[derive(Debug, Clone, Default)]
125pub struct RelationRegistry {
126 belongs_to: HashMap<(String, String), ResolvedRelation>,
127 has_many: HashMap<String, Vec<InverseRelation>>,
128 /// Forward relations indexed by source model.
129 belongs_to_of: HashMap<String, Vec<ResolvedRelation>>,
130}
131
132impl RelationRegistry {
133 // public:
134 /// Empty registry. Every lookup returns `None`.
135 pub fn empty() -> Self {
136 Self::default()
137 }
138
139 // public:
140 /// Build the registry from the current admin entries. Silent on
141 /// unknown targets / display fields — call [`validate`](Self::validate)
142 /// after if you want those surfaced as errors.
143 pub fn from_admin_entries(entries: &[AdminEntry]) -> Self {
144 let mut belongs_to: HashMap<(String, String), ResolvedRelation> = HashMap::new();
145 let mut has_many: HashMap<String, Vec<InverseRelation>> = HashMap::new();
146 let mut belongs_to_of: HashMap<String, Vec<ResolvedRelation>> = HashMap::new();
147
148 // Index by singular name for O(1) target lookup.
149 let by_singular: HashMap<&str, &AdminEntry> =
150 entries.iter().map(|e| (e.singular_name, e)).collect();
151
152 for source in entries {
153 for field in source.fields {
154 let Some(rel) = &field.relation else {
155 continue;
156 };
157 let Some(target) = by_singular.get(rel.target_model) else {
158 continue;
159 };
160 // Validate display_field against target's fields, drop
161 // silently if missing; `validate` surfaces it.
162 let display_field = match rel.display_field {
163 None => None,
164 Some(col) => {
165 if target.fields.iter().any(|f| f.name == col) {
166 Some(col.to_string())
167 } else {
168 None
169 }
170 }
171 };
172
173 let resolved = ResolvedRelation {
174 source_model: source.singular_name.to_string(),
175 source_field: field.name.to_string(),
176 target_model: target.singular_name.to_string(),
177 target_table: target.table.to_string(),
178 target_admin_name: target.admin_name.to_string(),
179 target_display_field: display_field,
180 };
181
182 belongs_to.insert(
183 (source.singular_name.to_string(), field.name.to_string()),
184 resolved.clone(),
185 );
186
187 belongs_to_of
188 .entry(source.singular_name.to_string())
189 .or_default()
190 .push(resolved.clone());
191
192 has_many
193 .entry(target.singular_name.to_string())
194 .or_default()
195 .push(InverseRelation {
196 source_model: source.singular_name.to_string(),
197 source_table: source.table.to_string(),
198 source_admin_name: source.admin_name.to_string(),
199 source_display_name: source.display_name.to_string(),
200 source_field: field.name.to_string(),
201 target_model: target.singular_name.to_string(),
202 });
203 }
204 }
205
206 // Deterministic order so panel rendering is stable across runs.
207 for list in has_many.values_mut() {
208 list.sort_by(|a, b| a.source_model.cmp(&b.source_model));
209 }
210 for list in belongs_to_of.values_mut() {
211 list.sort_by(|a, b| a.source_field.cmp(&b.source_field));
212 }
213
214 Self {
215 belongs_to,
216 has_many,
217 belongs_to_of,
218 }
219 }
220
221 // public:
222 /// The `ResolvedRelation` for `(model, field)`, if any.
223 pub fn belongs_to(&self, model: &str, field: &str) -> Option<&ResolvedRelation> {
224 self.belongs_to.get(&(model.to_string(), field.to_string()))
225 }
226
227 // public:
228 /// Every forward relation owned by a source model.
229 pub fn belongs_to_of(&self, model: &str) -> &[ResolvedRelation] {
230 self.belongs_to_of
231 .get(model)
232 .map(|v| v.as_slice())
233 .unwrap_or(&[])
234 }
235
236 // public:
237 /// Every incoming edge into `model`. Used by the inverse-panel
238 /// renderer and the delete guard.
239 pub fn has_many(&self, model: &str) -> &[InverseRelation] {
240 self.has_many
241 .get(model)
242 .map(|v| v.as_slice())
243 .unwrap_or(&[])
244 }
245
246 // public:
247 /// `true` if the registry knows no relations at all.
248 pub fn is_empty(&self) -> bool {
249 self.belongs_to.is_empty()
250 }
251
252 // public:
253 /// Walk every stored relation and report declarations that
254 /// reference models or columns not present in the current admin.
255 pub fn validate(&self, entries: &[AdminEntry]) -> Vec<RegistryError> {
256 let mut errors: Vec<RegistryError> = Vec::new();
257 let by_singular: HashMap<&str, &AdminEntry> =
258 entries.iter().map(|e| (e.singular_name, e)).collect();
259
260 for source in entries {
261 for field in source.fields {
262 let Some(rel) = &field.relation else {
263 continue;
264 };
265 let Some(target) = by_singular.get(rel.target_model) else {
266 errors.push(RegistryError::UnknownTarget {
267 model: source.singular_name.to_string(),
268 field: field.name.to_string(),
269 target: rel.target_model.to_string(),
270 });
271 continue;
272 };
273 if let Some(display) = rel.display_field {
274 if !target.fields.iter().any(|f| f.name == display) {
275 errors.push(RegistryError::UnknownDisplayField {
276 model: source.singular_name.to_string(),
277 field: field.name.to_string(),
278 target: rel.target_model.to_string(),
279 display: display.to_string(),
280 });
281 }
282 }
283 }
284 }
285
286 errors
287 }
288
289 // public:
290 /// A forward iterator over every ResolvedRelation in the registry,
291 /// in deterministic order.
292 pub fn iter_belongs_to(&self) -> impl Iterator<Item = &ResolvedRelation> {
293 let mut entries: Vec<&ResolvedRelation> = self.belongs_to.values().collect();
294 entries.sort_by(|a, b| {
295 a.source_model
296 .cmp(&b.source_model)
297 .then_with(|| a.source_field.cmp(&b.source_field))
298 });
299 entries.into_iter()
300 }
301}