rustio_core/admin/from_schema.rs
1//! Phase 14, commit 5 — bridge from `ModelSchema` to admin metadata.
2//!
3//! This module is the framework's first real consumer of the
4//! Phase 14 schema contract. Given a `&ModelSchema` produced by
5//! `#[derive(RustioModel)]`, it emits the per-column admin
6//! metadata required by the existing admin UI without a hand-
7//! written `AdminModel` impl.
8//!
9//! # What stays untouched
10//!
11//! Existing manual admin paths (the `#[derive(RustioAdmin)]`
12//! macro and projects that hand-build an `AdminModel`) are not
13//! affected. This module is **additive** — it produces values
14//! that consumers can plug into the existing `AdminEntry`
15//! constructor; it never modifies, replaces, or shadows any
16//! existing admin type.
17//!
18//! # Mapping rules (Phase 14, commit 5 spec)
19//!
20//! For each `ModelColumn`:
21//!
22//! | Contract field | Bridge output |
23//! |---------------------|--------------------------------------------|
24//! | `name` | `AdminField.name` (verbatim) |
25//! | `admin_label` | `AdminField.label` (fallback = `name`) |
26//! | `admin_widget` | `BridgedField.widget` (preserved through) |
27//! | `flags.searchable` | `BridgedField.searchable` |
28//! | `flags.filterable` | `BridgedField.filterable` |
29//! | `flags.sortable` | `BridgedField.sortable` |
30//! | `flags.readonly` | `BridgedField.readonly` + `editable=!ro` |
31//! | `primary_key` | `BridgedField.primary_key` |
32//!
33//! `AdminField` (the existing type) only models `editable`. The
34//! remaining flag bits and the widget hint live on
35//! `BridgedField` — a side-channel struct so consumers (search
36//! indexer, list/sort UI, future renderer changes) can read them
37//! without breaking `AdminField`'s shape.
38//!
39//! # Static lifetimes via `Box::leak`
40//!
41//! `AdminField` requires `&'static str` and an `&'static
42//! [AdminField]` slice (the existing macro emits compile-time
43//! constants). When bridging at runtime, we promote owned data
44//! to static via `Box::leak`. This is a one-time setup cost
45//! equivalent to a `static`: schemas are registered at process
46//! startup and live for the program's lifetime, so leaked memory
47//! is never reclaimed but never grows either.
48//!
49//! # No DB, no reflection, no new deps
50//!
51//! Pure CPU. No async, no database access, no `unsafe`, no new
52//! `Cargo.toml` entries.
53
54use crate::admin::types::{AdminField, FieldType};
55use crate::contract::{ModelColumn, ModelSchema, RustType};
56
57// ---------------------------------------------------------------------------
58// FieldType mapping
59// ---------------------------------------------------------------------------
60
61/// Map a contract column's `(RustType, nullable)` pair to the
62/// admin's `FieldType` vocabulary.
63///
64/// Variants the admin layer doesn't model natively (`F64`,
65/// `Decimal`, `JsonValue`, `Uuid`) fall through to
66/// `String` / `OptionalString` — admin renders them as text
67/// inputs, which preserves their values without inventing
68/// widgets that don't exist yet. Future commits may extend
69/// `FieldType` with dedicated variants; until then text input
70/// is the safe minimum.
71pub fn field_type_for(col: &ModelColumn) -> FieldType {
72 // Match exhaustively — `RustType` is `#[non_exhaustive]` only
73 // cross-crate, but inside `rustio-core` it's exhaustive. Keeping
74 // the match tight means adding a future variant fails compilation
75 // here until the bridge gets an explicit mapping; a wildcard
76 // would silently fall back to text input and mask the gap.
77 use RustType::*;
78 match col.rust_type {
79 // The admin's `FieldType` has no `OptionalI32` variant; a
80 // nullable `i32` column collapses to `OptionalI64` since
81 // both render as the same numeric input. Non-nullable
82 // `i32` keeps its dedicated variant.
83 I32 if col.nullable => FieldType::OptionalI64,
84 I32 => FieldType::I32,
85 I64 if col.nullable => FieldType::OptionalI64,
86 I64 => FieldType::I64,
87 // `Bool` has no nullable variant in the admin layer; a
88 // tri-state checkbox isn't part of the existing UI, so
89 // nullable bools render as the same checkbox (NULL is
90 // treated as `false` at form-submission time).
91 Bool => FieldType::Bool,
92 String if col.nullable => FieldType::OptionalString,
93 String => FieldType::String,
94 DateTimeUtc if col.nullable => FieldType::OptionalDateTime,
95 DateTimeUtc => FieldType::DateTime,
96 // Variants the admin layer doesn't model natively — `F64`,
97 // `Decimal`, `JsonValue`, `Uuid` — collapse to text inputs
98 // (`String` / `OptionalString`). Documented behaviour;
99 // a future commit may extend `FieldType` with dedicated
100 // variants and tighten these.
101 F64 | Decimal | JsonValue | Uuid if col.nullable => FieldType::OptionalString,
102 F64 | Decimal | JsonValue | Uuid => FieldType::String,
103 }
104}
105
106// ---------------------------------------------------------------------------
107// Label resolution
108// ---------------------------------------------------------------------------
109
110/// Resolved admin label.
111///
112/// Phase 15 / commit 9 polish: when no explicit
113/// `#[rustio(label = "...")]` is set, derive a friendly default
114/// rather than echoing the raw column name:
115///
116/// 1. Strip a trailing `_id` foreign-key suffix when the
117/// remainder is non-empty (`client_id` → `client` →
118/// `Client`). The bare `id` PK column is left intact.
119/// 2. Translate snake_case to Title Case
120/// (`full_name` → `Full Name`).
121///
122/// Explicit overrides win — projects that want lowercase or
123/// punctuated labels keep them by setting
124/// `#[rustio(label = "...")]`.
125///
126/// The derived string is `Box::leak`'d so the result stays
127/// `&'static str` for `AdminField.label`. One-time setup cost
128/// equivalent to a `static`.
129pub fn label_for(col: &ModelColumn) -> &'static str {
130 if let Some(explicit) = col.admin_label {
131 return explicit;
132 }
133 let stem = strip_id_suffix(col.name);
134 let humanised = humanise_label(stem);
135 Box::leak(humanised.into_boxed_str())
136}
137
138/// Strip a trailing `_id` suffix only when the remainder is
139/// non-empty. The bare `id` PK column round-trips unchanged.
140fn strip_id_suffix(name: &str) -> &str {
141 name.strip_suffix("_id")
142 .filter(|s| !s.is_empty())
143 .unwrap_or(name)
144}
145
146/// snake_case → Title Case (every word capitalised). Mirrors
147/// `humanise_table` but kept separate so the column-label and
148/// table-name code paths can evolve independently.
149fn humanise_label(name: &str) -> std::string::String {
150 let mut out = std::string::String::with_capacity(name.len());
151 let mut next_upper = true;
152 for ch in name.chars() {
153 if ch == '_' {
154 out.push(' ');
155 next_upper = true;
156 } else if next_upper {
157 out.extend(ch.to_uppercase());
158 next_upper = false;
159 } else {
160 out.push(ch);
161 }
162 }
163 out
164}
165
166// ---------------------------------------------------------------------------
167// BridgedField
168// ---------------------------------------------------------------------------
169
170/// One column in its bridge form: the existing `AdminField`
171/// (consumed verbatim by the admin UI) plus the column-level
172/// flags `AdminField` doesn't model.
173///
174/// Consumers:
175/// - The admin renderer plucks `.field` out for `AdminEntry`.
176/// - A search-index sync layer (commit 6) reads `.searchable`.
177/// - Future filter/sort UI reads `.filterable` / `.sortable`.
178/// - The `.primary_key` bit identifies the row-id column for
179/// any code that needs it without re-scanning the schema.
180#[derive(Debug, Clone)]
181pub struct BridgedField {
182 /// The existing-shape admin field. Plug this directly into
183 /// `AdminEntry.fields` (after `Box::leak`-ing the slice).
184 pub field: AdminField,
185 /// `true` when the source column has `primary_key = true`.
186 /// Mirrors `ModelColumn.primary_key`.
187 pub primary_key: bool,
188 /// `flags.searchable` from the source column.
189 pub searchable: bool,
190 /// `flags.filterable` from the source column.
191 pub filterable: bool,
192 /// `flags.sortable` from the source column.
193 pub sortable: bool,
194 /// `flags.readonly` from the source column. Also drives
195 /// `field.editable = !readonly`.
196 pub readonly: bool,
197 /// `admin_widget` from the source column, preserved
198 /// verbatim. `AdminField` doesn't carry a widget override
199 /// today; the existing renderer derives the widget from
200 /// `FieldType.widget()`. Holding the override here lets
201 /// future renderer code consult it without altering
202 /// `AdminField`'s shape.
203 pub widget: Option<&'static str>,
204}
205
206impl BridgedField {
207 /// Phase 15 / commit 9 polish — effective form widget.
208 ///
209 /// Resolves to the first available answer:
210 /// 1. Explicit `admin_widget` from the source column.
211 /// 2. Name-based inference: `email` / `*_email` →
212 /// `"email"`; `phone` / `tel` / `*_phone` / `*_tel` →
213 /// `"tel"`; `url` / `*_url` / `*_uri` → `"url"`;
214 /// `password` / `passwd` / `*_password` →
215 /// `"password"`.
216 /// 3. `None` — the renderer falls back to
217 /// `FieldType.widget()` (the existing default).
218 ///
219 /// Inference is intentionally conservative: only column
220 /// names that are *unambiguous* matches return a hint,
221 /// to avoid e.g. a column called `description` getting
222 /// "url" because someone embedded a URL in their schema
223 /// docs.
224 pub fn effective_widget(&self) -> Option<&'static str> {
225 if let Some(explicit) = self.widget {
226 return Some(explicit);
227 }
228 let name = self.field.name;
229 if name == "email" || name.ends_with("_email") {
230 return Some("email");
231 }
232 if name == "phone"
233 || name == "tel"
234 || name.ends_with("_phone")
235 || name.ends_with("_tel")
236 {
237 return Some("tel");
238 }
239 if name == "url" || name.ends_with("_url") || name.ends_with("_uri") {
240 return Some("url");
241 }
242 if name == "password" || name == "passwd" || name.ends_with("_password") {
243 return Some("password");
244 }
245 None
246 }
247}
248
249// ---------------------------------------------------------------------------
250// Public bridge API
251// ---------------------------------------------------------------------------
252
253/// Bridge every column in declaration order. Order is
254/// preserved 1:1 with `schema.columns` — the admin UI lists
255/// columns in the order the model declared them, and skipping
256/// or reordering would silently change rendered forms.
257pub fn bridged_fields_from_schema(schema: &ModelSchema) -> Vec<BridgedField> {
258 schema
259 .columns
260 .iter()
261 .map(|col| BridgedField {
262 field: AdminField {
263 name: col.name,
264 label: label_for(col),
265 field_type: field_type_for(col),
266 editable: !col.flags.readonly,
267 relation: None,
268 choices: None,
269 },
270 primary_key: col.primary_key,
271 searchable: col.flags.searchable,
272 filterable: col.flags.filterable,
273 sortable: col.flags.sortable,
274 readonly: col.flags.readonly,
275 widget: col.admin_widget,
276 })
277 .collect()
278}
279
280/// Static-leaked `&'static [AdminField]` for direct use as
281/// `AdminEntry.fields`. Equivalent to a `static` array — the
282/// memory is allocated once and lives the program's lifetime.
283pub fn admin_fields_from_schema(schema: &ModelSchema) -> &'static [AdminField] {
284 let fields: Vec<AdminField> = bridged_fields_from_schema(schema)
285 .into_iter()
286 .map(|b| b.field)
287 .collect();
288 Box::leak(fields.into_boxed_slice())
289}
290
291/// The schema's primary-key column, located by the
292/// `primary_key = true` flag. Returns `None` when no column
293/// is flagged (a malformed schema; the validator in commit 3
294/// surfaces this as `WrongPrimaryKey`).
295pub fn primary_key_column(schema: &ModelSchema) -> Option<&ModelColumn> {
296 schema.columns.iter().find(|c| c.primary_key)
297}
298
299// ---------------------------------------------------------------------------
300// SchemaOps — Phase 14, commit 8
301// ---------------------------------------------------------------------------
302//
303// `SchemaOps` is a generic `AdminOps` implementation that drives
304// CRUD using only a `ModelSchema` — no `AdminModel` impl, no
305// `Model` impl, no per-type code. The admin runtime registers
306// schema-driven entries via `Admin::from_schema::<T>()`, and the
307// resulting `AdminEntry` ferries CRUD through this type.
308//
309// SQL is built dynamically from the schema's column list. Type
310// dispatch is via `ModelColumn::rust_type` — every supported
311// `RustType` variant maps to one read path
312// (`format_pg_value_for_column`) and one write path
313// (`bind_form_value`). Variants the framework doesn't yet model
314// natively for the admin path return a clear validation error
315// rather than silently coercing to text.
316//
317// Constraint envelope:
318// - No new dependencies (sqlx, chrono, uuid, serde_json are
319// already pulled in via rustio-core's Cargo.toml).
320// - Read-only against schema metadata; write paths only modify
321// rows in the model's own table.
322// - The SQL strings are built from `ModelSchema`'s `&'static
323// str` column / table names — there is no path for a request
324// to inject arbitrary identifiers (the column name list is
325// defined at compile time by `#[derive(RustioModel)]`).
326
327use std::future::Future;
328use std::pin::Pin;
329use std::sync::Arc;
330
331use chrono::{DateTime, Utc};
332use sqlx::Row as SqlxRow;
333
334use crate::admin::types::{AdminEntry, AdminOps, EditRow, ListRow};
335use crate::contract::HasSchema;
336use crate::error::{Error, Result};
337use crate::http::FormData;
338use crate::orm::Db;
339
340/// Static-leaked `ModelSchema`. Required because `AdminEntry`
341/// stores `&'static str` for table / admin_name / etc., and the
342/// schema needs to outlive every async future spawned from
343/// `SchemaOps`. Schemas are registered at startup and live for
344/// the program's lifetime, so the leak is a one-time setup cost
345/// equivalent to a `static`.
346fn leak_schema(schema: ModelSchema) -> &'static ModelSchema {
347 Box::leak(Box::new(schema))
348}
349
350/// `AdminOps` driven entirely by a `ModelSchema`.
351///
352/// Holds a static-leaked schema so each async `AdminOps` method
353/// can borrow the column list across `await` points without
354/// lifetime issues — `'a` references the captured `&'a self`,
355/// but the underlying schema reference is `'static`.
356pub(crate) struct SchemaOps {
357 schema: &'static ModelSchema,
358}
359
360impl SchemaOps {
361 fn new(schema: &'static ModelSchema) -> Self {
362 Self { schema }
363 }
364
365 fn pk_col(&self) -> &'static crate::contract::ModelColumn {
366 // The schema's `primary_key` field names the PK column;
367 // primary_key_column finds the entry flagged
368 // `primary_key = true`. We trust both agree (the
369 // validator surfaces drift) and prefer the latter.
370 primary_key_column(self.schema).unwrap_or_else(|| {
371 // Defensive: a schema without any flagged PK column
372 // is a contract bug. Returning the first column
373 // makes the code defensible without panicking; the
374 // validator's `WrongPrimaryKey` issue catches it
375 // separately.
376 &self.schema.columns[0]
377 })
378 }
379
380 /// Columns the create/update path writes. Excludes the
381 /// primary key (assumed BIGSERIAL — auto-assigned by PG)
382 /// and any column flagged `readonly` (e.g. `created_at
383 /// DEFAULT NOW()`). Returns the column references in
384 /// declaration order.
385 fn writable_columns(&self) -> Vec<&'static crate::contract::ModelColumn> {
386 self.schema
387 .columns
388 .iter()
389 .filter(|c| !c.primary_key && !c.flags.readonly)
390 .collect()
391 }
392}
393
394// ---------------------------------------------------------------------------
395// Per-RustType read formatting — turns a sqlx row column into a
396// String suitable for `ListRow.cells` / `EditRow.values`.
397// ---------------------------------------------------------------------------
398
399fn format_pg_value_for_column(
400 row: &sqlx::postgres::PgRow,
401 col: &crate::contract::ModelColumn,
402) -> String {
403 // Centralised null handling: any column read that errors out
404 // OR returns NULL maps to the empty string. The render layer
405 // displays empty strings as "—" already.
406 use crate::contract::RustType::*;
407 match (col.rust_type, col.nullable) {
408 (I32, false) => row.try_get::<i32, _>(col.name).map(|v| v.to_string()).unwrap_or_default(),
409 (I32, true) => row
410 .try_get::<Option<i32>, _>(col.name)
411 .ok()
412 .flatten()
413 .map(|v| v.to_string())
414 .unwrap_or_default(),
415 (I64, false) => row.try_get::<i64, _>(col.name).map(|v| v.to_string()).unwrap_or_default(),
416 (I64, true) => row
417 .try_get::<Option<i64>, _>(col.name)
418 .ok()
419 .flatten()
420 .map(|v| v.to_string())
421 .unwrap_or_default(),
422 (Bool, false) => row.try_get::<bool, _>(col.name).map(|b| b.to_string()).unwrap_or_default(),
423 (Bool, true) => row
424 .try_get::<Option<bool>, _>(col.name)
425 .ok()
426 .flatten()
427 .map(|b| b.to_string())
428 .unwrap_or_default(),
429 (String, false) => row.try_get::<std::string::String, _>(col.name).unwrap_or_default(),
430 (String, true) => row
431 .try_get::<Option<std::string::String>, _>(col.name)
432 .ok()
433 .flatten()
434 .unwrap_or_default(),
435 (DateTimeUtc, false) => row
436 .try_get::<DateTime<Utc>, _>(col.name)
437 .map(|d| d.to_rfc3339())
438 .unwrap_or_default(),
439 (DateTimeUtc, true) => row
440 .try_get::<Option<DateTime<Utc>>, _>(col.name)
441 .ok()
442 .flatten()
443 .map(|d| d.to_rfc3339())
444 .unwrap_or_default(),
445 (F64, false) => row.try_get::<f64, _>(col.name).map(|v| v.to_string()).unwrap_or_default(),
446 (F64, true) => row
447 .try_get::<Option<f64>, _>(col.name)
448 .ok()
449 .flatten()
450 .map(|v| v.to_string())
451 .unwrap_or_default(),
452 (Uuid, false) => row
453 .try_get::<uuid::Uuid, _>(col.name)
454 .map(|u| u.to_string())
455 .unwrap_or_default(),
456 (Uuid, true) => row
457 .try_get::<Option<uuid::Uuid>, _>(col.name)
458 .ok()
459 .flatten()
460 .map(|u| u.to_string())
461 .unwrap_or_default(),
462 // Decimal and JsonValue: render the raw text the DB
463 // returns rather than parsing into a typed value the
464 // admin layer can't carry without an extra dep. PG
465 // exposes both as text-coercible — `::text` cast in the
466 // query would be cleaner, but reading as String works
467 // for the common shapes.
468 (Decimal, _) | (JsonValue, _) => row
469 .try_get::<std::string::String, _>(col.name)
470 .unwrap_or_default(),
471 }
472}
473
474// ---------------------------------------------------------------------------
475// Per-RustType write parsing — turns a form value string into a
476// SQL bind argument; emits a clear validation error on parse
477// failure rather than panicking.
478// ---------------------------------------------------------------------------
479
480/// Bind one form value onto a `sqlx::query` builder, dispatched
481/// by `RustType` + nullability. Returns the updated builder on
482/// success, or a string error suitable for the `Err(Vec<String>)`
483/// validation channel of `AdminOps::create` / `update`.
484///
485/// Empty form input on a nullable column binds `NULL`. Empty
486/// form input on a non-nullable column binds the empty string
487/// (for `String`) or returns a "required" error (for typed
488/// columns).
489fn bind_form_value<'a>(
490 q: sqlx::query::Query<'a, sqlx::Postgres, sqlx::postgres::PgArguments>,
491 col: &crate::contract::ModelColumn,
492 raw: Option<&str>,
493) -> std::result::Result<sqlx::query::Query<'a, sqlx::Postgres, sqlx::postgres::PgArguments>, std::string::String> {
494 use crate::contract::RustType::*;
495 let raw = raw.unwrap_or("").trim();
496
497 // Empty input + nullable column = NULL. Empty input +
498 // String column = empty string (the DB constraint catches
499 // NOT NULL TEXT fields when they should have content).
500 if raw.is_empty() && col.nullable {
501 return Ok(match col.rust_type {
502 I32 => q.bind(None::<i32>),
503 I64 => q.bind(None::<i64>),
504 F64 => q.bind(None::<f64>),
505 Bool => q.bind(None::<bool>),
506 String => q.bind(None::<std::string::String>),
507 DateTimeUtc => q.bind(None::<DateTime<Utc>>),
508 Uuid => q.bind(None::<uuid::Uuid>),
509 // Decimal / JsonValue null-binding goes through the
510 // text path; PG accepts NULL casts at the protocol
511 // level for any column.
512 Decimal | JsonValue => q.bind(None::<std::string::String>),
513 });
514 }
515
516 let parsed: std::result::Result<sqlx::query::Query<'a, sqlx::Postgres, sqlx::postgres::PgArguments>, std::string::String> = match col.rust_type {
517 I32 => raw
518 .parse::<i32>()
519 .map(|v| q.bind(v))
520 .map_err(|e| format!("`{}`: {}", col.name, e)),
521 I64 => raw
522 .parse::<i64>()
523 .map(|v| q.bind(v))
524 .map_err(|e| format!("`{}`: {}", col.name, e)),
525 F64 => raw
526 .parse::<f64>()
527 .map(|v| q.bind(v))
528 .map_err(|e| format!("`{}`: {}", col.name, e)),
529 Bool => Ok({
530 // HTML form checkboxes send "on" / "true" / "1" when
531 // checked, nothing when unchecked. The form layer
532 // normalises absent fields to None — by the time we
533 // see a string here it's almost always "on" for
534 // truthy. Unknown tokens default to false rather
535 // than rejecting outright; the column's `NOT NULL
536 // DEFAULT FALSE` semantics match.
537 let truthy = matches!(
538 raw.to_ascii_lowercase().as_str(),
539 "on" | "true" | "1" | "yes"
540 );
541 q.bind(truthy)
542 }),
543 String => Ok(q.bind(raw.to_string())),
544 DateTimeUtc => DateTime::parse_from_rfc3339(raw)
545 .map(|dt| q.bind(dt.with_timezone(&Utc)))
546 .map_err(|e| format!("`{}`: expected RFC3339 timestamp ({})", col.name, e)),
547 Uuid => uuid::Uuid::parse_str(raw)
548 .map(|u| q.bind(u))
549 .map_err(|e| format!("`{}`: {}", col.name, e)),
550 Decimal | JsonValue => Ok(q.bind(raw.to_string())),
551 };
552
553 parsed
554}
555
556// ---------------------------------------------------------------------------
557// AdminOps — the read + write surface
558// ---------------------------------------------------------------------------
559
560type CreateFut<'a> = Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<std::string::String>>>> + Send + 'a>>;
561type UpdateFut<'a> = Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<std::string::String>>>> + Send + 'a>>;
562
563impl AdminOps for SchemaOps {
564 fn list<'a>(
565 &'a self,
566 db: &'a Db,
567 ) -> Pin<Box<dyn Future<Output = Result<Vec<ListRow>>> + Send + 'a>> {
568 Box::pin(async move {
569 let pk = self.pk_col();
570 let cols = self
571 .schema
572 .columns
573 .iter()
574 .map(|c| c.name)
575 .collect::<Vec<_>>()
576 .join(", ");
577 let sql = format!(
578 "SELECT {cols} FROM {} ORDER BY {} DESC LIMIT 200",
579 self.schema.table, pk.name
580 );
581 let rows = sqlx::query(&sql)
582 .fetch_all(db.pool())
583 .await
584 .map_err(|e| Error::Internal(format!("schema-list({}): {e}", self.schema.table)))?;
585
586 let out = rows
587 .into_iter()
588 .map(|row| {
589 // ID column comes back as i64 (BIGSERIAL); fall
590 // back to 0 if the column is shaped differently.
591 let id = row.try_get::<i64, _>(pk.name).unwrap_or(0);
592 let cells = self
593 .schema
594 .columns
595 .iter()
596 .map(|c| format_pg_value_for_column(&row, c))
597 .collect();
598 ListRow { id, cells }
599 })
600 .collect();
601 Ok(out)
602 })
603 }
604
605 fn find_row<'a>(
606 &'a self,
607 db: &'a Db,
608 id: i64,
609 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
610 Box::pin(async move {
611 let pk = self.pk_col();
612 let cols = self
613 .schema
614 .columns
615 .iter()
616 .map(|c| c.name)
617 .collect::<Vec<_>>()
618 .join(", ");
619 let sql = format!(
620 "SELECT {cols} FROM {} WHERE {} = $1",
621 self.schema.table, pk.name
622 );
623 let maybe_row = sqlx::query(&sql)
624 .bind(id)
625 .fetch_optional(db.pool())
626 .await
627 .map_err(|e| Error::Internal(format!("schema-find({}): {e}", self.schema.table)))?;
628 Ok(maybe_row.map(|row| {
629 let values = self
630 .schema
631 .columns
632 .iter()
633 .map(|c| (c.name.to_string(), format_pg_value_for_column(&row, c)))
634 .collect();
635 EditRow { id, values }
636 }))
637 })
638 }
639
640 fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateFut<'a> {
641 Box::pin(async move {
642 let pk = self.pk_col();
643 let writables = self.writable_columns();
644 let col_names: Vec<&str> = writables.iter().map(|c| c.name).collect();
645 let placeholders: Vec<std::string::String> =
646 (1..=writables.len()).map(|i| format!("${i}")).collect();
647 let sql = format!(
648 "INSERT INTO {} ({}) VALUES ({}) RETURNING {}",
649 self.schema.table,
650 col_names.join(", "),
651 placeholders.join(", "),
652 pk.name
653 );
654
655 let mut q = sqlx::query(&sql);
656 let mut errors: Vec<std::string::String> = Vec::new();
657 for col in &writables {
658 match bind_form_value(q, col, form.get(col.name)) {
659 Ok(next) => q = next,
660 Err(msg) => {
661 errors.push(msg);
662 // Bind a placeholder so subsequent
663 // bindings stay aligned with placeholders;
664 // the query won't run if errors is
665 // non-empty.
666 q = sqlx::query(&sql); // reset; we won't execute
667 break;
668 }
669 }
670 }
671 if !errors.is_empty() {
672 return Ok(Err(errors));
673 }
674
675 let row = q
676 .fetch_one(db.pool())
677 .await
678 .map_err(|e| Error::Internal(format!("schema-create({}): {e}", self.schema.table)))?;
679 let id: i64 = row
680 .try_get(pk.name)
681 .map_err(|e| Error::Internal(format!("returning {}: {e}", pk.name)))?;
682 db.invalidate(self.schema.table);
683 Ok(Ok(id))
684 })
685 }
686
687 fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateFut<'a> {
688 Box::pin(async move {
689 let pk = self.pk_col();
690 let writables = self.writable_columns();
691 let sets: Vec<std::string::String> = writables
692 .iter()
693 .enumerate()
694 .map(|(i, c)| format!("{} = ${}", c.name, i + 1))
695 .collect();
696 let sql = format!(
697 "UPDATE {} SET {} WHERE {} = ${}",
698 self.schema.table,
699 sets.join(", "),
700 pk.name,
701 writables.len() + 1
702 );
703
704 let mut q = sqlx::query(&sql);
705 let mut errors: Vec<std::string::String> = Vec::new();
706 for col in &writables {
707 match bind_form_value(q, col, form.get(col.name)) {
708 Ok(next) => q = next,
709 Err(msg) => {
710 errors.push(msg);
711 q = sqlx::query(&sql);
712 break;
713 }
714 }
715 }
716 if !errors.is_empty() {
717 return Ok(Err(errors));
718 }
719 q = q.bind(id);
720 q.execute(db.pool())
721 .await
722 .map_err(|e| Error::Internal(format!("schema-update({}): {e}", self.schema.table)))?;
723 db.invalidate(self.schema.table);
724 Ok(Ok(()))
725 })
726 }
727
728 fn delete<'a>(
729 &'a self,
730 db: &'a Db,
731 id: i64,
732 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
733 Box::pin(async move {
734 let pk = self.pk_col();
735 let sql = format!(
736 "DELETE FROM {} WHERE {} = $1",
737 self.schema.table, pk.name
738 );
739 sqlx::query(&sql)
740 .bind(id)
741 .execute(db.pool())
742 .await
743 .map_err(|e| Error::Internal(format!("schema-delete({}): {e}", self.schema.table)))?;
744 db.invalidate(self.schema.table);
745 Ok(())
746 })
747 }
748
749 fn object_label<'a>(
750 &'a self,
751 db: &'a Db,
752 id: i64,
753 ) -> Pin<Box<dyn Future<Output = Result<Option<std::string::String>>> + Send + 'a>> {
754 Box::pin(async move {
755 // Pick the first non-PK String column as the label
756 // source; fall back to "{table} #{id}" when there's
757 // none. Mirrors the heuristic the AdminModel-driven
758 // path uses for object_label.
759 let label_col = self.schema.columns.iter().find(|c| {
760 !c.primary_key
761 && matches!(c.rust_type, crate::contract::RustType::String)
762 });
763 let pk = self.pk_col();
764 match label_col {
765 Some(col) => {
766 let sql = format!(
767 "SELECT {} FROM {} WHERE {} = $1",
768 col.name, self.schema.table, pk.name
769 );
770 let row = sqlx::query(&sql)
771 .bind(id)
772 .fetch_optional(db.pool())
773 .await
774 .map_err(|e| {
775 Error::Internal(format!(
776 "schema-object-label({}): {e}",
777 self.schema.table
778 ))
779 })?;
780 Ok(row.and_then(|r| {
781 let v = if col.nullable {
782 r.try_get::<Option<std::string::String>, _>(col.name)
783 .ok()
784 .flatten()
785 } else {
786 r.try_get::<std::string::String, _>(col.name).ok()
787 };
788 v.filter(|s| !s.is_empty())
789 }))
790 }
791 None => Ok(Some(format!("{} #{}", self.schema.table, id))),
792 }
793 })
794 }
795}
796
797// ---------------------------------------------------------------------------
798// AdminEntry construction from a ModelSchema
799// ---------------------------------------------------------------------------
800
801/// Build a fully-configured `AdminEntry` from a `ModelSchema`,
802/// without requiring an `AdminModel` impl. The resulting entry's
803/// CRUD goes through `SchemaOps`; the field metadata comes from
804/// `admin_fields_from_schema`.
805///
806/// `admin_name`, `display_name`, and `singular_name` are derived
807/// from `schema.table`:
808///
809/// - `admin_name` = `schema.table` verbatim (route prefix)
810/// - `display_name` = humanised + Title Case (`"projects"` →
811/// `"Projects"`)
812/// - `singular_name` = humanised + naive singular (strip a
813/// trailing `s`; `"projects"` → `"Project"`)
814///
815/// Naive singularisation is fine for the common-case English
816/// plural; project models with irregular plurals can extend the
817/// macro layer with a `#[rustio(singular = "...")]` attribute in
818/// a future commit.
819pub fn admin_entry_from_schema(schema: ModelSchema) -> AdminEntry {
820 let static_schema = leak_schema(schema);
821 let admin_name: &'static str = static_schema.table;
822 let display_name: &'static str =
823 Box::leak(humanise_table(static_schema.table).into_boxed_str());
824 let singular_name: &'static str =
825 Box::leak(singularise(static_schema.table).into_boxed_str());
826
827 AdminEntry {
828 admin_name,
829 display_name,
830 singular_name,
831 table: static_schema.table,
832 fields: admin_fields_from_schema(static_schema),
833 core: false,
834 ops: Arc::new(SchemaOps::new(static_schema)),
835 search_hook: None,
836 }
837}
838
839/// Same as `admin_entry_from_schema` but takes the model type
840/// rather than a schema value. Convenience wrapper around
841/// `T::SCHEMA`.
842pub fn admin_entry_from_type<T: HasSchema>() -> AdminEntry {
843 admin_entry_from_schema(T::SCHEMA)
844}
845
846/// `"projects"` → `"Projects"`. ASCII Title Case of the first
847/// character; rest unchanged. Underscores → spaces.
848fn humanise_table(name: &str) -> std::string::String {
849 let mut out = std::string::String::with_capacity(name.len());
850 let mut next_upper = true;
851 for ch in name.chars() {
852 if ch == '_' {
853 out.push(' ');
854 next_upper = true;
855 } else if next_upper {
856 out.extend(ch.to_uppercase());
857 next_upper = false;
858 } else {
859 out.push(ch);
860 }
861 }
862 out
863}
864
865/// `"projects"` → `"Project"`. Phase 15 / commit 9 — handles
866/// the common English-plural endings:
867///
868/// - `-ies` → `-y` (`companies` → `Company`)
869/// - `-s` → strip (`projects` → `Project`)
870///
871/// Irregular plurals (people, children, indices) round-trip
872/// wrongly; words that aren't actually plurals but happen to
873/// end in `s` (status, virus, news) come out shortened. A
874/// future `#[rustio(singular = "...")]` attribute is the
875/// planned override path; until then projects with awkward
876/// names should set the attribute manually.
877fn singularise(name: &str) -> std::string::String {
878 let h = humanise_table(name);
879 if let Some(stripped) = h.strip_suffix("ies") {
880 if !stripped.is_empty() {
881 return format!("{stripped}y");
882 }
883 }
884 if let Some(stripped) = h.strip_suffix('s') {
885 if !stripped.is_empty() {
886 return stripped.to_string();
887 }
888 }
889 h
890}
891
892// ---------------------------------------------------------------------------
893// Tests
894// ---------------------------------------------------------------------------
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use crate::contract::SchemaFlags;
900
901 // ----- Test fixture -----------------------------------------------------
902
903 /// A schema covering every mapping rule the bridge must
904 /// honour: primary key, label override, widget override,
905 /// every flag bit, every commonly-used `RustType`, both
906 /// nullable and non-nullable. One static fixture so the
907 /// individual tests don't drift from each other.
908 fn fixture_schema() -> ModelSchema {
909 static COLS: &[ModelColumn] = &[
910 // Primary key, readonly (auto-managed).
911 ModelColumn {
912 name: "id",
913 sql_decl: "BIGSERIAL PRIMARY KEY",
914 rust_type: RustType::I64,
915 nullable: false,
916 primary_key: true,
917 flags: SchemaFlags {
918 searchable: false,
919 filterable: false,
920 sortable: true,
921 readonly: true,
922 },
923 admin_label: None,
924 admin_widget: None,
925 },
926 // Searchable + filterable + explicit label.
927 ModelColumn {
928 name: "title",
929 sql_decl: "TEXT NOT NULL",
930 rust_type: RustType::String,
931 nullable: false,
932 primary_key: false,
933 flags: SchemaFlags {
934 searchable: true,
935 filterable: true,
936 sortable: false,
937 readonly: false,
938 },
939 admin_label: Some("Headline"),
940 admin_widget: None,
941 },
942 // Nullable string + widget override.
943 ModelColumn {
944 name: "body",
945 sql_decl: "TEXT",
946 rust_type: RustType::String,
947 nullable: true,
948 primary_key: false,
949 flags: SchemaFlags {
950 searchable: true,
951 filterable: false,
952 sortable: false,
953 readonly: false,
954 },
955 admin_label: None,
956 admin_widget: Some("textarea"),
957 },
958 // Nullable timestamp + sortable.
959 ModelColumn {
960 name: "published_at",
961 sql_decl: "TIMESTAMPTZ",
962 rust_type: RustType::DateTimeUtc,
963 nullable: true,
964 primary_key: false,
965 flags: SchemaFlags {
966 searchable: false,
967 filterable: true,
968 sortable: true,
969 readonly: false,
970 },
971 admin_label: None,
972 admin_widget: None,
973 },
974 // Bool, no flags set.
975 ModelColumn {
976 name: "is_pinned",
977 sql_decl: "BOOLEAN NOT NULL",
978 rust_type: RustType::Bool,
979 nullable: false,
980 primary_key: false,
981 flags: SchemaFlags::empty(),
982 admin_label: None,
983 admin_widget: None,
984 },
985 ];
986 ModelSchema {
987 table: "posts",
988 columns: COLS,
989 primary_key: "id",
990 search_index: Some("posts"),
991 }
992 }
993
994 // ----- Required by spec -------------------------------------------------
995
996 /// Spec gate: "fields generated from schema". One
997 /// `BridgedField` per column, none dropped, none added.
998 #[test]
999 fn fields_generated_from_schema_one_per_column() {
1000 let schema = fixture_schema();
1001 let bridged = bridged_fields_from_schema(&schema);
1002 assert_eq!(
1003 bridged.len(),
1004 schema.columns.len(),
1005 "every ModelColumn must produce exactly one BridgedField"
1006 );
1007 }
1008
1009 /// Spec gate: "ordering preserved". Bridge output order
1010 /// matches the schema's declaration order column-for-column.
1011 /// Reordering would silently re-arrange admin forms.
1012 #[test]
1013 fn ordering_preserved_matches_schema_columns() {
1014 let schema = fixture_schema();
1015 let bridged = bridged_fields_from_schema(&schema);
1016 let bridged_names: Vec<&str> = bridged.iter().map(|b| b.field.name).collect();
1017 let schema_names: Vec<&str> = schema.columns.iter().map(|c| c.name).collect();
1018 assert_eq!(
1019 bridged_names, schema_names,
1020 "BridgedField order must mirror ModelSchema.columns order"
1021 );
1022 }
1023
1024 /// Spec gate: "flags correctly mapped". Every flag bit
1025 /// flows through to the matching `BridgedField` field.
1026 #[test]
1027 fn flags_correctly_mapped_per_column() {
1028 let schema = fixture_schema();
1029 let bridged = bridged_fields_from_schema(&schema);
1030
1031 // `id`: sortable + readonly only.
1032 let id = &bridged[0];
1033 assert!(!id.searchable);
1034 assert!(!id.filterable);
1035 assert!(id.sortable);
1036 assert!(id.readonly);
1037 assert!(!id.field.editable, "readonly => editable=false");
1038
1039 // `title`: searchable + filterable.
1040 let title = &bridged[1];
1041 assert!(title.searchable);
1042 assert!(title.filterable);
1043 assert!(!title.sortable);
1044 assert!(!title.readonly);
1045 assert!(title.field.editable);
1046
1047 // `body`: searchable only.
1048 let body = &bridged[2];
1049 assert!(body.searchable);
1050 assert!(!body.filterable);
1051 assert!(!body.sortable);
1052 assert!(!body.readonly);
1053
1054 // `published_at`: filterable + sortable.
1055 let pa = &bridged[3];
1056 assert!(!pa.searchable);
1057 assert!(pa.filterable);
1058 assert!(pa.sortable);
1059
1060 // `is_pinned`: all flags off.
1061 let pin = &bridged[4];
1062 assert!(!pin.searchable);
1063 assert!(!pin.filterable);
1064 assert!(!pin.sortable);
1065 assert!(!pin.readonly);
1066 }
1067
1068 /// Spec gate: "label fallback works". When `admin_label`
1069 /// is set, the bridge uses it verbatim; when `None`,
1070 /// Phase 15 / commit 9 derives a friendly default
1071 /// (humanise + strip `_id` suffix).
1072 #[test]
1073 fn label_fallback_humanises_column_name_when_no_override() {
1074 let schema = fixture_schema();
1075 let bridged = bridged_fields_from_schema(&schema);
1076
1077 // Override case: `title` had `admin_label = Some("Headline")`.
1078 assert_eq!(bridged[1].field.label, "Headline");
1079
1080 // Fallback case (commit 9 polish): humanised Title Case.
1081 assert_eq!(bridged[0].field.label, "Id");
1082 assert_eq!(bridged[2].field.label, "Body");
1083 assert_eq!(bridged[3].field.label, "Published At");
1084 assert_eq!(bridged[4].field.label, "Is Pinned");
1085 }
1086
1087 /// Phase 15 / commit 9 — `_id` foreign-key suffix is
1088 /// stripped during label derivation so `client_id` reads
1089 /// as "Client" in the admin UI rather than "Client Id".
1090 /// The bare `id` PK column round-trips unchanged
1091 /// (`"id"` → `"Id"`).
1092 #[test]
1093 fn label_fallback_strips_id_suffix_for_foreign_keys() {
1094 static COLS: &[ModelColumn] = &[
1095 ModelColumn {
1096 name: "id",
1097 sql_decl: "BIGSERIAL PRIMARY KEY",
1098 rust_type: RustType::I64,
1099 nullable: false,
1100 primary_key: true,
1101 flags: SchemaFlags::empty(),
1102 admin_label: None,
1103 admin_widget: None,
1104 },
1105 ModelColumn {
1106 name: "client_id",
1107 sql_decl: "BIGINT NOT NULL",
1108 rust_type: RustType::I64,
1109 nullable: false,
1110 primary_key: false,
1111 flags: SchemaFlags::empty(),
1112 admin_label: None,
1113 admin_widget: None,
1114 },
1115 ModelColumn {
1116 name: "primary_address_id",
1117 sql_decl: "BIGINT",
1118 rust_type: RustType::I64,
1119 nullable: true,
1120 primary_key: false,
1121 flags: SchemaFlags::empty(),
1122 admin_label: None,
1123 admin_widget: None,
1124 },
1125 ];
1126 let schema = ModelSchema {
1127 table: "scratch",
1128 columns: COLS,
1129 primary_key: "id",
1130 search_index: None,
1131 };
1132 let bridged = bridged_fields_from_schema(&schema);
1133 assert_eq!(bridged[0].field.label, "Id"); // bare id intact
1134 assert_eq!(bridged[1].field.label, "Client"); // _id stripped
1135 assert_eq!(bridged[2].field.label, "Primary Address"); // _id stripped + humanised
1136 }
1137
1138 /// Spec gate: "widget override works". `admin_widget`
1139 /// from the source column is preserved on `BridgedField.widget`
1140 /// verbatim. `None` stays `None`.
1141 #[test]
1142 fn widget_override_preserved_through_bridge() {
1143 let schema = fixture_schema();
1144 let bridged = bridged_fields_from_schema(&schema);
1145
1146 assert_eq!(bridged[2].widget, Some("textarea"), "body's textarea override must survive");
1147 assert!(bridged[0].widget.is_none(), "id had no widget override");
1148 assert!(bridged[1].widget.is_none(), "title had no widget override");
1149 assert!(bridged[3].widget.is_none(), "published_at had no widget override");
1150 assert!(bridged[4].widget.is_none(), "is_pinned had no widget override");
1151 }
1152
1153 /// Spec gate: "primary key detected". The bridge surfaces
1154 /// the same column flagged in the contract; helper picks
1155 /// it out by reference.
1156 #[test]
1157 fn primary_key_detected_from_schema() {
1158 let schema = fixture_schema();
1159 let bridged = bridged_fields_from_schema(&schema);
1160
1161 // Exactly one column flagged primary_key.
1162 let pk_count = bridged.iter().filter(|b| b.primary_key).count();
1163 assert_eq!(pk_count, 1, "fixture has exactly one primary-key column");
1164 assert!(bridged[0].primary_key, "the `id` column is the PK");
1165 assert!(!bridged[1].primary_key);
1166
1167 // Helper resolves the same column.
1168 let pk = primary_key_column(&schema).expect("PK exists in fixture");
1169 assert_eq!(pk.name, "id");
1170 }
1171
1172 /// `primary_key_column` returns `None` when no column is
1173 /// flagged. The validator in commit 3 surfaces this as a
1174 /// `WrongPrimaryKey` issue; the bridge just reports honestly.
1175 #[test]
1176 fn primary_key_column_returns_none_when_unflagged() {
1177 static COLS: &[ModelColumn] = &[ModelColumn {
1178 name: "value",
1179 sql_decl: "TEXT NOT NULL",
1180 rust_type: RustType::String,
1181 nullable: false,
1182 primary_key: false,
1183 flags: SchemaFlags::empty(),
1184 admin_label: None,
1185 admin_widget: None,
1186 }];
1187 let schema = ModelSchema {
1188 table: "scratch",
1189 columns: COLS,
1190 primary_key: "value",
1191 search_index: None,
1192 };
1193 assert!(primary_key_column(&schema).is_none());
1194 }
1195
1196 // ----- FieldType mapping -----------------------------------------------
1197
1198 /// Every `RustType` variant the admin natively models has
1199 /// the documented `(nullable, non-nullable)` pair.
1200 #[test]
1201 fn field_type_mapping_covers_native_variants() {
1202 // Helper: build a stub column for type-mapping checks.
1203 fn col(rust_type: RustType, nullable: bool) -> ModelColumn {
1204 ModelColumn {
1205 name: "f",
1206 sql_decl: "",
1207 rust_type,
1208 nullable,
1209 primary_key: false,
1210 flags: SchemaFlags::empty(),
1211 admin_label: None,
1212 admin_widget: None,
1213 }
1214 }
1215
1216 assert_eq!(field_type_for(&col(RustType::I32, false)), FieldType::I32);
1217 // No OptionalI32 variant — nullable I32 collapses into OptionalI64.
1218 assert_eq!(field_type_for(&col(RustType::I32, true)), FieldType::OptionalI64);
1219
1220 assert_eq!(field_type_for(&col(RustType::I64, false)), FieldType::I64);
1221 assert_eq!(field_type_for(&col(RustType::I64, true)), FieldType::OptionalI64);
1222
1223 assert_eq!(field_type_for(&col(RustType::Bool, false)), FieldType::Bool);
1224 assert_eq!(field_type_for(&col(RustType::Bool, true)), FieldType::Bool);
1225
1226 assert_eq!(field_type_for(&col(RustType::String, false)), FieldType::String);
1227 assert_eq!(field_type_for(&col(RustType::String, true)), FieldType::OptionalString);
1228
1229 assert_eq!(field_type_for(&col(RustType::DateTimeUtc, false)), FieldType::DateTime);
1230 assert_eq!(field_type_for(&col(RustType::DateTimeUtc, true)), FieldType::OptionalDateTime);
1231 }
1232
1233 /// Variants the admin doesn't natively model (`F64`,
1234 /// `Decimal`, `JsonValue`, `Uuid`) collapse to text inputs.
1235 /// Documented behaviour; a future commit may extend
1236 /// `FieldType` and tighten these.
1237 #[test]
1238 fn field_type_mapping_falls_back_to_string_for_unmodelled_variants() {
1239 fn col(rust_type: RustType, nullable: bool) -> ModelColumn {
1240 ModelColumn {
1241 name: "f",
1242 sql_decl: "",
1243 rust_type,
1244 nullable,
1245 primary_key: false,
1246 flags: SchemaFlags::empty(),
1247 admin_label: None,
1248 admin_widget: None,
1249 }
1250 }
1251
1252 for rt in [RustType::F64, RustType::Decimal, RustType::JsonValue, RustType::Uuid] {
1253 assert_eq!(field_type_for(&col(rt, false)), FieldType::String, "{:?} -> String", rt);
1254 assert_eq!(field_type_for(&col(rt, true)), FieldType::OptionalString, "{:?} -> OptionalString", rt);
1255 }
1256 }
1257
1258 // ----- AdminField slice helper -----------------------------------------
1259
1260 /// `admin_fields_from_schema` returns a slice the same
1261 /// length and order as `bridged_fields_from_schema`, with
1262 /// each `AdminField` matching the bridge output verbatim.
1263 #[test]
1264 fn admin_fields_slice_matches_bridge_output() {
1265 let schema = fixture_schema();
1266 let bridged = bridged_fields_from_schema(&schema);
1267 let slice = admin_fields_from_schema(&schema);
1268
1269 assert_eq!(slice.len(), bridged.len());
1270 for (i, f) in slice.iter().enumerate() {
1271 assert_eq!(f.name, bridged[i].field.name, "name @{}", i);
1272 assert_eq!(f.label, bridged[i].field.label, "label @{}", i);
1273 assert_eq!(f.field_type, bridged[i].field.field_type, "field_type @{}", i);
1274 assert_eq!(f.editable, bridged[i].field.editable, "editable @{}", i);
1275 }
1276 }
1277
1278 /// The slice satisfies the `&'static [AdminField]` shape
1279 /// `AdminEntry.fields` requires — it can be used in places
1280 /// where a `'static` lifetime is mandatory. Compile-time
1281 /// gate: this won't compile if the helper returns a non-
1282 /// static reference.
1283 #[test]
1284 fn admin_fields_slice_is_static_lifetime() {
1285 fn assert_static(_x: &'static [AdminField]) {}
1286 let schema = fixture_schema();
1287 let slice = admin_fields_from_schema(&schema);
1288 assert_static(slice);
1289 }
1290
1291 /// A column with `flags.readonly = true` produces
1292 /// `AdminField.editable = false`. The inverse
1293 /// (readonly = false → editable = true) is also covered.
1294 #[test]
1295 fn editable_is_inverse_of_readonly() {
1296 let schema = fixture_schema();
1297 let bridged = bridged_fields_from_schema(&schema);
1298 for b in &bridged {
1299 assert_eq!(
1300 b.field.editable, !b.readonly,
1301 "editable must always equal !readonly for `{}`",
1302 b.field.name
1303 );
1304 }
1305 }
1306
1307 /// Empty schema → empty bridge output. A schema with zero
1308 /// columns is malformed but the bridge shouldn't panic.
1309 #[test]
1310 fn empty_schema_produces_empty_bridge_output() {
1311 static COLS: &[ModelColumn] = &[];
1312 let schema = ModelSchema {
1313 table: "empty",
1314 columns: COLS,
1315 primary_key: "id",
1316 search_index: None,
1317 };
1318 assert_eq!(bridged_fields_from_schema(&schema).len(), 0);
1319 assert_eq!(admin_fields_from_schema(&schema).len(), 0);
1320 assert!(primary_key_column(&schema).is_none());
1321 }
1322
1323 // ----- Phase 14, commit 8 — name derivation for AdminEntry -----------
1324
1325 /// Plain plural `"projects"` humanises + singularises to
1326 /// `"Projects"` / `"Project"`.
1327 #[test]
1328 fn humanise_table_capitalises_first_letter() {
1329 assert_eq!(super::humanise_table("projects"), "Projects");
1330 assert_eq!(super::humanise_table("clients"), "Clients");
1331 assert_eq!(super::humanise_table("invoices"), "Invoices");
1332 }
1333
1334 /// Underscore tables humanise as Title Case (every
1335 /// underscore-separated word capitalised).
1336 #[test]
1337 fn humanise_table_translates_underscores_to_spaces() {
1338 assert_eq!(super::humanise_table("audit_logs"), "Audit Logs");
1339 assert_eq!(super::humanise_table("user_profiles"), "User Profiles");
1340 }
1341
1342 /// Phase 15 / commit 9 singular rules: handles `-ies` →
1343 /// `-y` and trailing `-s`. Words that aren't actually
1344 /// plural but happen to end in `s` (status, virus) round-
1345 /// trip wrongly; these need an explicit override.
1346 #[test]
1347 fn singularise_handles_common_plural_endings() {
1348 assert_eq!(super::singularise("projects"), "Project");
1349 assert_eq!(super::singularise("clients"), "Client");
1350 assert_eq!(super::singularise("invoices"), "Invoice");
1351 // -ies → -y (companies → Company).
1352 assert_eq!(super::singularise("companies"), "Company");
1353 assert_eq!(super::singularise("categories"), "Category");
1354 // Single-word non-plural ending in `s` gets shortened —
1355 // documents the known limitation.
1356 assert_eq!(super::singularise("status"), "Statu");
1357 }
1358
1359 /// Phase 15 / commit 9 — `effective_widget` resolution.
1360 #[test]
1361 fn effective_widget_returns_explicit_override_when_present() {
1362 let schema = fixture_schema();
1363 let bridged = bridged_fields_from_schema(&schema);
1364 // `body` has `admin_widget = Some("textarea")`.
1365 let body = bridged.iter().find(|b| b.field.name == "body").unwrap();
1366 assert_eq!(body.effective_widget(), Some("textarea"));
1367 }
1368
1369 /// Phase 15 / commit 9 — when no explicit widget, name-
1370 /// based inference kicks in. Conservative: only column
1371 /// names that unambiguously match a recognised pattern
1372 /// produce a hint.
1373 #[test]
1374 fn effective_widget_infers_from_recognised_column_names() {
1375 fn col(name: &'static str) -> ModelColumn {
1376 ModelColumn {
1377 name,
1378 sql_decl: "TEXT NOT NULL",
1379 rust_type: RustType::String,
1380 nullable: false,
1381 primary_key: false,
1382 flags: SchemaFlags::empty(),
1383 admin_label: None,
1384 admin_widget: None,
1385 }
1386 }
1387 let cases = [
1388 ("email", Some("email")),
1389 ("contact_email", Some("email")),
1390 ("phone", Some("tel")),
1391 ("home_phone", Some("tel")),
1392 ("tel", Some("tel")),
1393 ("url", Some("url")),
1394 ("homepage_url", Some("url")),
1395 ("api_uri", Some("url")),
1396 ("password", Some("password")),
1397 ("admin_password", Some("password")),
1398 // Negatives — ambiguous names don't infer.
1399 ("description", None),
1400 ("notes", None),
1401 ("title", None),
1402 ];
1403 for (name, expected) in cases {
1404 let bf = BridgedField {
1405 field: AdminField {
1406 name,
1407 label: name,
1408 field_type: FieldType::String,
1409 editable: true,
1410 relation: None,
1411 choices: None,
1412 },
1413 primary_key: false,
1414 searchable: false,
1415 filterable: false,
1416 sortable: false,
1417 readonly: false,
1418 widget: None,
1419 };
1420 assert_eq!(
1421 bf.effective_widget(),
1422 expected,
1423 "name-based inference for `{name}`"
1424 );
1425 let _ = col(name); // suppress unused warning if cases shrink
1426 }
1427 }
1428
1429 /// `admin_entry_from_schema` builds an entry with derived
1430 /// names and the bridge's field list. Integration test that
1431 /// exercises every commit-5 + commit-8 admin surface
1432 /// without a DB.
1433 #[test]
1434 fn admin_entry_from_schema_packages_metadata_correctly() {
1435 let schema = fixture_schema();
1436 let entry = super::admin_entry_from_schema(schema);
1437
1438 assert_eq!(entry.admin_name, "posts");
1439 assert_eq!(entry.display_name, "Posts");
1440 assert_eq!(entry.singular_name, "Post");
1441 assert_eq!(entry.table, "posts");
1442 assert!(!entry.core, "schema-derived entries are never `core`");
1443
1444 // Field list matches the bridge output column-for-column.
1445 let names: Vec<&str> = entry.fields.iter().map(|f| f.name).collect();
1446 assert_eq!(
1447 names,
1448 vec!["id", "title", "body", "published_at", "is_pinned"]
1449 );
1450
1451 // No search hook attached — search wiring is a separate
1452 // step (Indexer::from_schema in commit 8).
1453 assert!(entry.search_hook.is_none());
1454 }
1455
1456 /// `Admin::from_schemas` registers one entry per supplied
1457 /// schema and preserves the input order (the existing
1458 /// `core` user entry is pre-seeded; new entries appear after).
1459 #[test]
1460 fn admin_from_schemas_registers_each_schema_in_order() {
1461 use crate::admin::types::Admin;
1462
1463 let schemas = vec![
1464 ModelSchema {
1465 table: "alpha",
1466 columns: fixture_schema().columns,
1467 primary_key: "id",
1468 search_index: None,
1469 },
1470 ModelSchema {
1471 table: "beta",
1472 columns: fixture_schema().columns,
1473 primary_key: "id",
1474 search_index: None,
1475 },
1476 ];
1477
1478 let admin = Admin::new().from_schemas(&schemas);
1479 let entry_tables: Vec<&str> =
1480 admin.entries().iter().map(|e| e.table).collect();
1481
1482 // Core user entry at index 0; the two schema entries
1483 // follow in declaration order.
1484 assert!(entry_tables.contains(&"alpha"));
1485 assert!(entry_tables.contains(&"beta"));
1486 let alpha_pos = entry_tables.iter().position(|t| *t == "alpha").unwrap();
1487 let beta_pos = entry_tables.iter().position(|t| *t == "beta").unwrap();
1488 assert!(alpha_pos < beta_pos, "from_schemas preserves slice order");
1489 }
1490}