#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RustType {
I32,
I64,
F64,
Bool,
String,
DateTimeUtc,
Decimal,
JsonValue,
Uuid,
}
impl RustType {
pub fn pg_compatible(&self) -> &'static [&'static str] {
match self {
RustType::I32 => &["integer", "int4"],
RustType::I64 => &["bigint", "int8", "bigserial", "serial8"],
RustType::F64 => &["double precision", "float8", "real", "float4"],
RustType::Bool => &["boolean", "bool"],
RustType::String => &["text", "varchar", "character varying"],
RustType::DateTimeUtc => &["timestamp with time zone", "timestamptz"],
RustType::Decimal => &["numeric", "decimal"],
RustType::JsonValue => &["jsonb"],
RustType::Uuid => &["uuid"],
}
}
pub fn is_compatible_with(&self, pg_type: &str) -> bool {
let needle = pg_type.trim().to_lowercase();
self.pg_compatible().contains(&needle.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct SchemaFlags {
pub searchable: bool,
pub filterable: bool,
pub sortable: bool,
pub readonly: bool,
}
impl SchemaFlags {
pub const fn empty() -> Self {
Self {
searchable: false,
filterable: false,
sortable: false,
readonly: false,
}
}
pub const fn searchable() -> Self {
Self {
searchable: true,
filterable: false,
sortable: false,
readonly: false,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ModelColumn {
pub name: &'static str,
pub sql_decl: &'static str,
pub rust_type: RustType,
pub nullable: bool,
pub primary_key: bool,
pub flags: SchemaFlags,
pub admin_label: Option<&'static str>,
pub admin_widget: Option<&'static str>,
}
impl ModelColumn {
pub const fn new(
name: &'static str,
sql_decl: &'static str,
rust_type: RustType,
) -> Self {
Self {
name,
sql_decl,
rust_type,
nullable: false,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
}
}
pub const fn nullable(mut self) -> Self {
self.nullable = true;
self
}
pub const fn primary_key(mut self) -> Self {
self.primary_key = true;
self
}
pub const fn with_flags(mut self, flags: SchemaFlags) -> Self {
self.flags = flags;
self
}
pub const fn with_label(mut self, label: &'static str) -> Self {
self.admin_label = Some(label);
self
}
pub const fn with_widget(mut self, widget: &'static str) -> Self {
self.admin_widget = Some(widget);
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ModelSchema {
pub table: &'static str,
pub columns: &'static [ModelColumn],
pub primary_key: &'static str,
pub search_index: Option<&'static str>,
}
impl ModelSchema {
pub const fn new(
table: &'static str,
columns: &'static [ModelColumn],
primary_key: &'static str,
) -> Self {
Self {
table,
columns,
primary_key,
search_index: None,
}
}
pub const fn with_search_index(mut self, index: &'static str) -> Self {
self.search_index = Some(index);
self
}
pub fn column(&self, name: &str) -> Option<&ModelColumn> {
self.columns.iter().find(|c| c.name == name)
}
pub fn searchable_columns(&self) -> impl Iterator<Item = &ModelColumn> {
self.columns.iter().filter(|c| c.flags.searchable)
}
}
pub trait HasSchema {
const SCHEMA: ModelSchema;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn i64_is_compatible_with_bigint_and_bigserial() {
for pg in &["bigint", "BIGINT", "int8", "INT8", "bigserial", "BIGSERIAL", "serial8"] {
assert!(
RustType::I64.is_compatible_with(pg),
"i64 must be compatible with `{pg}` (Type Rule #1)"
);
}
}
#[test]
fn i32_does_not_satisfy_id_constraint() {
for pg in &["bigint", "int8", "bigserial", "serial8"] {
assert!(
!RustType::I32.is_compatible_with(pg),
"i32 must NOT match `{pg}` — using i32 for IDs violates Type Rule #1"
);
}
assert!(RustType::I32.is_compatible_with("integer"));
assert!(RustType::I32.is_compatible_with("int4"));
}
#[test]
fn datetime_utc_is_compatible_with_timestamptz() {
for pg in &[
"timestamp with time zone",
"TIMESTAMP WITH TIME ZONE",
"Timestamp With Time Zone",
"timestamptz",
"TIMESTAMPTZ",
] {
assert!(
RustType::DateTimeUtc.is_compatible_with(pg),
"DateTime<Utc> must be compatible with `{pg}` (Type Rule #2)"
);
}
}
#[test]
fn datetime_utc_rejects_naive_timestamp() {
for pg in &["timestamp", "timestamp without time zone", "timestamp(6)"] {
assert!(
!RustType::DateTimeUtc.is_compatible_with(pg),
"DateTime<Utc> must NOT match `{pg}` — naive timestamps violate Type Rule #2"
);
}
}
#[test]
fn decimal_is_compatible_with_numeric() {
for pg in &["numeric", "NUMERIC", "decimal", "DECIMAL"] {
assert!(
RustType::Decimal.is_compatible_with(pg),
"Decimal must be compatible with `{pg}` (Type Rule #3)"
);
}
}
#[test]
fn f64_is_not_valid_for_money_columns() {
for pg in &["numeric", "decimal", "numeric(12,2)"] {
assert!(
!RustType::F64.is_compatible_with(pg),
"f64 must NOT match `{pg}` — using f64 for money violates Type Rule #3"
);
}
assert!(RustType::F64.is_compatible_with("double precision"));
assert!(RustType::F64.is_compatible_with("float8"));
}
#[test]
fn decimal_rejects_double_precision_columns() {
for pg in &["double precision", "float8", "real", "float4"] {
assert!(
!RustType::Decimal.is_compatible_with(pg),
"Decimal must NOT match `{pg}` — money lives in NUMERIC, not floating point"
);
}
}
#[test]
fn json_value_is_compatible_with_jsonb_only() {
assert!(RustType::JsonValue.is_compatible_with("jsonb"));
assert!(RustType::JsonValue.is_compatible_with("JSONB"));
assert!(
!RustType::JsonValue.is_compatible_with("json"),
"JsonValue must NOT match plain `json` — Type Rule #4 requires JSONB"
);
}
#[test]
fn string_is_compatible_with_text_and_varchar() {
for pg in &[
"text",
"TEXT",
"varchar",
"VARCHAR",
"character varying",
"Character Varying",
] {
assert!(
RustType::String.is_compatible_with(pg),
"String must be compatible with `{pg}` (Type Rule #5)"
);
}
}
#[test]
fn pg_compatible_lists_are_lowercase() {
for variant in [
RustType::I32,
RustType::I64,
RustType::F64,
RustType::Bool,
RustType::String,
RustType::DateTimeUtc,
RustType::Decimal,
RustType::JsonValue,
RustType::Uuid,
] {
for pg in variant.pg_compatible() {
assert_eq!(
pg.to_lowercase().as_str(),
*pg,
"pg_compatible entry `{pg}` for {variant:?} must be lowercase"
);
}
}
}
#[test]
fn only_decimal_maps_to_numeric() {
let mut numeric_carriers: Vec<RustType> = Vec::new();
for variant in [
RustType::I32,
RustType::I64,
RustType::F64,
RustType::Bool,
RustType::String,
RustType::DateTimeUtc,
RustType::Decimal,
RustType::JsonValue,
RustType::Uuid,
] {
if variant.is_compatible_with("numeric") {
numeric_carriers.push(variant);
}
}
assert_eq!(
numeric_carriers,
vec![RustType::Decimal],
"exactly one RustType (Decimal) may be compatible with `numeric`; \
a second compatible variant means money columns can drift type"
);
}
#[test]
fn only_i64_maps_to_bigint() {
let mut bigint_carriers: Vec<RustType> = Vec::new();
for variant in [
RustType::I32,
RustType::I64,
RustType::F64,
RustType::Bool,
RustType::String,
RustType::DateTimeUtc,
RustType::Decimal,
RustType::JsonValue,
RustType::Uuid,
] {
if variant.is_compatible_with("bigint") {
bigint_carriers.push(variant);
}
}
assert_eq!(
bigint_carriers,
vec![RustType::I64],
"exactly one RustType (I64) may be compatible with `bigint`; \
allowing i32 here would silently violate Type Rule #1"
);
}
#[test]
fn model_schema_column_lookup() {
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: false,
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(),
admin_label: None,
admin_widget: None,
},
];
let schema = ModelSchema {
table: "posts",
columns: COLS,
primary_key: "id",
search_index: Some("posts"),
};
assert_eq!(schema.column("id").map(|c| c.name), Some("id"));
assert_eq!(schema.column("title").map(|c| c.name), Some("title"));
assert!(schema.column("missing").is_none());
}
#[test]
fn model_schema_searchable_columns_filter() {
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: false,
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(),
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "body",
sql_decl: "TEXT",
rust_type: RustType::String,
nullable: true,
primary_key: false,
flags: SchemaFlags::searchable(),
admin_label: None,
admin_widget: Some("textarea"),
},
];
let schema = ModelSchema {
table: "posts",
columns: COLS,
primary_key: "id",
search_index: Some("posts"),
};
let names: Vec<&str> = schema.searchable_columns().map(|c| c.name).collect();
assert_eq!(names, vec!["title", "body"]);
}
#[test]
fn schema_flags_default_is_safe_minimum() {
let f = SchemaFlags::default();
assert!(!f.searchable);
assert!(!f.filterable);
assert!(!f.sortable);
assert!(!f.readonly);
}
#[test]
fn schema_flags_searchable_constructor() {
let f = SchemaFlags::searchable();
assert!(f.searchable);
assert!(!f.filterable);
assert!(!f.sortable);
assert!(!f.readonly);
}
#[test]
fn is_compatible_with_trims_whitespace() {
assert!(RustType::I64.is_compatible_with(" bigint "));
assert!(RustType::String.is_compatible_with("\ttext\n"));
}
#[test]
fn schema_flags_empty_matches_default() {
assert_eq!(SchemaFlags::empty(), SchemaFlags::default());
}
#[test]
fn model_column_new_minimal_construction() {
let c = ModelColumn::new("title", "TEXT NOT NULL", RustType::String);
assert_eq!(c.name, "title");
assert_eq!(c.sql_decl, "TEXT NOT NULL");
assert_eq!(c.rust_type, RustType::String);
assert!(!c.nullable);
assert!(!c.primary_key);
assert_eq!(c.flags, SchemaFlags::empty());
assert!(c.admin_label.is_none());
assert!(c.admin_widget.is_none());
}
#[test]
fn model_column_builder_chain_accumulates() {
let c = ModelColumn::new("description", "TEXT", RustType::String)
.nullable()
.with_flags(SchemaFlags::searchable())
.with_label("Description")
.with_widget("textarea");
assert!(c.nullable);
assert!(!c.primary_key);
assert!(c.flags.searchable);
assert!(!c.flags.filterable);
assert_eq!(c.admin_label, Some("Description"));
assert_eq!(c.admin_widget, Some("textarea"));
}
#[test]
fn model_column_primary_key_smoke() {
let id = ModelColumn::new("id", "BIGSERIAL PRIMARY KEY", RustType::I64)
.primary_key();
assert!(id.primary_key);
assert_eq!(id.rust_type, RustType::I64);
}
#[test]
fn model_schema_new_minimal_construction() {
static COLS: &[ModelColumn] = &[];
let schema = ModelSchema::new("posts", COLS, "id");
assert_eq!(schema.table, "posts");
assert_eq!(schema.primary_key, "id");
assert!(schema.search_index.is_none());
assert_eq!(schema.columns.len(), 0);
}
#[test]
fn model_schema_with_search_index_setter() {
static COLS: &[ModelColumn] = &[];
let schema = ModelSchema::new("posts", COLS, "id").with_search_index("posts");
assert_eq!(schema.search_index, Some("posts"));
}
#[test]
fn const_context_composition_compiles() {
const COLS: &[ModelColumn] = &[
ModelColumn::new("id", "BIGSERIAL PRIMARY KEY", RustType::I64)
.primary_key()
.with_flags(SchemaFlags::empty()),
ModelColumn::new("title", "TEXT NOT NULL", RustType::String)
.with_flags(SchemaFlags::searchable())
.with_label("Title"),
ModelColumn::new("body", "TEXT", RustType::String)
.nullable()
.with_flags(SchemaFlags::searchable())
.with_widget("textarea"),
];
const SCHEMA: ModelSchema =
ModelSchema::new("posts", COLS, "id").with_search_index("posts");
assert_eq!(SCHEMA.table, "posts");
assert_eq!(SCHEMA.columns.len(), 3);
assert_eq!(SCHEMA.search_index, Some("posts"));
assert!(SCHEMA.column("id").unwrap().primary_key);
assert!(SCHEMA.column("body").unwrap().nullable);
let searchable: Vec<&str> = SCHEMA.searchable_columns().map(|c| c.name).collect();
assert_eq!(searchable, vec!["title", "body"]);
}
}