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// 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}