rustio_core/admin/entry_builder.rs
1//! Dynamic AdminEntry construction — 0.7.3.
2//!
3//! The compile-time [`AdminEntry`] list is baked by
4//! `#[derive(RustioAdmin)]` and drives *route registration* — there
5//! is no way to register a GET/POST pair for a struct the binary
6//! doesn't know about. But for everything that just **reads** the
7//! model shape (the dashboard alerts, the suggestion engine, the
8//! schema diff on the review page), the schema on disk is a better
9//! source of truth than the compiled `&'static [AdminField]` slice.
10//!
11//! This module turns that observation into a concrete type. A
12//! [`DynamicAdminEntry`] is the same conceptual shape as an
13//! `AdminEntry` but with owned `String` names and an owned
14//! `Vec<DynamicAdminField>`. Renderers that currently iterate
15//! compile-time entries can iterate dynamic ones instead; the only
16//! thing they give up is `&'static` identity.
17//!
18//! ## Safety
19//!
20//! - [`field_type_from_str`] is total: an unknown string never
21//! panics, it falls back to [`FieldType::String`] (which the
22//! renderer shows as a plain-text input). The CHANGELOG bump rule
23//! means the planner + schema layers would catch unknown types
24//! much earlier, but the fallback here is the defence in depth.
25//! - [`entries_effective`] is deterministic: when the cache is warm,
26//! order follows `schema.models`; when it's cold, order follows
27//! the compile-time slice. Neither path allocates randomness.
28//!
29//! ## What this module does NOT do
30//!
31//! - It does not register routes. Route registration still needs a
32//! real `T: AdminModel` because `item.field_display(name)` is
33//! trait-bound.
34//! - It does not mutate the schema cache. Reads only.
35
36use super::{schema_cache, AdminEntry, AdminField, FieldType};
37use crate::schema::Schema;
38
39/// Same shape as [`AdminEntry`] but with owned strings, so a schema
40/// re-read can rebuild one without touching the compile-time slice.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DynamicAdminEntry {
43 pub admin_name: String,
44 pub display_name: String,
45 pub singular_name: String,
46 pub table: String,
47 pub fields: Vec<DynamicAdminField>,
48 pub core: bool,
49}
50
51/// Same shape as [`AdminField`] but owned. Derived either from a
52/// `&'static AdminField` or from a [`crate::schema::SchemaField`].
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DynamicAdminField {
55 pub name: String,
56 pub ty: FieldType,
57 pub editable: bool,
58 pub nullable: bool,
59}
60
61impl DynamicAdminField {
62 /// Build from a compile-time [`AdminField`]. Zero-cost clone —
63 /// just copies the static slices' bytes into owned strings.
64 pub fn from_admin(f: &AdminField) -> Self {
65 Self {
66 name: f.name.to_string(),
67 ty: f.ty,
68 editable: f.editable,
69 nullable: f.nullable,
70 }
71 }
72}
73
74impl DynamicAdminEntry {
75 /// Project a compile-time entry into a dynamic one. The pair
76 /// is round-trippable for any schema that matches the compiled
77 /// binary — the invariant tested by
78 /// `compile_time_round_trip_matches_admin_entry`.
79 pub fn from_admin(entry: &AdminEntry) -> Self {
80 Self {
81 admin_name: entry.admin_name.to_string(),
82 display_name: entry.display_name.to_string(),
83 singular_name: entry.singular_name.to_string(),
84 table: entry.table.to_string(),
85 fields: entry
86 .fields
87 .iter()
88 .map(DynamicAdminField::from_admin)
89 .collect(),
90 core: entry.core,
91 }
92 }
93}
94
95/// Parse a schema-level type name (`"i32"`, `"DateTime"`, …) back
96/// into a [`FieldType`]. Unknown strings fall through to
97/// [`FieldType::String`] so the renderer shows a plain-text input
98/// instead of panicking — the 0.7.3 "render as PlainText" rule.
99///
100/// Kept as a separate function (rather than a `FromStr` impl) so the
101/// fallback is explicit at every call site.
102pub fn field_type_from_str(ty: &str) -> FieldType {
103 match ty {
104 "i32" => FieldType::I32,
105 "i64" => FieldType::I64,
106 "String" => FieldType::String,
107 "bool" => FieldType::Bool,
108 "DateTime" => FieldType::DateTime,
109 _ => FieldType::String,
110 }
111}
112
113/// Build a `Vec<DynamicAdminEntry>` from the schema on disk. Every
114/// model's fields are projected from schema fields; unknown type
115/// strings fall back to [`FieldType::String`] (see
116/// [`field_type_from_str`]). Order follows `schema.models`.
117pub fn build_admin_entries(schema: &Schema) -> Vec<DynamicAdminEntry> {
118 schema
119 .models
120 .iter()
121 .map(|m| DynamicAdminEntry {
122 admin_name: m.admin_name.clone(),
123 display_name: m.display_name.clone(),
124 singular_name: m.singular_name.clone(),
125 table: m.table.clone(),
126 core: m.core,
127 fields: m
128 .fields
129 .iter()
130 .map(|f| DynamicAdminField {
131 name: f.name.clone(),
132 ty: field_type_from_str(&f.ty),
133 editable: f.editable,
134 nullable: f.nullable,
135 })
136 .collect(),
137 })
138 .collect()
139}
140
141/// Single source of truth for rendering-side admin entries.
142///
143/// When the [`schema_cache`] has a live snapshot, builds dynamic
144/// entries from it. Otherwise falls back to projecting the
145/// compile-time slice. The returned `Vec` is independent of either
146/// source and can be passed to any helper that needs to iterate
147/// models without being type-bound.
148pub fn entries_effective(compiled: &[AdminEntry]) -> Vec<DynamicAdminEntry> {
149 if let Some(cached) = schema_cache::snapshot() {
150 // Intersect with the compiled list so we still drop core
151 // entries the schema happens to mention but the admin never
152 // actually serves (core: true on `User`). Also preserves the
153 // `core` flag correctly — the schema tracks it but the
154 // compiled side is authoritative.
155 let compiled_by_name: std::collections::HashMap<&str, &AdminEntry> =
156 compiled.iter().map(|e| (e.admin_name, e)).collect();
157 return build_admin_entries(&cached.schema)
158 .into_iter()
159 .map(|mut dyn_entry| {
160 if let Some(compiled_e) = compiled_by_name.get(dyn_entry.admin_name.as_str()) {
161 // Trust the compiled entry for `core` since the
162 // admin never registers routes for core models.
163 dyn_entry.core = compiled_e.core;
164 }
165 dyn_entry
166 })
167 .collect();
168 }
169 compiled.iter().map(DynamicAdminEntry::from_admin).collect()
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::schema::{SchemaField, SchemaModel, SCHEMA_VERSION};
176
177 fn tiny_schema() -> Schema {
178 Schema {
179 version: SCHEMA_VERSION,
180 rustio_version: env!("CARGO_PKG_VERSION").to_string(),
181 models: vec![SchemaModel {
182 name: "Post".into(),
183 table: "posts".into(),
184 admin_name: "posts".into(),
185 display_name: "Posts".into(),
186 singular_name: "Post".into(),
187 fields: vec![
188 SchemaField {
189 name: "id".into(),
190 ty: "i64".into(),
191 nullable: false,
192 editable: false,
193 relation: None,
194 },
195 SchemaField {
196 name: "title".into(),
197 ty: "String".into(),
198 nullable: false,
199 editable: true,
200 relation: None,
201 },
202 SchemaField {
203 // Unknown type — exercises the PlainText fallback.
204 name: "odd".into(),
205 ty: "UnknownType".into(),
206 nullable: true,
207 editable: true,
208 relation: None,
209 },
210 ],
211 relations: vec![],
212 core: false,
213 }],
214 }
215 }
216
217 #[test]
218 fn field_type_fallback_is_string_for_unknown() {
219 assert!(matches!(field_type_from_str("i32"), FieldType::I32));
220 assert!(matches!(field_type_from_str("i64"), FieldType::I64));
221 assert!(matches!(field_type_from_str("bool"), FieldType::Bool));
222 assert!(matches!(
223 field_type_from_str("DateTime"),
224 FieldType::DateTime
225 ));
226 assert!(matches!(field_type_from_str("String"), FieldType::String));
227 // Unknown → String (safety).
228 assert!(matches!(field_type_from_str("Decimal"), FieldType::String));
229 assert!(matches!(field_type_from_str(""), FieldType::String));
230 }
231
232 #[test]
233 fn build_admin_entries_mirrors_the_schema() {
234 let s = tiny_schema();
235 let entries = build_admin_entries(&s);
236 assert_eq!(entries.len(), 1);
237 let posts = &entries[0];
238 assert_eq!(posts.admin_name, "posts");
239 assert_eq!(posts.display_name, "Posts");
240 assert_eq!(posts.table, "posts");
241 assert_eq!(posts.fields.len(), 3);
242 // Field order preserved from the schema.
243 assert_eq!(posts.fields[0].name, "id");
244 assert_eq!(posts.fields[1].name, "title");
245 assert_eq!(posts.fields[2].name, "odd");
246 // Unknown type fell back to String without panicking.
247 assert!(matches!(posts.fields[2].ty, FieldType::String));
248 // Nullability / editability survived the projection.
249 assert!(!posts.fields[0].nullable);
250 assert!(posts.fields[2].nullable);
251 assert!(!posts.fields[0].editable);
252 assert!(posts.fields[1].editable);
253 }
254
255 #[test]
256 fn entry_from_admin_round_trips_compile_time_shape() {
257 // A synthetic compile-time entry; projecting it to a dynamic
258 // one must not lose or add anything.
259 let af = AdminField {
260 name: "x",
261 ty: FieldType::I32,
262 editable: true,
263 nullable: false,
264 relation: None,
265 };
266 let fields: &'static [AdminField] = Box::leak(vec![af].into_boxed_slice());
267 let ae = AdminEntry {
268 admin_name: "widgets",
269 display_name: "Widgets",
270 singular_name: "Widget",
271 table: "widgets",
272 fields,
273 core: false,
274 };
275 let de = DynamicAdminEntry::from_admin(&ae);
276 assert_eq!(de.admin_name, "widgets");
277 assert_eq!(de.display_name, "Widgets");
278 assert_eq!(de.table, "widgets");
279 assert_eq!(de.fields.len(), 1);
280 assert_eq!(de.fields[0].name, "x");
281 assert!(matches!(de.fields[0].ty, FieldType::I32));
282 assert!(de.fields[0].editable);
283 assert!(!de.fields[0].nullable);
284 assert!(!de.core);
285 }
286}