Skip to main content

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}