Skip to main content

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}