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}