use crate::admin::types::{AdminField, FieldType};
use crate::contract::{ModelColumn, ModelSchema, RustType};
pub fn field_type_for(col: &ModelColumn) -> FieldType {
use RustType::*;
match col.rust_type {
I32 if col.nullable => FieldType::OptionalI64,
I32 => FieldType::I32,
I64 if col.nullable => FieldType::OptionalI64,
I64 => FieldType::I64,
Bool => FieldType::Bool,
String if col.nullable => FieldType::OptionalString,
String => FieldType::String,
DateTimeUtc if col.nullable => FieldType::OptionalDateTime,
DateTimeUtc => FieldType::DateTime,
F64 | Decimal | JsonValue | Uuid if col.nullable => FieldType::OptionalString,
F64 | Decimal | JsonValue | Uuid => FieldType::String,
}
}
pub fn label_for(col: &ModelColumn) -> &'static str {
if let Some(explicit) = col.admin_label {
return explicit;
}
let stem = strip_id_suffix(col.name);
let humanised = humanise_label(stem);
Box::leak(humanised.into_boxed_str())
}
fn strip_id_suffix(name: &str) -> &str {
name.strip_suffix("_id")
.filter(|s| !s.is_empty())
.unwrap_or(name)
}
fn humanise_label(name: &str) -> std::string::String {
let mut out = std::string::String::with_capacity(name.len());
let mut next_upper = true;
for ch in name.chars() {
if ch == '_' {
out.push(' ');
next_upper = true;
} else if next_upper {
out.extend(ch.to_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
#[derive(Debug, Clone)]
pub struct BridgedField {
pub field: AdminField,
pub primary_key: bool,
pub searchable: bool,
pub filterable: bool,
pub sortable: bool,
pub readonly: bool,
pub widget: Option<&'static str>,
}
impl BridgedField {
pub fn effective_widget(&self) -> Option<&'static str> {
if let Some(explicit) = self.widget {
return Some(explicit);
}
let name = self.field.name;
if name == "email" || name.ends_with("_email") {
return Some("email");
}
if name == "phone"
|| name == "tel"
|| name.ends_with("_phone")
|| name.ends_with("_tel")
{
return Some("tel");
}
if name == "url" || name.ends_with("_url") || name.ends_with("_uri") {
return Some("url");
}
if name == "password" || name == "passwd" || name.ends_with("_password") {
return Some("password");
}
None
}
}
pub fn bridged_fields_from_schema(schema: &ModelSchema) -> Vec<BridgedField> {
schema
.columns
.iter()
.map(|col| BridgedField {
field: AdminField {
name: col.name,
label: label_for(col),
field_type: field_type_for(col),
editable: !col.flags.readonly,
relation: None,
choices: None,
},
primary_key: col.primary_key,
searchable: col.flags.searchable,
filterable: col.flags.filterable,
sortable: col.flags.sortable,
readonly: col.flags.readonly,
widget: col.admin_widget,
})
.collect()
}
pub fn admin_fields_from_schema(schema: &ModelSchema) -> &'static [AdminField] {
let fields: Vec<AdminField> = bridged_fields_from_schema(schema)
.into_iter()
.map(|b| b.field)
.collect();
Box::leak(fields.into_boxed_slice())
}
pub fn primary_key_column(schema: &ModelSchema) -> Option<&ModelColumn> {
schema.columns.iter().find(|c| c.primary_key)
}
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use sqlx::Row as SqlxRow;
use crate::admin::types::{AdminEntry, AdminOps, EditRow, ListRow};
use crate::contract::HasSchema;
use crate::error::{Error, Result};
use crate::http::FormData;
use crate::orm::Db;
fn leak_schema(schema: ModelSchema) -> &'static ModelSchema {
Box::leak(Box::new(schema))
}
pub(crate) struct SchemaOps {
schema: &'static ModelSchema,
}
impl SchemaOps {
fn new(schema: &'static ModelSchema) -> Self {
Self { schema }
}
fn pk_col(&self) -> &'static crate::contract::ModelColumn {
primary_key_column(self.schema).unwrap_or_else(|| {
&self.schema.columns[0]
})
}
fn writable_columns(&self) -> Vec<&'static crate::contract::ModelColumn> {
self.schema
.columns
.iter()
.filter(|c| !c.primary_key && !c.flags.readonly)
.collect()
}
}
fn format_pg_value_for_column(
row: &sqlx::postgres::PgRow,
col: &crate::contract::ModelColumn,
) -> String {
use crate::contract::RustType::*;
match (col.rust_type, col.nullable) {
(I32, false) => row.try_get::<i32, _>(col.name).map(|v| v.to_string()).unwrap_or_default(),
(I32, true) => row
.try_get::<Option<i32>, _>(col.name)
.ok()
.flatten()
.map(|v| v.to_string())
.unwrap_or_default(),
(I64, false) => row.try_get::<i64, _>(col.name).map(|v| v.to_string()).unwrap_or_default(),
(I64, true) => row
.try_get::<Option<i64>, _>(col.name)
.ok()
.flatten()
.map(|v| v.to_string())
.unwrap_or_default(),
(Bool, false) => row.try_get::<bool, _>(col.name).map(|b| b.to_string()).unwrap_or_default(),
(Bool, true) => row
.try_get::<Option<bool>, _>(col.name)
.ok()
.flatten()
.map(|b| b.to_string())
.unwrap_or_default(),
(String, false) => row.try_get::<std::string::String, _>(col.name).unwrap_or_default(),
(String, true) => row
.try_get::<Option<std::string::String>, _>(col.name)
.ok()
.flatten()
.unwrap_or_default(),
(DateTimeUtc, false) => row
.try_get::<DateTime<Utc>, _>(col.name)
.map(|d| d.to_rfc3339())
.unwrap_or_default(),
(DateTimeUtc, true) => row
.try_get::<Option<DateTime<Utc>>, _>(col.name)
.ok()
.flatten()
.map(|d| d.to_rfc3339())
.unwrap_or_default(),
(F64, false) => row.try_get::<f64, _>(col.name).map(|v| v.to_string()).unwrap_or_default(),
(F64, true) => row
.try_get::<Option<f64>, _>(col.name)
.ok()
.flatten()
.map(|v| v.to_string())
.unwrap_or_default(),
(Uuid, false) => row
.try_get::<uuid::Uuid, _>(col.name)
.map(|u| u.to_string())
.unwrap_or_default(),
(Uuid, true) => row
.try_get::<Option<uuid::Uuid>, _>(col.name)
.ok()
.flatten()
.map(|u| u.to_string())
.unwrap_or_default(),
(Decimal, _) | (JsonValue, _) => row
.try_get::<std::string::String, _>(col.name)
.unwrap_or_default(),
}
}
fn bind_form_value<'a>(
q: sqlx::query::Query<'a, sqlx::Postgres, sqlx::postgres::PgArguments>,
col: &crate::contract::ModelColumn,
raw: Option<&str>,
) -> std::result::Result<sqlx::query::Query<'a, sqlx::Postgres, sqlx::postgres::PgArguments>, std::string::String> {
use crate::contract::RustType::*;
let raw = raw.unwrap_or("").trim();
if raw.is_empty() && col.nullable {
return Ok(match col.rust_type {
I32 => q.bind(None::<i32>),
I64 => q.bind(None::<i64>),
F64 => q.bind(None::<f64>),
Bool => q.bind(None::<bool>),
String => q.bind(None::<std::string::String>),
DateTimeUtc => q.bind(None::<DateTime<Utc>>),
Uuid => q.bind(None::<uuid::Uuid>),
Decimal | JsonValue => q.bind(None::<std::string::String>),
});
}
let parsed: std::result::Result<sqlx::query::Query<'a, sqlx::Postgres, sqlx::postgres::PgArguments>, std::string::String> = match col.rust_type {
I32 => raw
.parse::<i32>()
.map(|v| q.bind(v))
.map_err(|e| format!("`{}`: {}", col.name, e)),
I64 => raw
.parse::<i64>()
.map(|v| q.bind(v))
.map_err(|e| format!("`{}`: {}", col.name, e)),
F64 => raw
.parse::<f64>()
.map(|v| q.bind(v))
.map_err(|e| format!("`{}`: {}", col.name, e)),
Bool => Ok({
let truthy = matches!(
raw.to_ascii_lowercase().as_str(),
"on" | "true" | "1" | "yes"
);
q.bind(truthy)
}),
String => Ok(q.bind(raw.to_string())),
DateTimeUtc => DateTime::parse_from_rfc3339(raw)
.map(|dt| q.bind(dt.with_timezone(&Utc)))
.map_err(|e| format!("`{}`: expected RFC3339 timestamp ({})", col.name, e)),
Uuid => uuid::Uuid::parse_str(raw)
.map(|u| q.bind(u))
.map_err(|e| format!("`{}`: {}", col.name, e)),
Decimal | JsonValue => Ok(q.bind(raw.to_string())),
};
parsed
}
type CreateFut<'a> = Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<std::string::String>>>> + Send + 'a>>;
type UpdateFut<'a> = Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<std::string::String>>>> + Send + 'a>>;
impl AdminOps for SchemaOps {
fn list<'a>(
&'a self,
db: &'a Db,
) -> Pin<Box<dyn Future<Output = Result<Vec<ListRow>>> + Send + 'a>> {
Box::pin(async move {
let pk = self.pk_col();
let cols = self
.schema
.columns
.iter()
.map(|c| c.name)
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT {cols} FROM {} ORDER BY {} DESC LIMIT 200",
self.schema.table, pk.name
);
let rows = sqlx::query(&sql)
.fetch_all(db.pool())
.await
.map_err(|e| Error::Internal(format!("schema-list({}): {e}", self.schema.table)))?;
let out = rows
.into_iter()
.map(|row| {
let id = row.try_get::<i64, _>(pk.name).unwrap_or(0);
let cells = self
.schema
.columns
.iter()
.map(|c| format_pg_value_for_column(&row, c))
.collect();
ListRow { id, cells }
})
.collect();
Ok(out)
})
}
fn find_row<'a>(
&'a self,
db: &'a Db,
id: i64,
) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
Box::pin(async move {
let pk = self.pk_col();
let cols = self
.schema
.columns
.iter()
.map(|c| c.name)
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT {cols} FROM {} WHERE {} = $1",
self.schema.table, pk.name
);
let maybe_row = sqlx::query(&sql)
.bind(id)
.fetch_optional(db.pool())
.await
.map_err(|e| Error::Internal(format!("schema-find({}): {e}", self.schema.table)))?;
Ok(maybe_row.map(|row| {
let values = self
.schema
.columns
.iter()
.map(|c| (c.name.to_string(), format_pg_value_for_column(&row, c)))
.collect();
EditRow { id, values }
}))
})
}
fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateFut<'a> {
Box::pin(async move {
let pk = self.pk_col();
let writables = self.writable_columns();
let col_names: Vec<&str> = writables.iter().map(|c| c.name).collect();
let placeholders: Vec<std::string::String> =
(1..=writables.len()).map(|i| format!("${i}")).collect();
let sql = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING {}",
self.schema.table,
col_names.join(", "),
placeholders.join(", "),
pk.name
);
let mut q = sqlx::query(&sql);
let mut errors: Vec<std::string::String> = Vec::new();
for col in &writables {
match bind_form_value(q, col, form.get(col.name)) {
Ok(next) => q = next,
Err(msg) => {
errors.push(msg);
q = sqlx::query(&sql); break;
}
}
}
if !errors.is_empty() {
return Ok(Err(errors));
}
let row = q
.fetch_one(db.pool())
.await
.map_err(|e| Error::Internal(format!("schema-create({}): {e}", self.schema.table)))?;
let id: i64 = row
.try_get(pk.name)
.map_err(|e| Error::Internal(format!("returning {}: {e}", pk.name)))?;
db.invalidate(self.schema.table);
Ok(Ok(id))
})
}
fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateFut<'a> {
Box::pin(async move {
let pk = self.pk_col();
let writables = self.writable_columns();
let sets: Vec<std::string::String> = writables
.iter()
.enumerate()
.map(|(i, c)| format!("{} = ${}", c.name, i + 1))
.collect();
let sql = format!(
"UPDATE {} SET {} WHERE {} = ${}",
self.schema.table,
sets.join(", "),
pk.name,
writables.len() + 1
);
let mut q = sqlx::query(&sql);
let mut errors: Vec<std::string::String> = Vec::new();
for col in &writables {
match bind_form_value(q, col, form.get(col.name)) {
Ok(next) => q = next,
Err(msg) => {
errors.push(msg);
q = sqlx::query(&sql);
break;
}
}
}
if !errors.is_empty() {
return Ok(Err(errors));
}
q = q.bind(id);
q.execute(db.pool())
.await
.map_err(|e| Error::Internal(format!("schema-update({}): {e}", self.schema.table)))?;
db.invalidate(self.schema.table);
Ok(Ok(()))
})
}
fn delete<'a>(
&'a self,
db: &'a Db,
id: i64,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
let pk = self.pk_col();
let sql = format!(
"DELETE FROM {} WHERE {} = $1",
self.schema.table, pk.name
);
sqlx::query(&sql)
.bind(id)
.execute(db.pool())
.await
.map_err(|e| Error::Internal(format!("schema-delete({}): {e}", self.schema.table)))?;
db.invalidate(self.schema.table);
Ok(())
})
}
fn object_label<'a>(
&'a self,
db: &'a Db,
id: i64,
) -> Pin<Box<dyn Future<Output = Result<Option<std::string::String>>> + Send + 'a>> {
Box::pin(async move {
let label_col = self.schema.columns.iter().find(|c| {
!c.primary_key
&& matches!(c.rust_type, crate::contract::RustType::String)
});
let pk = self.pk_col();
match label_col {
Some(col) => {
let sql = format!(
"SELECT {} FROM {} WHERE {} = $1",
col.name, self.schema.table, pk.name
);
let row = sqlx::query(&sql)
.bind(id)
.fetch_optional(db.pool())
.await
.map_err(|e| {
Error::Internal(format!(
"schema-object-label({}): {e}",
self.schema.table
))
})?;
Ok(row.and_then(|r| {
let v = if col.nullable {
r.try_get::<Option<std::string::String>, _>(col.name)
.ok()
.flatten()
} else {
r.try_get::<std::string::String, _>(col.name).ok()
};
v.filter(|s| !s.is_empty())
}))
}
None => Ok(Some(format!("{} #{}", self.schema.table, id))),
}
})
}
}
pub fn admin_entry_from_schema(schema: ModelSchema) -> AdminEntry {
let static_schema = leak_schema(schema);
let admin_name: &'static str = static_schema.table;
let display_name: &'static str =
Box::leak(humanise_table(static_schema.table).into_boxed_str());
let singular_name: &'static str =
Box::leak(singularise(static_schema.table).into_boxed_str());
AdminEntry {
admin_name,
display_name,
singular_name,
table: static_schema.table,
fields: admin_fields_from_schema(static_schema),
core: false,
ops: Arc::new(SchemaOps::new(static_schema)),
search_hook: None,
}
}
pub fn admin_entry_from_type<T: HasSchema>() -> AdminEntry {
admin_entry_from_schema(T::SCHEMA)
}
fn humanise_table(name: &str) -> std::string::String {
let mut out = std::string::String::with_capacity(name.len());
let mut next_upper = true;
for ch in name.chars() {
if ch == '_' {
out.push(' ');
next_upper = true;
} else if next_upper {
out.extend(ch.to_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
fn singularise(name: &str) -> std::string::String {
let h = humanise_table(name);
if let Some(stripped) = h.strip_suffix("ies") {
if !stripped.is_empty() {
return format!("{stripped}y");
}
}
if let Some(stripped) = h.strip_suffix('s') {
if !stripped.is_empty() {
return stripped.to_string();
}
}
h
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::SchemaFlags;
fn fixture_schema() -> ModelSchema {
static COLS: &[ModelColumn] = &[
ModelColumn {
name: "id",
sql_decl: "BIGSERIAL PRIMARY KEY",
rust_type: RustType::I64,
nullable: false,
primary_key: true,
flags: SchemaFlags {
searchable: false,
filterable: false,
sortable: true,
readonly: true,
},
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "title",
sql_decl: "TEXT NOT NULL",
rust_type: RustType::String,
nullable: false,
primary_key: false,
flags: SchemaFlags {
searchable: true,
filterable: true,
sortable: false,
readonly: false,
},
admin_label: Some("Headline"),
admin_widget: None,
},
ModelColumn {
name: "body",
sql_decl: "TEXT",
rust_type: RustType::String,
nullable: true,
primary_key: false,
flags: SchemaFlags {
searchable: true,
filterable: false,
sortable: false,
readonly: false,
},
admin_label: None,
admin_widget: Some("textarea"),
},
ModelColumn {
name: "published_at",
sql_decl: "TIMESTAMPTZ",
rust_type: RustType::DateTimeUtc,
nullable: true,
primary_key: false,
flags: SchemaFlags {
searchable: false,
filterable: true,
sortable: true,
readonly: false,
},
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "is_pinned",
sql_decl: "BOOLEAN NOT NULL",
rust_type: RustType::Bool,
nullable: false,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
},
];
ModelSchema {
table: "posts",
columns: COLS,
primary_key: "id",
search_index: Some("posts"),
}
}
#[test]
fn fields_generated_from_schema_one_per_column() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
assert_eq!(
bridged.len(),
schema.columns.len(),
"every ModelColumn must produce exactly one BridgedField"
);
}
#[test]
fn ordering_preserved_matches_schema_columns() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
let bridged_names: Vec<&str> = bridged.iter().map(|b| b.field.name).collect();
let schema_names: Vec<&str> = schema.columns.iter().map(|c| c.name).collect();
assert_eq!(
bridged_names, schema_names,
"BridgedField order must mirror ModelSchema.columns order"
);
}
#[test]
fn flags_correctly_mapped_per_column() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
let id = &bridged[0];
assert!(!id.searchable);
assert!(!id.filterable);
assert!(id.sortable);
assert!(id.readonly);
assert!(!id.field.editable, "readonly => editable=false");
let title = &bridged[1];
assert!(title.searchable);
assert!(title.filterable);
assert!(!title.sortable);
assert!(!title.readonly);
assert!(title.field.editable);
let body = &bridged[2];
assert!(body.searchable);
assert!(!body.filterable);
assert!(!body.sortable);
assert!(!body.readonly);
let pa = &bridged[3];
assert!(!pa.searchable);
assert!(pa.filterable);
assert!(pa.sortable);
let pin = &bridged[4];
assert!(!pin.searchable);
assert!(!pin.filterable);
assert!(!pin.sortable);
assert!(!pin.readonly);
}
#[test]
fn label_fallback_humanises_column_name_when_no_override() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
assert_eq!(bridged[1].field.label, "Headline");
assert_eq!(bridged[0].field.label, "Id");
assert_eq!(bridged[2].field.label, "Body");
assert_eq!(bridged[3].field.label, "Published At");
assert_eq!(bridged[4].field.label, "Is Pinned");
}
#[test]
fn label_fallback_strips_id_suffix_for_foreign_keys() {
static COLS: &[ModelColumn] = &[
ModelColumn {
name: "id",
sql_decl: "BIGSERIAL PRIMARY KEY",
rust_type: RustType::I64,
nullable: false,
primary_key: true,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "client_id",
sql_decl: "BIGINT NOT NULL",
rust_type: RustType::I64,
nullable: false,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "primary_address_id",
sql_decl: "BIGINT",
rust_type: RustType::I64,
nullable: true,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
},
];
let schema = ModelSchema {
table: "scratch",
columns: COLS,
primary_key: "id",
search_index: None,
};
let bridged = bridged_fields_from_schema(&schema);
assert_eq!(bridged[0].field.label, "Id"); assert_eq!(bridged[1].field.label, "Client"); assert_eq!(bridged[2].field.label, "Primary Address"); }
#[test]
fn widget_override_preserved_through_bridge() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
assert_eq!(bridged[2].widget, Some("textarea"), "body's textarea override must survive");
assert!(bridged[0].widget.is_none(), "id had no widget override");
assert!(bridged[1].widget.is_none(), "title had no widget override");
assert!(bridged[3].widget.is_none(), "published_at had no widget override");
assert!(bridged[4].widget.is_none(), "is_pinned had no widget override");
}
#[test]
fn primary_key_detected_from_schema() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
let pk_count = bridged.iter().filter(|b| b.primary_key).count();
assert_eq!(pk_count, 1, "fixture has exactly one primary-key column");
assert!(bridged[0].primary_key, "the `id` column is the PK");
assert!(!bridged[1].primary_key);
let pk = primary_key_column(&schema).expect("PK exists in fixture");
assert_eq!(pk.name, "id");
}
#[test]
fn primary_key_column_returns_none_when_unflagged() {
static COLS: &[ModelColumn] = &[ModelColumn {
name: "value",
sql_decl: "TEXT NOT NULL",
rust_type: RustType::String,
nullable: false,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
}];
let schema = ModelSchema {
table: "scratch",
columns: COLS,
primary_key: "value",
search_index: None,
};
assert!(primary_key_column(&schema).is_none());
}
#[test]
fn field_type_mapping_covers_native_variants() {
fn col(rust_type: RustType, nullable: bool) -> ModelColumn {
ModelColumn {
name: "f",
sql_decl: "",
rust_type,
nullable,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
}
}
assert_eq!(field_type_for(&col(RustType::I32, false)), FieldType::I32);
assert_eq!(field_type_for(&col(RustType::I32, true)), FieldType::OptionalI64);
assert_eq!(field_type_for(&col(RustType::I64, false)), FieldType::I64);
assert_eq!(field_type_for(&col(RustType::I64, true)), FieldType::OptionalI64);
assert_eq!(field_type_for(&col(RustType::Bool, false)), FieldType::Bool);
assert_eq!(field_type_for(&col(RustType::Bool, true)), FieldType::Bool);
assert_eq!(field_type_for(&col(RustType::String, false)), FieldType::String);
assert_eq!(field_type_for(&col(RustType::String, true)), FieldType::OptionalString);
assert_eq!(field_type_for(&col(RustType::DateTimeUtc, false)), FieldType::DateTime);
assert_eq!(field_type_for(&col(RustType::DateTimeUtc, true)), FieldType::OptionalDateTime);
}
#[test]
fn field_type_mapping_falls_back_to_string_for_unmodelled_variants() {
fn col(rust_type: RustType, nullable: bool) -> ModelColumn {
ModelColumn {
name: "f",
sql_decl: "",
rust_type,
nullable,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
}
}
for rt in [RustType::F64, RustType::Decimal, RustType::JsonValue, RustType::Uuid] {
assert_eq!(field_type_for(&col(rt, false)), FieldType::String, "{:?} -> String", rt);
assert_eq!(field_type_for(&col(rt, true)), FieldType::OptionalString, "{:?} -> OptionalString", rt);
}
}
#[test]
fn admin_fields_slice_matches_bridge_output() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
let slice = admin_fields_from_schema(&schema);
assert_eq!(slice.len(), bridged.len());
for (i, f) in slice.iter().enumerate() {
assert_eq!(f.name, bridged[i].field.name, "name @{}", i);
assert_eq!(f.label, bridged[i].field.label, "label @{}", i);
assert_eq!(f.field_type, bridged[i].field.field_type, "field_type @{}", i);
assert_eq!(f.editable, bridged[i].field.editable, "editable @{}", i);
}
}
#[test]
fn admin_fields_slice_is_static_lifetime() {
fn assert_static(_x: &'static [AdminField]) {}
let schema = fixture_schema();
let slice = admin_fields_from_schema(&schema);
assert_static(slice);
}
#[test]
fn editable_is_inverse_of_readonly() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
for b in &bridged {
assert_eq!(
b.field.editable, !b.readonly,
"editable must always equal !readonly for `{}`",
b.field.name
);
}
}
#[test]
fn empty_schema_produces_empty_bridge_output() {
static COLS: &[ModelColumn] = &[];
let schema = ModelSchema {
table: "empty",
columns: COLS,
primary_key: "id",
search_index: None,
};
assert_eq!(bridged_fields_from_schema(&schema).len(), 0);
assert_eq!(admin_fields_from_schema(&schema).len(), 0);
assert!(primary_key_column(&schema).is_none());
}
#[test]
fn humanise_table_capitalises_first_letter() {
assert_eq!(super::humanise_table("projects"), "Projects");
assert_eq!(super::humanise_table("clients"), "Clients");
assert_eq!(super::humanise_table("invoices"), "Invoices");
}
#[test]
fn humanise_table_translates_underscores_to_spaces() {
assert_eq!(super::humanise_table("audit_logs"), "Audit Logs");
assert_eq!(super::humanise_table("user_profiles"), "User Profiles");
}
#[test]
fn singularise_handles_common_plural_endings() {
assert_eq!(super::singularise("projects"), "Project");
assert_eq!(super::singularise("clients"), "Client");
assert_eq!(super::singularise("invoices"), "Invoice");
assert_eq!(super::singularise("companies"), "Company");
assert_eq!(super::singularise("categories"), "Category");
assert_eq!(super::singularise("status"), "Statu");
}
#[test]
fn effective_widget_returns_explicit_override_when_present() {
let schema = fixture_schema();
let bridged = bridged_fields_from_schema(&schema);
let body = bridged.iter().find(|b| b.field.name == "body").unwrap();
assert_eq!(body.effective_widget(), Some("textarea"));
}
#[test]
fn effective_widget_infers_from_recognised_column_names() {
fn col(name: &'static str) -> ModelColumn {
ModelColumn {
name,
sql_decl: "TEXT NOT NULL",
rust_type: RustType::String,
nullable: false,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
}
}
let cases = [
("email", Some("email")),
("contact_email", Some("email")),
("phone", Some("tel")),
("home_phone", Some("tel")),
("tel", Some("tel")),
("url", Some("url")),
("homepage_url", Some("url")),
("api_uri", Some("url")),
("password", Some("password")),
("admin_password", Some("password")),
("description", None),
("notes", None),
("title", None),
];
for (name, expected) in cases {
let bf = BridgedField {
field: AdminField {
name,
label: name,
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
primary_key: false,
searchable: false,
filterable: false,
sortable: false,
readonly: false,
widget: None,
};
assert_eq!(
bf.effective_widget(),
expected,
"name-based inference for `{name}`"
);
let _ = col(name); }
}
#[test]
fn admin_entry_from_schema_packages_metadata_correctly() {
let schema = fixture_schema();
let entry = super::admin_entry_from_schema(schema);
assert_eq!(entry.admin_name, "posts");
assert_eq!(entry.display_name, "Posts");
assert_eq!(entry.singular_name, "Post");
assert_eq!(entry.table, "posts");
assert!(!entry.core, "schema-derived entries are never `core`");
let names: Vec<&str> = entry.fields.iter().map(|f| f.name).collect();
assert_eq!(
names,
vec!["id", "title", "body", "published_at", "is_pinned"]
);
assert!(entry.search_hook.is_none());
}
#[test]
fn admin_from_schemas_registers_each_schema_in_order() {
use crate::admin::types::Admin;
let schemas = vec![
ModelSchema {
table: "alpha",
columns: fixture_schema().columns,
primary_key: "id",
search_index: None,
},
ModelSchema {
table: "beta",
columns: fixture_schema().columns,
primary_key: "id",
search_index: None,
},
];
let admin = Admin::new().from_schemas(&schemas);
let entry_tables: Vec<&str> =
admin.entries().iter().map(|e| e.table).collect();
assert!(entry_tables.contains(&"alpha"));
assert!(entry_tables.contains(&"beta"));
let alpha_pos = entry_tables.iter().position(|t| *t == "alpha").unwrap();
let beta_pos = entry_tables.iter().position(|t| *t == "beta").unwrap();
assert!(alpha_pos < beta_pos, "from_schemas preserves slice order");
}
}