Skip to main content

rustio_core/admin/
schema_introspect.rs

1//! Database-driven [`AdminModelConfig`] generation.
2//!
3//! Reads a SQLite table's structure via `PRAGMA table_info(...)` and
4//! converts each column into an [`AdminUiField`]. The result is the
5//! same kind of [`AdminModelConfig`] the manual builder produces, so
6//! every downstream consumer (registry, routes, persistence,
7//! rendering) stays unaware of where the metadata came from.
8//!
9//! Lifetime note: the registry stores `&'static str` for slug, table,
10//! field names, etc. Names returned by `PRAGMA` are owned `String`s,
11//! so `generate_from_table` leaks them at registration time
12//! (`String::leak`). This is a one-shot at startup — the leaks are
13//! bounded by the number of registered models, never per-request.
14//!
15//! # Constraints
16//! - **No CREATE TABLE generation.** `ensure_table_sql` is left empty;
17//!   the caller is expected to have provisioned the table already.
18//! - **Identifier quoting.** Table names are routed through
19//!   [`quote_ident`] before being interpolated into the PRAGMA
20//!   statement. SQLite pragmas do not accept bind parameters, so
21//!   identifier-quoting is the safety boundary here.
22
23use sqlx::Row;
24
25use crate::admin::admin_form_bridge::{AdminUiField, AdminUiModel};
26use crate::admin::admin_generator::{from_config, AdminModelConfig};
27use crate::error::Error;
28use crate::orm::Db;
29
30/// A single column as reported by `PRAGMA table_info`. Just the four
31/// fields the generator actually needs — `dflt_value` and `cid` are
32/// dropped to keep the surface area minimal.
33#[derive(Debug, Clone)]
34pub struct ColumnInfo {
35    pub name: String,
36    pub data_type: String,
37    pub not_null: bool,
38    pub is_primary_key: bool,
39}
40
41/// Quote a SQL identifier the same way `persistence.rs` does. Kept
42/// local to avoid a cross-module visibility change for one helper.
43fn quote_ident(s: &str) -> String {
44    format!("\"{}\"", s.replace('"', "\"\""))
45}
46
47/// Read column metadata for `table` via `PRAGMA table_info(...)`.
48///
49/// Returns [`Error::NotFound`] when the table is unknown or has no
50/// columns (PRAGMA returns an empty result set in both cases — there
51/// is no separate "missing table" signal).
52pub async fn get_table_columns(db: &Db, table: &str) -> Result<Vec<ColumnInfo>, Error> {
53    let sql = format!("PRAGMA table_info({})", quote_ident(table));
54    let rows = sqlx::query(&sql)
55        .fetch_all(db.pool())
56        .await
57        .map_err(Error::from)?;
58
59    if rows.is_empty() {
60        return Err(Error::NotFound);
61    }
62
63    let mut out = Vec::with_capacity(rows.len());
64    for row in rows {
65        let name: String = row.try_get("name").map_err(Error::from)?;
66        let data_type: String = row.try_get("type").map_err(Error::from)?;
67        let not_null: i64 = row.try_get("notnull").map_err(Error::from)?;
68        let pk: i64 = row.try_get("pk").map_err(Error::from)?;
69        out.push(ColumnInfo {
70            name,
71            data_type,
72            not_null: not_null != 0,
73            is_primary_key: pk != 0,
74        });
75    }
76    Ok(out)
77}
78
79/// Decide the [`AdminUiField`] shape for one column. Pure function
80/// on metadata — no DB lookup. Heuristics:
81///
82/// - `is_*` / `*_flag` columns become booleans regardless of the
83///   raw SQLite type (SQLite has no native bool — INTEGER 0/1 is
84///   the convention).
85/// - Names containing `email` get [`AdminDataType::Email`].
86/// - Long-text hints (`description`, `content`) promote TEXT to
87///   `textarea`.
88/// - Unknown SQLite types fall back to text rather than erroring.
89///
90/// Defaults: every emitted field is `filterable`, `sortable`,
91/// `visible_in_table`. The caller can post-process if a column
92/// should be excluded.
93pub fn column_to_field(col: &ColumnInfo) -> AdminUiField {
94    use crate::admin::admin_form_bridge::AdminDataType;
95
96    // `name` and `label` need `&'static str`. Leak once at
97    // registration time — see module docs.
98    let name_static: &'static str = Box::leak(col.name.clone().into_boxed_str());
99    // Trivial label = identifier; renderers / future i18n can refine.
100    let label_static: &'static str = name_static;
101
102    let lname = col.name.to_lowercase();
103    let upper_type = col.data_type.to_uppercase();
104
105    let is_boolean_name = lname.starts_with("is_") || lname.ends_with("_flag");
106    let is_email = lname.contains("email");
107    let is_long_text = lname.contains("description") || lname.contains("content");
108
109    // 1. Name-based overrides win over raw SQL type — boolean and
110    //    email semantics matter more than storage class.
111    let dt = if is_boolean_name {
112        AdminDataType::Boolean
113    } else if is_email {
114        AdminDataType::Email
115    } else if upper_type.contains("INT") {
116        AdminDataType::Integer
117    } else if upper_type.contains("REAL")
118        || upper_type.contains("FLOA")
119        || upper_type.contains("DOUB")
120        || upper_type.contains("NUMERIC")
121        || upper_type.contains("DECIMAL")
122    {
123        AdminDataType::Float
124    } else if upper_type.contains("DATE") || upper_type.contains("TIME") {
125        AdminDataType::DateTime
126    } else if upper_type.contains("CHAR")
127        || upper_type.contains("TEXT")
128        || upper_type.contains("CLOB")
129    {
130        if is_long_text {
131            AdminDataType::Text
132        } else {
133            AdminDataType::String
134        }
135    } else {
136        // Fallback — treat unknown types as plain text.
137        AdminDataType::String
138    };
139
140    let mut field = match dt {
141        AdminDataType::Boolean => AdminUiField::boolean(name_static, label_static),
142        AdminDataType::Email => AdminUiField::email(name_static, label_static),
143        AdminDataType::Integer => AdminUiField::integer(name_static, label_static),
144        AdminDataType::Float => AdminUiField::float(name_static, label_static),
145        AdminDataType::DateTime => AdminUiField::datetime(name_static, label_static),
146        AdminDataType::Text => AdminUiField::textarea(name_static, label_static),
147        AdminDataType::String => AdminUiField::text(name_static, label_static),
148    };
149
150    field = field
151        .required(col.not_null)
152        .filterable(true)
153        .sortable(true)
154        .visible_in_table(true);
155    field
156}
157
158/// Strip the demo prefix and pluralisation off a table name for the
159/// URL slug. `admin_new_demo_users` → `users`. Tables that don't
160/// carry the demo prefix pass through unchanged.
161fn slug_from_table(table: &str) -> String {
162    table
163        .strip_prefix("admin_new_demo_")
164        .unwrap_or(table)
165        .to_string()
166}
167
168/// `users` → `User`, `orders` → `Order`. ASCII-only title-case +
169/// trailing-`s` strip; good enough for the demo vocabulary, and
170/// non-ASCII slugs simply get title-cased without pluralisation
171/// surgery.
172fn model_name_from_slug(slug: &str) -> String {
173    let mut base = slug.to_string();
174    if base.len() > 1 && base.ends_with('s') {
175        base.pop();
176    }
177    let mut chars = base.chars();
178    match chars.next() {
179        None => String::new(),
180        Some(c) => c.to_uppercase().chain(chars).collect(),
181    }
182}
183
184/// Build an [`AdminModelConfig`] entirely from a live table's
185/// schema. Equivalent to hand-writing the same config — the result
186/// plugs into `register_generated` exactly like a manual one.
187pub async fn generate_from_table(db: &Db, table: &str) -> Result<AdminModelConfig, Error> {
188    let columns = get_table_columns(db, table).await?;
189    if columns.is_empty() {
190        return Err(Error::NotFound);
191    }
192
193    let pk_name = columns
194        .iter()
195        .find(|c| c.is_primary_key)
196        .map(|c| c.name.clone())
197        .unwrap_or_else(|| "id".to_string());
198
199    // 1. Skip the PK from the editable field list — the form engine
200    //    treats it as a hidden routing field, not a user input.
201    let fields: Vec<AdminUiField> = columns
202        .iter()
203        .filter(|c| !c.is_primary_key)
204        .map(column_to_field)
205        .collect();
206
207    // 2. Searchable: free-text columns only. Booleans are handled by
208    //    the filter UI; integer ids are not useful for `LIKE`.
209    let searchable_fields: Vec<&'static str> = columns
210        .iter()
211        .filter(|c| !c.is_primary_key)
212        .filter(|c| {
213            let lname = c.name.to_lowercase();
214            let upper_type = c.data_type.to_uppercase();
215            let is_boolean_name = lname.starts_with("is_") || lname.ends_with("_flag");
216            !is_boolean_name
217                && (upper_type.contains("CHAR")
218                    || upper_type.contains("TEXT")
219                    || upper_type.contains("CLOB"))
220        })
221        .map(|c| {
222            let s: &'static str = Box::leak(c.name.clone().into_boxed_str());
223            s
224        })
225        .collect();
226
227    // 3. Status field: first `is_*` boolean. `_flag` columns are
228    //    booleans too but the bulk Activate/Deactivate semantics
229    //    align better with `is_*` naming, so the spec restricts
230    //    detection to that prefix.
231    let primary_status_field: Option<&'static str> = columns
232        .iter()
233        .find(|c| c.name.to_lowercase().starts_with("is_"))
234        .map(|c| {
235            let s: &'static str = Box::leak(c.name.clone().into_boxed_str());
236            s
237        });
238
239    let slug_owned = slug_from_table(table);
240    let slug_static: &'static str = Box::leak(slug_owned.clone().into_boxed_str());
241    let model_name_static: &'static str =
242        Box::leak(model_name_from_slug(&slug_owned).into_boxed_str());
243    let table_static: &'static str = Box::leak(table.to_string().into_boxed_str());
244    let pk_static: &'static str = Box::leak(pk_name.into_boxed_str());
245
246    let mut cfg = AdminModelConfig::new(slug_static, model_name_static)
247        .table(table_static)
248        .primary_key(pk_static)
249        .fields(fields)
250        .searchable(searchable_fields);
251    if let Some(s) = primary_status_field {
252        cfg = cfg.status_field(s);
253    }
254    // ensure_sql intentionally left unset — schema-driven generation
255    // assumes the table already exists.
256    Ok(cfg)
257}
258
259/// Convenience pairing: introspect + box. Lets a caller stash the
260/// result behind `Box<dyn AdminUiModel>` without naming the
261/// generator type.
262pub async fn generate_model_from_table(
263    db: &Db,
264    table: &str,
265) -> Result<Box<dyn AdminUiModel>, Error> {
266    let cfg = generate_from_table(db, table).await?;
267    Ok(from_config(cfg))
268}