Skip to main content

rustio_core/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` that is built pure-functionally
5//! from a parsed [`Schema`]. The registry itself is data; it does no
6//! I/O and holds no connections. Every rendering or query path passes
7//! a `&RelationRegistry` explicitly — there is no global singleton and
8//! no background refresh.
9//!
10//! ## Scope — what this module owns
11//!
12//! - Two lookup tables computed once per schema:
13//!   (a) `belongs_to[(model, field)] → ResolvedRelation` — the forward
14//!   direction declared via `#[rustio(belongs_to = "Target")]`.
15//!   (b) `has_many[model] → Vec<InverseRelation>` — every incoming
16//!   edge into `model` from any other model, including the
17//!   field on the source that carries the FK.
18//! - A single validation pass flagging declarations that reference
19//!   models that don't exist in the current schema.
20//! - Constants consumed by the admin UI:
21//!   - [`RELATION_FILTER_DROPDOWN_CAP`] — above this row count, a
22//!     filter falls back to a numeric input rather than a `<select>`.
23//!
24//! ## Scope — what this module does NOT own
25//!
26//! - SQL execution. Query builders live in `admin.rs` alongside the
27//!   list / detail / delete handlers that own the `Db` handle.
28//! - Rendering. The admin decides how a resolved relation looks on
29//!   the page; the registry only tells it which target to look up.
30//! - User-facing error messages. The delete-guard's 409 page, the
31//!   filter's "too many options" hint, and the unresolved `#<id>`
32//!   fallback are all rendered by `admin.rs` with copy owned there.
33//!
34//! ## v1 performance notes
35//!
36//! Current FK-label resolution on a list page issues **one extra
37//! SELECT per FK column** with the IDs batched into an `IN (…)`
38//! clause: for a table with `K` foreign keys and `N` rows the cost
39//! is `1 + K` queries, independent of `N`. This is the v1 shape on
40//! purpose — correctness and stability first.
41//!
42//! ### Future JOIN optimisation point
43//!
44//! A later pass will rewrite the list query to `LEFT JOIN` every
45//! target carrying a `display_field`, projecting `display_field` as
46//! an aliased column alongside the FK id. That collapses `1 + K`
47//! round-trips into a single query and moves the join cost from the
48//! application layer into the database engine where it belongs. Not
49//! implemented yet because the admin's list query builder in
50//! `admin.rs` does not currently support projection aliases, and
51//! changing that shape touches more of the codebase than is warranted
52//! for the initial relation layer.
53//!
54//! ## Future evolution — intentionally named extension points
55//!
56//! The v1 module is deliberately narrow. These are the places the
57//! next iteration is expected to grow into, and the lines where they
58//! plug in are annotated in code.
59//!
60//! - **Inverse panels: preview rows.** Phase 4 shows counts only. The
61//!   next step is a small preview table per inverse (top N by
62//!   `created_at DESC`) with its own link to a filtered list page.
63//!   Extension point: a new [`ResolvedRelation::preview_query`]
64//!   helper + a `render_related_preview` call in `admin.rs`.
65//!
66//! - **Inverse panels: per-panel navigation.** Today each panel links
67//!   to `/admin/<inverse_table>?<fk>=<id>`. Future versions may open
68//!   the inverse as a nested section on the same page. Extension
69//!   point: the admin's detail-page renderer, not the registry.
70//!
71//! - **Relation-aware search (Phase 7, design only).** The admin's
72//!   `?q=<text>` today searches in-table string columns only. A
73//!   relation-aware version would: for every `belongs_to` on the
74//!   model carrying a non-`None` `display_field`, issue
75//!   `SELECT id FROM <target> WHERE <display_field> LIKE '%{q}%' LIMIT 200`,
76//!   collect the ids into a `Vec<i64>`, and add `OR <field> IN (<ids>)`
77//!   to the outer list query. A cap of 200 rows per target keeps the
78//!   `IN` list bounded. When every target hits the cap, the UI warns
79//!   ("too many matches for `q` inside `patient.full_name`; refine
80//!   the search"). No code is written in this pass.
81//!
82//! - **Many-to-many / join tables.** Not modelled. A future
83//!   `RelationKind::ManyToMany { via_table, via_left, via_right }`
84//!   variant would live here alongside `BelongsTo` / `HasMany`, with
85//!   the registry learning to walk the join table. Every public
86//!   method on `RelationRegistry` today handles unknown variants
87//!   with `_ => ...` wildcards so the addition is non-breaking.
88
89use std::collections::HashMap;
90
91use crate::schema::{RelationKind, Schema};
92
93/// Soft cap on the number of rows a relation filter will expose as a
94/// `<select>` dropdown. Above this threshold the admin renders a
95/// numeric-id input instead, with a muted hint explaining why.
96///
97/// 500 was chosen to fit comfortably in one HTTP round-trip, not
98/// because of any browser limit on `<option>` count (browsers handle
99/// thousands fine). The real constraint is cognitive: an operator
100/// cannot usefully scan through more than a few hundred options.
101pub const RELATION_FILTER_DROPDOWN_CAP: usize = 500;
102
103/// One forward (`BelongsTo`) relation resolved against the schema.
104///
105/// Carries enough information for the admin to:
106///   1. render the FK column (`target_model`, `target_table`,
107///      `target_display_field`);
108///   2. link to the target's detail page (`target_admin_name`);
109///   3. issue the batched prefetch query for list pages.
110///
111/// Everything is owned `String` rather than `&'static str` because
112/// the registry is built from a parsed [`Schema`] whose `String`
113/// fields outlive the registry. Copying costs once at schema-reload
114/// time and buys simple lifetimes everywhere else.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ResolvedRelation {
117    /// Model name holding the FK column, e.g. `"Appointment"`.
118    pub source_model: String,
119    /// Field on the source carrying the id, e.g. `"patient_id"`.
120    pub source_field: String,
121    /// Target model name, e.g. `"Patient"`.
122    pub target_model: String,
123    /// Target model's SQL table, e.g. `"patients"`.
124    pub target_table: String,
125    /// Target model's admin slug, e.g. `"patients"` — what appears
126    /// in `/admin/<slug>/<id>` URLs.
127    pub target_admin_name: String,
128    /// Column on the target whose value is rendered as the human
129    /// label. `None` means the admin renders `#<id>` and does NOT
130    /// infer a column — display-field guessing is explicitly off.
131    pub target_display_field: Option<String>,
132    /// Direction marker. Always `BelongsTo` for forward relations.
133    pub kind: RelationKind,
134}
135
136/// One reverse (`HasMany`) relation — an incoming edge pointing at a
137/// given target model. Produced by inverting every stored
138/// `BelongsTo` at registry-build time. Consumed by the inverse-panel
139/// renderer and the delete guard.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct InverseRelation {
142    /// Source model holding the FK, e.g. `"Appointment"`.
143    pub source_model: String,
144    /// Source model's SQL table, e.g. `"appointments"`.
145    pub source_table: String,
146    /// Source model's admin slug for filter links.
147    pub source_admin_name: String,
148    /// Source model's display name — used as the panel heading.
149    /// Always the plural form ("Appointments").
150    pub source_display_name: String,
151    /// Field on the source pointing at `target_model.id`.
152    pub source_field: String,
153    /// Target model name — supplied for symmetry with
154    /// [`ResolvedRelation`] even though callers already know it.
155    pub target_model: String,
156}
157
158/// Why a [`RelationRegistry`] declaration was rejected. Each variant
159/// names the enclosing relation so CLI tooling (`rustio schema`,
160/// `ai validate`) can point a user straight at the bad declaration.
161#[non_exhaustive]
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum RegistryError {
164    /// `#[rustio(belongs_to = "X")]` on `<model>.<field>` but `X`
165    /// doesn't exist in the schema.
166    UnknownTarget {
167        model: String,
168        field: String,
169        target: String,
170    },
171    /// `display = "col"` but `col` isn't a field on the target model.
172    /// The macro's compile-time check catches this at build time; the
173    /// runtime check exists as a backstop for hand-edited
174    /// `rustio.schema.json` files (AI-layer users).
175    UnknownDisplayField {
176        model: String,
177        field: String,
178        target: String,
179        display: String,
180    },
181}
182
183impl std::fmt::Display for RegistryError {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        match self {
186            Self::UnknownTarget {
187                model,
188                field,
189                target,
190            } => write!(
191                f,
192                "`{model}.{field}` declares `belongs_to = \"{target}\"`, \
193                 but no model named `{target}` exists in the schema"
194            ),
195            Self::UnknownDisplayField {
196                model,
197                field,
198                target,
199                display,
200            } => write!(
201                f,
202                "`{model}.{field}` declares `display = \"{display}\"` against `{target}`, \
203                 but `{target}` has no field named `{display}`"
204            ),
205        }
206    }
207}
208
209impl std::error::Error for RegistryError {}
210
211/// Relation lookup tables for one snapshot of the schema.
212///
213/// Build once per schema reload and consume by `&`-reference from
214/// every admin handler that needs it. Pure data — no interior
215/// mutability, no cached queries, no hidden state.
216///
217/// ```text
218/// belongs_to[("Appointment", "patient_id")] → ResolvedRelation {
219///     target_model: "Patient",
220///     target_table: "patients",
221///     target_display_field: Some("full_name"),
222///     …
223/// }
224///
225/// has_many["Patient"] → [
226///     InverseRelation { source_model: "Appointment", source_field: "patient_id", … },
227///     InverseRelation { source_model: "Invoice",     source_field: "patient_id", … },
228/// ]
229/// ```
230#[derive(Debug, Clone, Default)]
231pub struct RelationRegistry {
232    belongs_to: HashMap<(String, String), ResolvedRelation>,
233    has_many: HashMap<String, Vec<InverseRelation>>,
234    /// Forward relations indexed by source model. Used by the list
235    /// handler when it needs to enumerate every FK on a given model
236    /// (to render filters, to pre-fetch labels, etc).
237    belongs_to_of: HashMap<String, Vec<ResolvedRelation>>,
238}
239
240impl RelationRegistry {
241    /// Empty registry. Every lookup returns `None`. Useful as a
242    /// safe default when the schema file is missing or fails to
243    /// parse — the admin falls back to raw-id rendering.
244    pub fn empty() -> Self {
245        Self::default()
246    }
247
248    /// Build the registry from a [`Schema`]. Silent on unknown
249    /// targets / display fields — call [`validate`](Self::validate)
250    /// after if you want those surfaced as errors. Building is
251    /// intentionally lenient so a single typo in
252    /// `rustio.schema.json` doesn't blank out the entire registry.
253    pub fn from_schema(schema: &Schema) -> Self {
254        let mut belongs_to: HashMap<(String, String), ResolvedRelation> = HashMap::new();
255        let mut has_many: HashMap<String, Vec<InverseRelation>> = HashMap::new();
256        let mut belongs_to_of: HashMap<String, Vec<ResolvedRelation>> = HashMap::new();
257
258        // Index models by name for O(1) target lookup.
259        let index: HashMap<&str, &crate::schema::SchemaModel> =
260            schema.models.iter().map(|m| (m.name.as_str(), m)).collect();
261
262        for source in &schema.models {
263            for field in &source.fields {
264                let Some(rel) = &field.relation else {
265                    continue;
266                };
267                let Some(target) = index.get(rel.model.as_str()) else {
268                    // Dangling relation: recorded via `validate` below,
269                    // not surfaced here.
270                    continue;
271                };
272                // Silently drop a relation whose display field is
273                // declared but doesn't exist on the target. The admin
274                // falls back to `#<id>`; `validate` surfaces this as a
275                // warning/error for tooling that cares.
276                let display_field = match &rel.display_field {
277                    None => None,
278                    Some(col) => {
279                        if target.fields.iter().any(|f| &f.name == col) {
280                            Some(col.clone())
281                        } else {
282                            None
283                        }
284                    }
285                };
286
287                let resolved = ResolvedRelation {
288                    source_model: source.name.clone(),
289                    source_field: field.name.clone(),
290                    target_model: target.name.clone(),
291                    target_table: target.table.clone(),
292                    target_admin_name: target.admin_name.clone(),
293                    target_display_field: display_field,
294                    kind: rel.kind,
295                };
296
297                belongs_to.insert((source.name.clone(), field.name.clone()), resolved.clone());
298
299                belongs_to_of
300                    .entry(source.name.clone())
301                    .or_default()
302                    .push(resolved.clone());
303
304                if matches!(rel.kind, RelationKind::BelongsTo) {
305                    has_many
306                        .entry(target.name.clone())
307                        .or_default()
308                        .push(InverseRelation {
309                            source_model: source.name.clone(),
310                            source_table: source.table.clone(),
311                            source_admin_name: source.admin_name.clone(),
312                            source_display_name: source.display_name.clone(),
313                            source_field: field.name.clone(),
314                            target_model: target.name.clone(),
315                        });
316                }
317            }
318        }
319
320        // Deterministic order: sort inverse lists by source model name
321        // so panel rendering is stable across runs.
322        for list in has_many.values_mut() {
323            list.sort_by(|a, b| a.source_model.cmp(&b.source_model));
324        }
325        for list in belongs_to_of.values_mut() {
326            list.sort_by(|a, b| a.source_field.cmp(&b.source_field));
327        }
328
329        Self {
330            belongs_to,
331            has_many,
332            belongs_to_of,
333        }
334    }
335
336    /// The `ResolvedRelation` for `(model, field)`, if any.
337    pub fn belongs_to(&self, model: &str, field: &str) -> Option<&ResolvedRelation> {
338        self.belongs_to.get(&(model.to_string(), field.to_string()))
339    }
340
341    /// Every forward relation owned by a source model. Used by the
342    /// list handler to enumerate FK columns for prefetch + filters.
343    /// Returns an empty slice when the model declares none.
344    pub fn belongs_to_of(&self, model: &str) -> &[ResolvedRelation] {
345        self.belongs_to_of
346            .get(model)
347            .map(|v| v.as_slice())
348            .unwrap_or(&[])
349    }
350
351    /// Every incoming edge into `model`. Used by the inverse-panel
352    /// renderer and the delete guard.
353    pub fn has_many(&self, model: &str) -> &[InverseRelation] {
354        self.has_many
355            .get(model)
356            .map(|v| v.as_slice())
357            .unwrap_or(&[])
358    }
359
360    /// `true` if the registry knows no relations at all. Cheap check
361    /// used by list / detail handlers to skip the prefetch machinery
362    /// entirely when the project has no annotations.
363    pub fn is_empty(&self) -> bool {
364        self.belongs_to.is_empty()
365    }
366
367    /// Walk every stored relation and report declarations that
368    /// reference models or columns not present in the schema. The
369    /// macro's compile-time checks already catch these for declared
370    /// fields; this pass also covers hand-edited `rustio.schema.json`
371    /// files (the AI pipeline writes those).
372    pub fn validate(&self, schema: &Schema) -> Vec<RegistryError> {
373        let mut errors: Vec<RegistryError> = Vec::new();
374        let models: HashMap<&str, &crate::schema::SchemaModel> =
375            schema.models.iter().map(|m| (m.name.as_str(), m)).collect();
376
377        for source in &schema.models {
378            for field in &source.fields {
379                let Some(rel) = &field.relation else {
380                    continue;
381                };
382                let Some(target) = models.get(rel.model.as_str()) else {
383                    errors.push(RegistryError::UnknownTarget {
384                        model: source.name.clone(),
385                        field: field.name.clone(),
386                        target: rel.model.clone(),
387                    });
388                    continue;
389                };
390                if let Some(display) = &rel.display_field {
391                    if !target.fields.iter().any(|f| &f.name == display) {
392                        errors.push(RegistryError::UnknownDisplayField {
393                            model: source.name.clone(),
394                            field: field.name.clone(),
395                            target: rel.model.clone(),
396                            display: display.clone(),
397                        });
398                    }
399                }
400            }
401        }
402
403        errors
404    }
405
406    /// A forward iterator over every ResolvedRelation in the
407    /// registry, in deterministic order. Not called on hot paths;
408    /// convenient for tests and CLI introspection commands.
409    pub fn iter_belongs_to(&self) -> impl Iterator<Item = &ResolvedRelation> {
410        let mut entries: Vec<&ResolvedRelation> = self.belongs_to.values().collect();
411        entries.sort_by(|a, b| {
412            a.source_model
413                .cmp(&b.source_model)
414                .then_with(|| a.source_field.cmp(&b.source_field))
415        });
416        entries.into_iter()
417    }
418}