Skip to main content

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