use autumn_schema_core::{Backend, IdKind};
use autumn_web::config::DatabaseBackend;
use super::GenerateError;
use super::naming;
const TRANSLATED_RUST_TYPE: &str = "autumn_web::i18n::Translated";
const EMPTY_TRANSLATION_CONTAINER: &str = "'{}'";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FieldConstraints {
pub min: Option<String>,
pub max: Option<String>,
pub email: bool,
pub url: bool,
pub label: Option<String>,
pub from: Option<String>,
pub encrypted: Option<EncryptedMode>,
pub scope: Option<String>,
pub translatable: bool,
pub widget: Option<StringWidget>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StringWidget {
Tel,
Color,
}
impl StringWidget {
#[must_use]
pub const fn dsl_token(self) -> &'static str {
match self {
Self::Tel => "phone",
Self::Color => "color",
}
}
#[must_use]
pub const fn field_control(self) -> &'static str {
match self {
Self::Tel => "Tel",
Self::Color => "Color",
}
}
}
impl FieldConstraints {
#[must_use]
pub const fn validation_attrs_are_empty(&self) -> bool {
self.min.is_none() && self.max.is_none() && !self.email && !self.url
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.min.is_none()
&& self.max.is_none()
&& !self.email
&& !self.url
&& self.label.is_none()
&& self.from.is_none()
&& self.encrypted.is_none()
&& self.scope.is_none()
&& !self.translatable
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncryptedMode {
Randomized,
Deterministic,
}
impl EncryptedMode {
fn parse(value: &str) -> Result<Self, String> {
match value {
"deterministic" => Ok(Self::Deterministic),
"randomized" => Ok(Self::Randomized),
opt @ ("admin_visible" | "versioned_ciphertext") => Err(format!(
"the `{opt}` encryption option is not expressible in the field DSL — a \
generated encrypted column is redacted in the admin and kept out of version \
history by default. Declare `{{encrypted}}` here and add \
`#[encrypted({opt})]` to the generated model field by hand if you need it."
)),
other => Err(format!(
"unknown encryption mode '{other}'; supported: deterministic, randomized \
(the default). Use `{{encrypted:deterministic}}` when the column needs \
`find_by`/`exists_by` equality lookups or a UNIQUE index."
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateTransition {
pub from: String,
pub to: String,
pub guard: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateMachine {
pub transitions: Vec<StateTransition>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Field {
pub name: String,
pub kind: FieldKind,
pub nullable: bool,
pub variants: Vec<String>,
pub unique: bool,
pub constraints: FieldConstraints,
pub state_machine: Option<StateMachine>,
}
impl Field {
#[must_use]
pub fn rust_type(&self) -> String {
if self.constraints.translatable {
return TRANSLATED_RUST_TYPE.to_owned();
}
let inner = self
.enum_type_name()
.unwrap_or_else(|| self.kind.rust_type().to_owned());
if self.nullable {
format!("Option<{inner}>")
} else {
inner
}
}
#[must_use]
pub fn rust_type_for(&self, backend: DatabaseBackend) -> String {
if backend == DatabaseBackend::Postgres {
return self.rust_type();
}
if self.constraints.translatable {
return TRANSLATED_RUST_TYPE.to_owned();
}
let inner = self
.enum_type_name()
.unwrap_or_else(|| self.kind.rust_type_for(backend).to_owned());
if self.nullable {
format!("Option<{inner}>")
} else {
inner
}
}
#[must_use]
pub const fn is_translatable(&self) -> bool {
self.constraints.translatable
}
#[must_use]
pub const fn sql_default(&self) -> Option<&'static str> {
if self.constraints.translatable {
Some(EMPTY_TRANSLATION_CONTAINER)
} else {
None
}
}
#[must_use]
pub fn enum_type_name(&self) -> Option<String> {
self.kind.is_enum().then(|| naming::pascal(&self.name))
}
#[must_use]
pub const fn is_enum(&self) -> bool {
self.kind.is_enum()
}
#[must_use]
pub fn schema_type(&self) -> String {
let inner = self.kind.schema_type();
if self.nullable {
format!("Nullable<{inner}>")
} else {
inner.to_string()
}
}
#[must_use]
pub const fn sql_type(&self) -> &'static str {
self.kind.sql_type()
}
#[must_use]
pub fn sql_column_type(&self) -> String {
match self.kind {
FieldKind::Decimal { precision, scale } => format!("NUMERIC({precision},{scale})"),
_ => self.sql_type().to_owned(),
}
}
#[must_use]
pub fn sql_column_type_for(&self, backend: DatabaseBackend) -> String {
if backend == DatabaseBackend::Postgres {
return self.sql_column_type();
}
self.kind.sql_type_for(backend).to_owned()
}
#[must_use]
pub fn schema_type_for(&self, backend: DatabaseBackend) -> String {
if backend == DatabaseBackend::Postgres {
return self.schema_type();
}
let inner = self.kind.schema_type_for(backend);
if self.nullable {
format!("Nullable<{inner}>")
} else {
inner.to_string()
}
}
#[must_use]
pub const fn sql_nullability(&self) -> &'static str {
if self.nullable { "NULL" } else { "NOT NULL" }
}
#[must_use]
pub fn validation_attrs(&self) -> Vec<String> {
let c = &self.constraints;
let mut out = Vec::new();
match self.kind {
FieldKind::String | FieldKind::Text | FieldKind::RichText => {
if c.min.is_some() || c.max.is_some() {
out.push(format!(
"length({})",
min_max_args(c.min.as_ref(), c.max.as_ref(), false)
));
}
if c.email {
out.push("email".to_owned());
}
if c.url {
out.push("url".to_owned());
}
}
FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64
if c.min.is_some() || c.max.is_some() =>
{
let is_float = matches!(self.kind, FieldKind::F32 | FieldKind::F64);
out.push(format!(
"range({})",
min_max_args(c.min.as_ref(), c.max.as_ref(), is_float)
));
}
_ => {}
}
out
}
#[must_use]
pub const fn encrypted_mode(&self) -> Option<EncryptedMode> {
self.constraints.encrypted
}
#[must_use]
pub const fn is_encrypted(&self) -> bool {
self.constraints.encrypted.is_some()
}
#[must_use]
pub const fn is_randomized_encrypted(&self) -> bool {
matches!(self.constraints.encrypted, Some(EncryptedMode::Randomized))
}
#[must_use]
pub fn reference_table(&self) -> Option<String> {
if !self.kind.is_reference() {
return None;
}
let base = self.name.strip_suffix("_id").unwrap_or(&self.name);
Some(naming::pluralize(base))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldKind {
String,
Text,
RichText,
I32,
I64,
Bool,
F32,
F64,
Uuid,
NaiveDateTime,
DateTime,
Time,
Bytea,
Attachment,
Json,
References,
Enum,
Decimal {
precision: u32,
scale: u32,
},
Slug,
Position,
Commentable,
}
impl FieldKind {
#[must_use]
pub const fn rust_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::RichText | Self::Enum | Self::Slug => "String",
Self::I32 => "i32",
Self::I64 | Self::References | Self::Position | Self::Commentable => "i64",
Self::Bool => "bool",
Self::F32 => "f32",
Self::F64 => "f64",
Self::Uuid => "uuid::Uuid",
Self::NaiveDateTime => "chrono::NaiveDateTime",
Self::DateTime => "chrono::DateTime<chrono::Utc>",
Self::Time => "chrono::NaiveTime",
Self::Bytea => "Vec<u8>",
Self::Attachment => "autumn_web::storage::Blob",
Self::Json => "serde_json::Value",
Self::Decimal { .. } => "rust_decimal::Decimal",
}
}
#[must_use]
pub const fn rust_type_for(self, backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => self.rust_type(),
DatabaseBackend::Sqlite => match self {
Self::Uuid => SQLITE_UUID_RUST_TYPE,
Self::Decimal { .. } => SQLITE_DECIMAL_RUST_TYPE,
_ => self.rust_type(),
},
}
}
#[must_use]
pub const fn schema_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::RichText | Self::Enum | Self::Slug => "Text",
Self::I32 => "Int4",
Self::I64 | Self::References | Self::Position | Self::Commentable => "Int8",
Self::Bool => "Bool",
Self::F32 => "Float4",
Self::F64 => "Float8",
Self::Uuid => "Uuid",
Self::NaiveDateTime => "Timestamp",
Self::DateTime => "Timestamptz",
Self::Time => "Time",
Self::Bytea => "Bytea",
Self::Attachment | Self::Json => "Jsonb",
Self::Decimal { .. } => "Numeric",
}
}
#[must_use]
pub const fn sql_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::RichText | Self::Enum | Self::Slug => "TEXT",
Self::I32 => "INTEGER",
Self::I64 | Self::References | Self::Position | Self::Commentable => "BIGINT",
Self::Bool => "BOOLEAN",
Self::F32 => "REAL",
Self::F64 => "DOUBLE PRECISION",
Self::Uuid => "UUID",
Self::NaiveDateTime => "TIMESTAMP",
Self::DateTime => "TIMESTAMPTZ",
Self::Time => "TIME",
Self::Bytea => "BYTEA",
Self::Attachment | Self::Json => "JSONB",
Self::Decimal { .. } => "NUMERIC",
}
}
#[must_use]
#[allow(
clippy::match_same_arms,
reason = "every FieldKind is listed explicitly to document the complete SQLite \
column mapping (AC #4), even where several kinds share a storage type"
)]
pub const fn sqlite_sql_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::RichText | Self::Enum | Self::Slug => "TEXT",
Self::I32
| Self::I64
| Self::References
| Self::Position
| Self::Commentable
| Self::Bool => "INTEGER",
Self::F32 | Self::F64 => "REAL",
Self::Uuid => "TEXT",
Self::NaiveDateTime | Self::DateTime | Self::Time => "TEXT",
Self::Bytea => "BLOB",
Self::Attachment | Self::Json => "TEXT",
Self::Decimal { .. } => "TEXT",
}
}
#[must_use]
pub const fn sql_type_for(self, backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => self.sql_type(),
DatabaseBackend::Sqlite => self.sqlite_sql_type(),
}
}
#[must_use]
#[allow(
clippy::match_same_arms,
reason = "every FieldKind is listed explicitly to document the complete SQLite \
diesel-type mapping (AC #4), even where several kinds share a type"
)]
pub const fn sqlite_schema_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::RichText | Self::Enum | Self::Slug => "Text",
Self::I32 => "Int4",
Self::I64 | Self::References | Self::Position | Self::Commentable => "Int8",
Self::Bool => "Bool",
Self::F32 => "Float4",
Self::F64 => "Float8",
Self::Uuid => "Text",
Self::NaiveDateTime => "Timestamp",
Self::DateTime => "TimestamptzSqlite",
Self::Time => "Time",
Self::Bytea => "Binary",
Self::Attachment => "Text",
Self::Json => "Json",
Self::Decimal { .. } => "Text",
}
}
#[must_use]
pub const fn sqlite_has_diesel_conversion(self) -> bool {
match self {
Self::String
| Self::Text
| Self::RichText
| Self::Slug
| Self::I32
| Self::I64
| Self::References
| Self::Position
| Self::Commentable
| Self::Bool
| Self::F32
| Self::F64
| Self::Uuid
| Self::NaiveDateTime
| Self::DateTime
| Self::Time
| Self::Bytea
| Self::Attachment
| Self::Json
| Self::Enum
| Self::Decimal { .. } => true,
}
}
#[must_use]
pub const fn schema_type_for(self, backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => self.schema_type(),
DatabaseBackend::Sqlite => self.sqlite_schema_type(),
}
}
#[must_use]
pub const fn dsl_token(self) -> &'static str {
match self {
Self::String => "String",
Self::Text => "Text",
Self::RichText => "richtext",
Self::I32 => "i32",
Self::I64 => "i64",
Self::Bool => "bool",
Self::F32 => "f32",
Self::F64 => "f64",
Self::Uuid => "Uuid",
Self::NaiveDateTime => "NaiveDateTime",
Self::DateTime => "DateTime",
Self::Time => "time",
Self::Bytea => "Bytea",
Self::Attachment => "Attachment",
Self::Json => "json",
Self::References => "references",
Self::Enum => "enum{…}",
Self::Decimal { .. } => "decimal{…}",
Self::Slug => "slug{…}",
Self::Position => "position",
Self::Commentable => "commentable",
}
}
#[must_use]
pub const fn is_attachment(self) -> bool {
matches!(self, Self::Attachment)
}
#[must_use]
pub const fn is_reference(self) -> bool {
matches!(self, Self::References)
}
#[must_use]
pub const fn is_enum(self) -> bool {
matches!(self, Self::Enum)
}
#[must_use]
pub const fn is_rich_text(self) -> bool {
matches!(self, Self::RichText)
}
#[must_use]
pub const fn is_slug(self) -> bool {
matches!(self, Self::Slug)
}
#[must_use]
pub const fn is_decimal(self) -> bool {
matches!(self, Self::Decimal { .. })
}
#[must_use]
pub const fn is_position(self) -> bool {
matches!(self, Self::Position)
}
#[must_use]
pub const fn is_commentable(self) -> bool {
matches!(self, Self::Commentable)
}
#[must_use]
pub const fn is_server_managed(self) -> bool {
matches!(self, Self::Position | Self::Commentable)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IdType {
#[default]
BigSerial,
Uuid,
}
impl IdType {
#[must_use]
pub const fn rust_type(self) -> &'static str {
match self {
Self::BigSerial => "i64",
Self::Uuid => "uuid::Uuid",
}
}
#[must_use]
pub const fn rust_type_for(self, backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => self.rust_type(),
DatabaseBackend::Sqlite => match self {
Self::BigSerial => IdKind::BigSerial.rust_type_for(Backend::Sqlite),
Self::Uuid => IdKind::Uuid.rust_type_for(Backend::Sqlite),
},
}
}
#[must_use]
pub const fn schema_type(self) -> &'static str {
match self {
Self::BigSerial => "Int8",
Self::Uuid => "Uuid",
}
}
#[must_use]
pub const fn pk_sql(self) -> &'static str {
match self {
Self::BigSerial => "BIGSERIAL PRIMARY KEY",
Self::Uuid => "UUID PRIMARY KEY DEFAULT gen_random_uuid()",
}
}
#[must_use]
pub const fn pk_sql_for(self, backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => self.pk_sql(),
DatabaseBackend::Sqlite => match self {
Self::BigSerial => "INTEGER PRIMARY KEY AUTOINCREMENT",
Self::Uuid => IdKind::Uuid.pk_sql(Backend::Sqlite),
},
}
}
#[must_use]
pub const fn schema_type_for(self, backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => self.schema_type(),
DatabaseBackend::Sqlite => match self {
Self::BigSerial => "Int8",
Self::Uuid => "Text",
},
}
}
#[must_use]
pub const fn migration_comment_for(self, backend: DatabaseBackend) -> Option<&'static str> {
match backend {
DatabaseBackend::Postgres => self.migration_comment(),
DatabaseBackend::Sqlite => None,
}
}
#[must_use]
pub const fn migration_comment(self) -> Option<&'static str> {
match self {
Self::BigSerial => None,
Self::Uuid => Some(
"-- gen_random_uuid() produces UUIDv4 (random, built into Postgres 13+).\n\
-- Trade-off: random UUIDs hurt B-tree insert locality on large tables.\n\
-- To switch to a time-ordered UUIDv7 (better locality, same privacy):\n\
-- 1. Enable pgcrypto or use the pg_uuidv7 extension.\n\
-- 2. Replace DEFAULT gen_random_uuid() with DEFAULT uuid_generate_v7().\n",
),
}
}
pub fn parse(s: &str) -> Result<Self, GenerateError> {
match s.to_ascii_lowercase().as_str() {
"uuid" => Ok(Self::Uuid),
"bigint" | "bigserial" | "i64" => Ok(Self::BigSerial),
other => Err(GenerateError::Config(format!(
"unknown --id value '{other}'; accepted values are: uuid, bigint"
))),
}
}
}
pub const SUPPORTED_TYPES: &str = "String, Text, richtext, i32, i64, bool, f32, f64, \
Uuid, NaiveDateTime, DateTime, time, Vec<u8>, Bytea, Attachment, json (alias jsonb), \
references, phone (alias tel), color, money, enum{a,b,…}, decimal{precision,scale}, \
slug{from:col}, position (optionally \
position{scope:col}), commentable, Option<…>, :unique, String{encrypted}, \
String{encrypted:deterministic}, String{translatable}";
pub const SQLITE_SUPPORTED_KINDS: &str = "String, Text, richtext, i32, i64, bool, f32, f64, \
Uuid, NaiveDateTime, DateTime, time, Vec<u8>, Bytea, Attachment, json (alias jsonb), \
references, phone (alias tel), color, money, enum{a,b,…}, decimal{precision,scale}, \
slug{from:col}, position (optionally \
position{scope:col}), commentable, Option<…>, :unique";
pub use autumn_schema_core::{SQLITE_UUID_RUST_TYPE, SQLITE_UUID_V4_SQL};
pub const SQLITE_DECIMAL_RUST_TYPE: &str = "autumn_web::db::sqlite_types::SqliteDecimal";
pub const SQL_SUPPORTED_TYPES: &str = "text, varchar, bpchar (-> String), int4 (-> i32), \
int8 (-> i64), bool, float4 (-> f32), float8 (-> f64), uuid, timestamp, timestamptz, time, bytea";
#[must_use]
pub fn sql_type_to_field_kind(udt_name: &str) -> Option<FieldKind> {
match udt_name {
"text" | "varchar" | "bpchar" => Some(FieldKind::String),
"int4" => Some(FieldKind::I32),
"int8" => Some(FieldKind::I64),
"bool" => Some(FieldKind::Bool),
"float4" => Some(FieldKind::F32),
"float8" => Some(FieldKind::F64),
"uuid" => Some(FieldKind::Uuid),
"timestamp" => Some(FieldKind::NaiveDateTime),
"timestamptz" => Some(FieldKind::DateTime),
"time" => Some(FieldKind::Time),
"bytea" => Some(FieldKind::Bytea),
_ => None,
}
}
#[allow(
clippy::too_many_lines,
reason = "a linear name→modifier→type→constraint parse; the early-return \
validation guards read more clearly inline than split across helpers"
)]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "the `{from:<field>}`/`{from:title}` mentions in the slug error message are the \
DSL's own constraint-modifier syntax, not format-string placeholders"
)]
pub fn parse_field(token: &str) -> Result<Field, GenerateError> {
let (name, rest) = token
.split_once(':')
.ok_or_else(|| GenerateError::InvalidField {
token: token.to_owned(),
reason: "expected `name:Type` (missing colon)".into(),
})?;
let name = name.trim();
let rest = rest.trim();
let (rest, state_machine_body) = split_state_machine_modifier(rest);
let state_machine = match state_machine_body {
Some(body) => {
Some(
parse_state_machine(body).map_err(|reason| GenerateError::InvalidField {
token: token.to_owned(),
reason,
})?,
)
}
None => None,
};
let (ty, unique) = match rest.rsplit_once(':') {
Some((before, modifier)) if modifier.trim() == "unique" => (before.trim(), true),
_ => (rest, false),
};
if name.is_empty() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "field name is empty".into(),
});
}
if !is_valid_ident(name) {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("'{name}' is not a valid snake_case identifier"),
});
}
if is_rust_keyword(name) {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("'{name}' is a Rust keyword and cannot be used as a struct field name"),
});
}
if let Some((variants, nullable)) =
parse_enum_type(ty).map_err(|reason| GenerateError::InvalidField {
token: token.to_owned(),
reason,
})?
{
if state_machine.is_some() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: STATE_MACHINE_STRING_ONLY.to_owned(),
});
}
return Ok(Field {
name: name.to_owned(),
kind: FieldKind::Enum,
nullable,
variants,
unique,
constraints: FieldConstraints::default(),
state_machine: None,
});
}
if let Some((precision, scale, nullable)) =
parse_decimal_type(ty).map_err(|reason| GenerateError::InvalidField {
token: token.to_owned(),
reason,
})?
{
if state_machine.is_some() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: STATE_MACHINE_STRING_ONLY.to_owned(),
});
}
return Ok(Field {
name: name.to_owned(),
kind: FieldKind::Decimal { precision, scale },
nullable,
variants: Vec::new(),
unique,
constraints: FieldConstraints::default(),
state_machine: None,
});
}
let (base_ty, modifier_body) = split_constraint_modifier(ty);
if base_ty.contains('{') {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!(
"malformed constraint modifier in '{ty}' (unbalanced braces). If you typed \
this in bash or zsh, quote the whole token so the shell doesn't brace-expand \
it, e.g. 'title:String{{min=3,max=120}}'."
),
});
}
if let Some((_, bad)) = base_ty.rsplit_once(':') {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!(
"unknown field modifier '{}'; the only bare modifier is 'unique' — other \
constraints go in a trailing `{{…}}` block (e.g. 'title:String{{min=3,max=120}}')",
bad.trim()
),
});
}
if let Some((widget, nullable)) = parse_string_widget(base_ty) {
if widget == StringWidget::Color && nullable {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "`color` cannot be `Option<…>`: a color input always posts a value, \
so the form cannot keep NULL. Use `Option<String>` for an optional color"
.to_owned(),
});
}
if modifier_body.is_some() || state_machine.is_some() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!(
"`{}` takes no `{{…}}` modifier and no `:states(…)`; use `String{{…}}` \
for a constrained text column",
widget.dsl_token()
),
});
}
return Ok(Field {
name: name.to_owned(),
kind: FieldKind::String,
nullable,
variants: Vec::new(),
unique,
constraints: FieldConstraints {
widget: Some(widget),
..FieldConstraints::default()
},
state_machine: None,
});
}
let (kind, nullable) = parse_type(base_ty).ok_or_else(|| GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("unsupported type '{base_ty}'. Supported: {SUPPORTED_TYPES}"),
})?;
let constraints = match modifier_body {
Some(body) => {
parse_field_constraints(body, kind).map_err(|reason| GenerateError::InvalidField {
token: token.to_owned(),
reason,
})?
}
None => FieldConstraints::default(),
};
if state_machine.is_some() && (nullable || !matches!(kind, FieldKind::String | FieldKind::Text))
{
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: STATE_MACHINE_STRING_ONLY.to_owned(),
});
}
let name = if kind == FieldKind::References && !name.ends_with("_id") {
format!("{name}_id")
} else if kind == FieldKind::Commentable {
if name.ends_with("_count") {
name.to_owned()
} else {
format!("{}_count", naming::singularize(name))
}
} else {
name.to_owned()
};
if kind == FieldKind::Commentable {
if nullable {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `commentable` field cannot be nullable — it is the \
framework-maintained comment counter, and `NULL + 1` is `NULL`"
.into(),
});
}
if unique {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `commentable` field cannot be `:unique` — it is a comment \
counter, and every model starts at 0"
.into(),
});
}
if !constraints.is_empty() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `commentable` field takes no `{…}` modifiers — configure the \
association on the generated model's `#[commentable(...)]` \
attribute instead"
.into(),
});
}
}
if kind == FieldKind::Slug {
if nullable {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `slug` field cannot be nullable — it is the record's routing key \
and every record needs a URL"
.into(),
});
}
if constraints.from.is_none() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `slug` field requires a `{from:<field>}` modifier naming its \
source field, e.g. `slug:slug{from:title}`"
.into(),
});
}
}
let unique = unique || kind == FieldKind::Slug;
if kind == FieldKind::Position {
if nullable {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `position` field cannot be nullable — every row has a place in its \
ordering"
.into(),
});
}
if unique {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `position` field cannot be `:unique` — the repository maintains a \
dense `0..len-1` sequence per scope, not a one-off distinct value"
.into(),
});
}
}
if let Some(mode) = constraints.encrypted {
if nullable {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "an `encrypted` field cannot be nullable — `#[encrypted]` supports \
non-null `String` columns in v1. Drop the `Option<…>` (store an \
empty string for \"unset\")."
.into(),
});
}
if state_machine.is_some() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "an `encrypted` field cannot also declare a `states(…)` state machine: \
the generated transition handler writes the new state with a raw \
`UPDATE … SET col = 'state'` that bypasses encryption, so transitions \
would store plaintext in the encrypted column. (A state column is also \
a low-entropy closed set, where randomized mode makes state queries \
impossible and deterministic mode leaks every row's state.)"
.into(),
});
}
if unique && mode == EncryptedMode::Randomized {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: randomized_equality_lookup_reason(&name, "is `unique`"),
});
}
}
if constraints.translatable {
if nullable {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `translatable` field cannot be nullable — the per-locale container \
already represents \"no translation\" as an empty map. Drop the \
`Option<…>`."
.into(),
});
}
if constraints.encrypted.is_some() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `translatable` field cannot also be `encrypted`: an encrypted column \
stores one opaque envelope, with no per-locale structure to resolve. \
Keep a separate encrypted column for the secret."
.into(),
});
}
if unique {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `translatable` field cannot be `:unique`: the index would compare \
whole per-locale containers, so identical text translated into \
different locale sets would never collide, and the derived \
`find_by_<col>` lookup could never match. Put the uniqueness on a \
non-translatable column (e.g. a `slug`)."
.into(),
});
}
let validation_modifiers = !constraints.validation_attrs_are_empty();
if validation_modifiers {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `translatable` field cannot carry `min`/`max`/`email`/`url` \
constraints: those emit a `#[validate(...)]` rule that `validator` \
implements for strings only, and a per-locale container has one \
value per locale rather than one string to measure. Validate the \
individual translations in your own edit handler."
.into(),
});
}
if state_machine.is_some() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: "a `translatable` field cannot also declare a `states(…)` state \
machine: a state name is locale-invariant, and the generated \
transition handler's raw `UPDATE … SET col = 'state'` would replace \
the whole per-locale container."
.into(),
});
}
}
Ok(Field {
name,
kind,
nullable,
variants: Vec::new(),
unique,
constraints,
state_machine,
})
}
#[must_use]
pub(super) fn randomized_equality_lookup_reason(field: &str, what: &str) -> String {
format!(
"field '{field}' {what}, but `{{encrypted}}` is randomized: a fresh nonce per write \
means equal plaintexts never produce equal ciphertext, so the lookup can never match \
(the runtime raises `EncryptionError::RandomizedEqualityLookup`) and a UNIQUE index \
over it would enforce nothing. Declare it `{{encrypted:deterministic}}` to support \
`find_by`/`exists_by` equality lookups and real uniqueness, or drop the lookup."
)
}
fn split_constraint_modifier(ty: &str) -> (&str, Option<&str>) {
let ty = ty.trim();
if let Some(open) = ty.find('{')
&& ty.ends_with('}')
{
let close = ty.len() - 1;
if close > open {
return (ty[..open].trim_end(), Some(ty[open + 1..close].trim()));
}
}
(ty, None)
}
const STATE_MACHINE_STRING_ONLY: &str =
"a `states(…)` state machine is only supported on a non-nullable `String`/`Text` field";
fn split_state_machine_modifier(rest: &str) -> (&str, Option<&str>) {
let rest = rest.trim();
if let Some(pos) = rest.find(":states(")
&& rest.ends_with(')')
{
let before = rest[..pos].trim_end();
let inner = rest[pos + ":states(".len()..rest.len() - 1].trim();
return (before, Some(inner));
}
(rest, None)
}
fn parse_state_machine(body: &str) -> Result<StateMachine, String> {
let mut transitions = Vec::new();
for segment in body.split(',') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let (from, to_and_guard) = segment.split_once("->").ok_or_else(|| {
format!("malformed transition '{segment}'; expected `from -> to` (missing `->`)")
})?;
let (to, guard) = match to_and_guard.split_once(':') {
Some((to, guard)) => (to.trim(), Some(guard.trim())),
None => (to_and_guard.trim(), None),
};
let from = from.trim();
validate_state_token(from, "state")?;
validate_state_token(to, "state")?;
if let Some(guard) = guard {
validate_state_token(guard, "guard")?;
}
transitions.push(StateTransition {
from: from.to_owned(),
to: to.to_owned(),
guard: guard.map(str::to_owned),
});
}
if transitions.is_empty() {
return Err(
"empty `states(…)` modifier; declare at least one `from -> to` transition".to_owned(),
);
}
Ok(StateMachine { transitions })
}
fn validate_state_token(token: &str, kind: &str) -> Result<(), String> {
if token.is_empty() {
return Err(format!("empty {kind} name in `states(…)` modifier"));
}
if !is_valid_ident(token) || is_rust_keyword(token) {
return Err(format!(
"'{token}' is not a valid {kind} name; expected a plain snake_case identifier such \
as `draft` or `can_publish`"
));
}
Ok(())
}
fn min_max_args(min: Option<&String>, max: Option<&String>, float: bool) -> String {
let fmt = |raw: &str| -> String {
if float && !raw.contains(['.', 'e', 'E']) {
format!("{raw}.0")
} else {
raw.to_owned()
}
};
let mut args = Vec::with_capacity(2);
if let Some(min) = min {
args.push(format!("min = {}", fmt(min)));
}
if let Some(max) = max {
args.push(format!("max = {}", fmt(max)));
}
args.join(", ")
}
fn canonical_float_literal(reparsed: &str) -> String {
if reparsed.contains(['.', 'e', 'E']) {
reparsed.to_owned()
} else {
format!("{reparsed}.0")
}
}
fn parse_field_constraints(body: &str, kind: FieldKind) -> Result<FieldConstraints, String> {
let mut c = FieldConstraints::default();
if body.is_empty() {
return Err(
"empty constraint block `{}` — remove the braces or add a constraint \
(e.g. `{min=3,max=120}`, `{email}`, `{label:title}`)"
.to_owned(),
);
}
let length_bounded = matches!(
kind,
FieldKind::String | FieldKind::Text | FieldKind::RichText
);
let string_like = matches!(kind, FieldKind::String | FieldKind::Text);
let numeric = matches!(
kind,
FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64
);
let is_reference = kind.is_reference();
let is_slug = kind.is_slug();
for raw in body.split(',') {
let tok = raw.trim();
if tok.is_empty() {
return Err("empty constraint (stray comma) in the `{…}` block".to_owned());
}
if let Some((key, value)) = tok.split_once('=') {
parse_constraint_kv(&mut c, key.trim(), value.trim(), kind)?;
} else if let Some((key, value)) = tok.split_once(':') {
match key.trim() {
"label" => set_label_constraint(&mut c, value.trim(), is_reference)?,
"from" => set_from_constraint(&mut c, value.trim(), is_slug)?,
"scope" => set_scope_constraint(&mut c, value.trim(), kind.is_position())?,
"encrypted" => {
reject_encrypted_on_kind(kind)?;
reject_repeated_encrypted(&c)?;
c.encrypted = Some(EncryptedMode::parse(value.trim())?);
}
other => return Err(unknown_constraint_message(other, kind)),
}
} else {
match tok {
"email" | "url" => {
if !string_like {
return Err(format!(
"the `{tok}` constraint only applies to String/Text fields"
));
}
if tok == "email" {
c.email = true;
} else {
c.url = true;
}
}
"encrypted" => {
reject_encrypted_on_kind(kind)?;
reject_repeated_encrypted(&c)?;
c.encrypted = Some(EncryptedMode::Randomized);
}
"translatable" => {
reject_translatable_on_kind(kind)?;
if c.translatable {
return Err(
"the `translatable` modifier is declared more than once — remove \
the duplicate"
.to_owned(),
);
}
c.translatable = true;
}
_ => return Err(unknown_constraint_message(tok, kind)),
}
}
}
if c.email && c.url {
return Err(
"the `email` and `url` constraints are mutually exclusive — a value can't satisfy \
both; keep only one"
.to_owned(),
);
}
if (c.min.is_some() || c.max.is_some()) && !length_bounded && !numeric {
return Err(format!(
"min/max constraints are not supported for {} fields",
kind.rust_type()
));
}
if let (Some(min), Some(max)) = (&c.min, &c.max) {
let inverted = match kind {
FieldKind::String | FieldKind::Text | FieldKind::RichText => {
matches!((min.parse::<u64>(), max.parse::<u64>()), (Ok(lo), Ok(hi)) if lo > hi)
}
FieldKind::I32 | FieldKind::I64 => {
matches!((min.parse::<i64>(), max.parse::<i64>()), (Ok(lo), Ok(hi)) if lo > hi)
}
_ => matches!((min.parse::<f64>(), max.parse::<f64>()), (Ok(lo), Ok(hi)) if lo > hi),
};
if inverted {
return Err(format!("min ({min}) cannot be greater than max ({max})"));
}
}
Ok(c)
}
fn parse_constraint_kv(
c: &mut FieldConstraints,
key: &str,
value: &str,
kind: FieldKind,
) -> Result<(), String> {
match key {
"min" | "max" => {
let bound = parse_bound(value, kind)?;
if key == "min" {
c.min = Some(bound);
} else {
c.max = Some(bound);
}
Ok(())
}
"label" => set_label_constraint(c, value, kind.is_reference()),
"from" => set_from_constraint(c, value, kind.is_slug()),
_ => Err(unknown_constraint_message(key, kind)),
}
}
fn parse_bound(value: &str, kind: FieldKind) -> Result<String, String> {
match kind {
FieldKind::String | FieldKind::Text | FieldKind::RichText => {
let n = value
.parse::<u64>()
.map_err(|_| format!("length bound '{value}' must be a non-negative integer"))?;
if n > u64::from(u32::MAX) {
return Err(format!(
"length bound '{value}' must be at most {} (HTML length attributes fit a u32)",
u32::MAX
));
}
Ok(n.to_string())
}
FieldKind::I32 => {
let n = value.parse::<i32>().map_err(|_| {
format!(
"range bound '{value}' must be an integer within the i32 range ({}..={})",
i32::MIN,
i32::MAX
)
})?;
Ok(n.to_string())
}
FieldKind::I64 => {
let n = value.parse::<i64>().map_err(|_| {
format!(
"range bound '{value}' must be an integer within the i64 range ({}..={})",
i64::MIN,
i64::MAX
)
})?;
Ok(n.to_string())
}
FieldKind::F32 => {
let parsed = value
.parse::<f32>()
.map_err(|_| format!("range bound '{value}' must be a number"))?;
if !parsed.is_finite() {
return Err(format!(
"range bound '{value}' is out of range for an f32 field (it overflows to a non-finite value)"
));
}
Ok(canonical_float_literal(&parsed.to_string()))
}
FieldKind::F64 => {
let parsed = value
.parse::<f64>()
.map_err(|_| format!("range bound '{value}' must be a number"))?;
if !parsed.is_finite() {
return Err(format!(
"range bound '{value}' is out of range for an f64 field (it overflows to a non-finite value)"
));
}
Ok(canonical_float_literal(&parsed.to_string()))
}
_ => Err(format!(
"min/max constraints are not supported for {} fields",
kind.rust_type()
)),
}
}
fn set_label_constraint(
c: &mut FieldConstraints,
value: &str,
is_reference: bool,
) -> Result<(), String> {
if !is_reference {
return Err("the `label` constraint only applies to `references` fields".to_owned());
}
if !is_valid_ident(value) {
return Err(format!(
"reference label column '{value}' is not a valid snake_case identifier"
));
}
c.label = Some(value.to_owned());
Ok(())
}
fn set_from_constraint(c: &mut FieldConstraints, value: &str, is_slug: bool) -> Result<(), String> {
if !is_slug {
return Err("the `from` constraint only applies to `slug` fields".to_owned());
}
if !is_valid_ident(value) {
return Err(format!(
"slug source field '{value}' is not a valid snake_case identifier"
));
}
c.from = Some(value.to_owned());
Ok(())
}
fn set_scope_constraint(
c: &mut FieldConstraints,
value: &str,
is_position: bool,
) -> Result<(), String> {
if !is_position {
return Err("the `scope` constraint only applies to `position` fields".to_owned());
}
if !is_valid_ident(value) {
return Err(format!(
"position scope column '{value}' is not a valid snake_case identifier"
));
}
c.scope = Some(value.to_owned());
Ok(())
}
fn reject_repeated_encrypted(c: &FieldConstraints) -> Result<(), String> {
if c.encrypted.is_some() {
return Err(
"the `encrypted` modifier is declared more than once — the modes differ in what \
they leak, so the mode must be unambiguous. Keep exactly one of `encrypted` \
(randomized) or `encrypted:deterministic`."
.to_owned(),
);
}
Ok(())
}
fn reject_encrypted_on_kind(kind: FieldKind) -> Result<(), String> {
if matches!(kind, FieldKind::String | FieldKind::Text) {
return Ok(());
}
Err(format!(
"the `encrypted` modifier only applies to `String`/`Text` fields; \
`#[encrypted]` supports non-null `String` columns in v1 (encrypt structured \
data before storing it). Got a `{}` field.",
kind.dsl_token()
))
}
fn reject_translatable_on_kind(kind: FieldKind) -> Result<(), String> {
if matches!(kind, FieldKind::String | FieldKind::Text) {
return Ok(());
}
Err(format!(
"the `translatable` modifier only applies to `String`/`Text` fields; it stores a \
per-locale JSON container in the column, which needs free-form text. Got a `{}` \
field.",
kind.dsl_token()
))
}
fn unknown_constraint_message(token: &str, kind: FieldKind) -> String {
let accepted = match kind {
FieldKind::String | FieldKind::Text => {
"min=N, max=N, email, url, encrypted, encrypted:deterministic, translatable"
}
FieldKind::RichText | FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64 => {
"min=N, max=N"
}
FieldKind::References => "label:col",
FieldKind::Slug => "from:col",
FieldKind::Position => "scope:col",
_ => "(none — this field type takes no constraint modifiers)",
};
format!(
"unknown constraint '{token}' for `{}` fields; supported: {accepted}",
kind.dsl_token()
)
}
fn parse_enum_type(ty: &str) -> Result<Option<(Vec<String>, bool)>, String> {
let (body, nullable) =
strip_wrapper(ty, "Option").map_or((ty, false), |inner| (inner.trim(), true));
let Some(rest) = body.strip_prefix("enum") else {
return Ok(None);
};
let rest = rest.trim_start();
let Some(inner) = rest.strip_prefix('{') else {
return Err(format!(
"expected enum{{variant1,variant2,…}}. If you typed this in bash or zsh, \
quote the token so the shell doesn't brace-expand it, \
e.g. 'status:enum{{draft,published}}'. Supported: {SUPPORTED_TYPES}"
));
};
let Some(body_inner) = inner.strip_suffix('}') else {
return Err("expected enum{variant1,variant2,…} (missing closing brace)".to_owned());
};
let mut variants: Vec<String> = Vec::new();
let mut seen_pascal = std::collections::HashSet::new();
for raw in body_inner.split(',') {
let variant = raw.trim();
if variant.is_empty() {
return Err("enum variants cannot be empty".to_owned());
}
if !is_valid_ident(variant) {
return Err(format!("'{variant}' is not a valid snake_case identifier"));
}
if is_rust_keyword(variant) {
return Err(format!(
"'{variant}' is a Rust keyword and cannot be used as an enum variant"
));
}
let pascal = naming::pascal(variant);
if pascal.is_empty() || pascal.starts_with(|c: char| c.is_ascii_digit()) {
return Err(format!(
"'{variant}' does not produce a valid Rust identifier once converted to \
PascalCase ('{pascal}'); rename the variant"
));
}
if !seen_pascal.insert(pascal.clone()) {
return Err(format!(
"duplicate enum variant '{pascal}' (variants must be distinct once converted to PascalCase)"
));
}
variants.push(variant.to_owned());
}
if variants.len() < 2 {
return Err(
"an enum needs at least two variants; use String for a free-form field".to_owned(),
);
}
Ok(Some((variants, nullable)))
}
pub const MAX_DECIMAL_PRECISION: u32 = 28;
fn parse_decimal_type(ty: &str) -> Result<Option<(u32, u32, bool)>, String> {
let (body, nullable) =
strip_wrapper(ty, "Option").map_or((ty, false), |inner| (inner.trim(), true));
let body = body.trim();
if matches!(body, "money" | "Money") {
return Ok(Some((12, 2, nullable)));
}
if body.starts_with("money{") || body.starts_with("Money{") {
return Err(format!(
"`money` takes no modifier; use decimal{{precision,scale}} for another \
precision or scale, e.g. 'price:decimal{{19,4}}'. Supported: {SUPPORTED_TYPES}"
));
}
let Some(rest) = body
.strip_prefix("decimal")
.or_else(|| body.strip_prefix("Decimal"))
else {
return Ok(None);
};
let rest = rest.trim_start();
if rest.is_empty() {
return Ok(Some((12, 2, nullable)));
}
let Some(inner) = rest.strip_prefix('{') else {
return Err(format!(
"expected decimal{{precision,scale}} (or bare `decimal` for the \
default NUMERIC(12,2)). If you typed this in bash or zsh, quote \
the token so the shell doesn't brace-expand it, e.g. \
'price:decimal{{10,2}}'. Supported: {SUPPORTED_TYPES}"
));
};
let Some(body_inner) = inner.strip_suffix('}') else {
return Err("expected decimal{precision,scale} (missing closing brace)".to_owned());
};
let parts: Vec<&str> = body_inner.split(',').map(str::trim).collect();
let [precision_str, scale_str] = parts.as_slice() else {
return Err(format!(
"expected decimal{{precision,scale}} (exactly two comma-separated numbers), \
got 'decimal{{{body_inner}}}'"
));
};
let precision: u32 = precision_str.parse().map_err(|_| {
format!(
"'{precision_str}' is not a valid decimal precision \
(expected a positive integer)"
)
})?;
let scale: u32 = scale_str.parse().map_err(|_| {
format!("'{scale_str}' is not a valid decimal scale (expected a non-negative integer)")
})?;
if precision == 0 || precision > MAX_DECIMAL_PRECISION {
return Err(format!(
"decimal precision must be between 1 and {MAX_DECIMAL_PRECISION} \
(rust_decimal's representable range), got {precision}"
));
}
if scale > precision {
return Err(format!(
"decimal scale ({scale}) cannot be greater than precision ({precision})"
));
}
Ok(Some((precision, scale, nullable)))
}
pub fn parse_fields(tokens: &[String]) -> Result<Vec<Field>, GenerateError> {
let mut fields: Vec<Field> = Vec::with_capacity(tokens.len());
for token in tokens {
let field = parse_field(token)?;
if let Some(prev) = fields.iter().find(|f| f.name == field.name) {
return Err(GenerateError::InvalidField {
token: token.clone(),
reason: format!(
"duplicate field name '{name}' (previously declared as '{name}:{prev_ty}')",
name = field.name,
prev_ty = prev.rust_type()
),
});
}
fields.push(field);
}
validate_slug_fields(tokens, &fields)?;
validate_position_fields(tokens, &fields)?;
validate_commentable_fields(tokens, &fields)?;
Ok(fields)
}
fn validate_position_fields(tokens: &[String], fields: &[Field]) -> Result<(), GenerateError> {
let position_fields: Vec<(usize, &Field)> = fields
.iter()
.enumerate()
.filter(|(_, f)| f.kind.is_position())
.collect();
if let [(_, first), (second_idx, second), ..] = position_fields[..] {
return Err(GenerateError::InvalidField {
token: tokens[second_idx].clone(),
reason: format!(
"only one `position` field is supported per model — found both '{}' and '{}'",
first.name, second.name
),
});
}
for (idx, position_field) in &position_fields {
let Some(scope) = position_field.constraints.scope.as_deref() else {
continue;
};
let token = tokens[*idx].clone();
let Some(source) = fields.iter().find(|f| f.name == scope) else {
return Err(GenerateError::InvalidField {
token,
reason: format!(
"position field '{}' is scoped `scope:{scope}`, but no field named \
'{scope}' is declared",
position_field.name
),
});
};
if !source.kind.is_reference() {
return Err(GenerateError::InvalidField {
token,
reason: format!(
"position field '{}' is scoped `scope:{scope}`, but '{scope}' is a {} \
field — position can only be scoped to a `references` foreign key",
position_field.name,
source.rust_type()
),
});
}
if source.nullable {
return Err(GenerateError::InvalidField {
token,
reason: format!(
"position field '{}' is scoped `scope:{scope}`, but '{scope}' is nullable \
(`Option<…>`) — a NULL scope value can't be locked or matched by the \
generated triggers/`move_to`. Make '{scope}' non-nullable, or drop the \
`references` field's `:null`.",
position_field.name
),
});
}
}
Ok(())
}
fn validate_commentable_fields(tokens: &[String], fields: &[Field]) -> Result<(), GenerateError> {
let commentable: Vec<(usize, &Field)> = fields
.iter()
.enumerate()
.filter(|(_, f)| f.kind.is_commentable())
.collect();
if let [(_, first), (second_idx, second), ..] = commentable[..] {
return Err(GenerateError::InvalidField {
token: tokens[second_idx].clone(),
reason: format!(
"only one `commentable` field is supported per model — found both '{}' and \
'{}'. One shared comments table already attaches to every model; a second \
token would add a second counter column and a second `#[commentable]` \
attribute, which `#[model]` rejects.",
first.name, second.name
),
});
}
Ok(())
}
fn validate_slug_fields(tokens: &[String], fields: &[Field]) -> Result<(), GenerateError> {
let slug_fields: Vec<(usize, &Field)> = fields
.iter()
.enumerate()
.filter(|(_, f)| f.kind.is_slug())
.collect();
if let [(_, first), (second_idx, second), ..] = slug_fields[..] {
return Err(GenerateError::InvalidField {
token: tokens[second_idx].clone(),
reason: format!(
"only one `slug` field is supported per model (it's the routing key) — \
found both '{}' and '{}'",
first.name, second.name
),
});
}
for (idx, slug_field) in &slug_fields {
let from = slug_field
.constraints
.from
.as_deref()
.expect("parse_field rejects a slug field with no `from` constraint");
let token = tokens[*idx].clone();
let Some(source) = fields.iter().find(|f| f.name == from) else {
return Err(GenerateError::InvalidField {
token,
reason: format!(
"slug field '{}' derives `from:{from}`, but no field named '{from}' is \
declared",
slug_field.name
),
});
};
if !matches!(
source.kind,
FieldKind::String | FieldKind::Text | FieldKind::RichText
) {
return Err(GenerateError::InvalidField {
token,
reason: format!(
"slug field '{}' derives `from:{from}`, but '{from}' is a {} field — \
slug can only derive from a String/Text/richtext field",
slug_field.name,
source.rust_type()
),
});
}
if source.is_encrypted() {
return Err(GenerateError::InvalidField {
token,
reason: format!(
"slug field '{}' derives `from:{from}`, but '{from}' is `{{encrypted}}`: \
the slug is stored in its own PLAINTEXT column and used as the record's \
URL, so deriving it from an encrypted column would write the secret to \
disk in the clear and put it in every request path. Derive the slug from \
a non-encrypted field instead.",
slug_field.name
),
});
}
if source.nullable {
return Err(GenerateError::InvalidField {
token,
reason: format!(
"slug field '{}' derives `from:{from}`, but '{from}' is nullable \
(`Option<…>`) — slug can only derive from a non-nullable field",
slug_field.name
),
});
}
}
Ok(())
}
fn parse_type(ty: &str) -> Option<(FieldKind, bool)> {
if matches!(
ty.strip_suffix('?').map(str::trim),
Some("references" | "References")
) {
return Some((FieldKind::References, true));
}
if let Some(inner) = strip_wrapper(ty, "Option") {
let kind = atomic_type(inner.trim())?;
Some((kind, true))
} else {
atomic_type(ty).map(|k| {
let nullable = matches!(k, FieldKind::Attachment);
(k, nullable)
})
}
}
fn atomic_type(ty: &str) -> Option<FieldKind> {
match ty {
"String" => Some(FieldKind::String),
"Text" => Some(FieldKind::Text),
"i32" => Some(FieldKind::I32),
"i64" => Some(FieldKind::I64),
"bool" => Some(FieldKind::Bool),
"f32" => Some(FieldKind::F32),
"f64" => Some(FieldKind::F64),
"Uuid" => Some(FieldKind::Uuid),
"NaiveDateTime" => Some(FieldKind::NaiveDateTime),
"DateTime" => Some(FieldKind::DateTime),
"time" | "Time" | "NaiveTime" => Some(FieldKind::Time),
"Bytea" => Some(FieldKind::Bytea),
"Attachment" | "attachment" => Some(FieldKind::Attachment),
"References" | "references" => Some(FieldKind::References),
"richtext" | "RichText" | "rich_text" => Some(FieldKind::RichText),
"slug" | "Slug" => Some(FieldKind::Slug),
"json" | "Json" | "jsonb" | "Jsonb" => Some(FieldKind::Json),
"position" | "Position" => Some(FieldKind::Position),
"commentable" | "Commentable" => Some(FieldKind::Commentable),
_ => {
strip_wrapper(ty, "Vec").and_then(|inner| {
if inner.trim() == "u8" {
Some(FieldKind::Bytea)
} else {
None
}
})
}
}
}
fn parse_string_widget(ty: &str) -> Option<(StringWidget, bool)> {
let (inner, nullable) = strip_wrapper(ty, "Option").map_or((ty, false), |t| (t.trim(), true));
let widget = match inner {
"phone" | "Phone" | "tel" | "Tel" => StringWidget::Tel,
"color" | "Color" => StringWidget::Color,
_ => return None,
};
Some((widget, nullable))
}
fn strip_wrapper<'a>(ty: &'a str, wrapper: &str) -> Option<&'a str> {
let prefix = format!("{wrapper}<");
let stripped = ty.strip_prefix(&prefix)?;
stripped.strip_suffix('>')
}
pub(super) fn is_valid_ident(s: &str) -> bool {
if s == "_" {
return false;
}
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_lowercase() || first == '_') {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub(super) const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "do", "dyn", "else", "enum",
"extern", "false", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "match", "mod",
"move", "mut", "pub", "ref", "return", "self", "static", "struct", "super", "trait", "true",
"try", "type", "unsafe", "use", "where", "while", "yield", "abstract", "become", "box",
"final", "macro", "override", "priv", "typeof", "unsized", "virtual",
];
pub(super) fn is_rust_keyword(s: &str) -> bool {
RUST_KEYWORDS.contains(&s)
}
#[cfg(test)]
#[allow(clippy::literal_string_with_formatting_args)]
mod tests {
use super::*;
#[test]
fn translatable_modifier_parses_on_string_and_text() {
for token in ["title:String{translatable}", "body:Text{translatable}"] {
let f = parse_field(token).unwrap_or_else(|e| panic!("{token}: {e}"));
assert!(f.is_translatable(), "{token}");
assert_eq!(f.rust_type(), "autumn_web::i18n::Translated");
assert_eq!(f.sql_column_type(), "TEXT");
assert_eq!(f.schema_type(), "Text");
assert_eq!(f.sql_nullability(), "NOT NULL");
assert_eq!(f.sql_default(), Some("'{}'"));
}
}
#[test]
fn translatable_modifier_is_rejected_on_non_text_kinds() {
for token in [
"count:i64{translatable}",
"at:NaiveDateTime{translatable}",
"post:references{translatable}",
] {
let err = parse_field(token).unwrap_err().to_string();
assert!(
err.contains("translatable"),
"{token} should name the modifier: {err}"
);
}
}
#[test]
fn translatable_is_rejected_on_a_nullable_column() {
let err = parse_field("title:Option<String>{translatable}")
.unwrap_err()
.to_string();
assert!(err.contains("translatable"), "{err}");
}
#[test]
fn translatable_and_encrypted_are_mutually_exclusive() {
let err = parse_field("title:String{translatable,encrypted}")
.unwrap_err()
.to_string();
assert!(err.contains("encrypted"), "{err}");
}
#[test]
fn translatable_is_rejected_with_unique() {
let err = parse_field("title:String:unique{translatable}")
.unwrap_err()
.to_string();
assert!(err.contains("translatable"), "{err}");
}
#[test]
fn translatable_is_rejected_with_a_state_machine() {
let err = parse_field("status:String{translatable}:states(draft -> live)")
.unwrap_err()
.to_string();
assert!(err.contains("translatable"), "{err}");
}
#[test]
fn a_repeated_translatable_modifier_is_rejected() {
let err = parse_field("title:String{translatable,translatable}")
.unwrap_err()
.to_string();
assert!(err.contains("translatable"), "{err}");
}
#[test]
fn non_translatable_fields_keep_their_type_and_have_no_default() {
let f = parse_field("title:String").unwrap();
assert!(!f.is_translatable());
assert_eq!(f.rust_type(), "String");
assert_eq!(f.sql_default(), None);
}
#[test]
fn unknown_constraint_message_advertises_translatable() {
let err = parse_field("title:String{bogus}").unwrap_err().to_string();
assert!(err.contains("translatable"), "{err}");
}
#[test]
fn parse_string_field() {
let f = parse_field("title:String").unwrap();
assert_eq!(f.name, "title");
assert_eq!(f.kind, FieldKind::String);
assert!(!f.nullable);
assert_eq!(f.rust_type(), "String");
assert_eq!(f.sql_type(), "TEXT");
assert_eq!(f.schema_type(), "Text");
}
#[test]
fn parse_text_alias() {
let f = parse_field("body:Text").unwrap();
assert_eq!(f.kind, FieldKind::Text);
assert_eq!(f.rust_type(), "String");
assert_eq!(f.sql_type(), "TEXT");
}
#[test]
fn parse_optional_field() {
let f = parse_field("description:Option<String>").unwrap();
assert_eq!(f.kind, FieldKind::String);
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<String>");
assert_eq!(f.sql_nullability(), "NULL");
assert_eq!(f.schema_type(), "Nullable<Text>");
}
#[test]
fn parse_bytea_via_vec() {
let f = parse_field("data:Vec<u8>").unwrap();
assert_eq!(f.kind, FieldKind::Bytea);
assert_eq!(f.rust_type(), "Vec<u8>");
assert_eq!(f.sql_type(), "BYTEA");
}
#[test]
fn parse_bytea_alias() {
let f = parse_field("data:Bytea").unwrap();
assert_eq!(f.kind, FieldKind::Bytea);
}
#[test]
fn parse_uuid() {
let f = parse_field("token:Uuid").unwrap();
assert_eq!(f.rust_type(), "uuid::Uuid");
assert_eq!(f.sql_type(), "UUID");
}
#[test]
fn parse_datetime() {
let f = parse_field("created_at:DateTime").unwrap();
assert_eq!(f.rust_type(), "chrono::DateTime<chrono::Utc>");
assert_eq!(f.schema_type(), "Timestamptz");
}
#[test]
fn sqlite_sql_type_covers_every_kind() {
let cases = [
("title:String", "TEXT"),
("body:Text", "TEXT"),
("count:i32", "INTEGER"),
("big:i64", "INTEGER"),
("flag:bool", "INTEGER"),
("ratio:f32", "REAL"),
("amount:f64", "REAL"),
("token:Uuid", "TEXT"),
("naive:NaiveDateTime", "TEXT"),
("at:DateTime", "TEXT"),
("data:Bytea", "BLOB"),
("cover:Attachment", "TEXT"),
("post:references", "INTEGER"),
];
for (token, expected) in cases {
let f = parse_field(token).unwrap();
assert_eq!(
f.sql_column_type_for(DatabaseBackend::Sqlite),
expected,
"SQLite column type for `{token}`"
);
assert_eq!(
f.sql_column_type_for(DatabaseBackend::Postgres),
f.sql_column_type(),
"Postgres column type for `{token}` must be unchanged"
);
}
}
#[test]
fn sqlite_enum_and_decimal_map_to_text() {
let e = parse_field("status:enum{draft,published}").unwrap();
assert_eq!(e.sql_column_type_for(DatabaseBackend::Sqlite), "TEXT");
assert_eq!(e.sql_column_type_for(DatabaseBackend::Postgres), "TEXT");
let d = parse_field("price:decimal{10,2}").unwrap();
assert_eq!(d.sql_column_type_for(DatabaseBackend::Sqlite), "TEXT");
assert_eq!(
d.sql_column_type_for(DatabaseBackend::Postgres),
"NUMERIC(10,2)"
);
}
#[test]
fn sqlite_schema_type_avoids_postgres_only_diesel_types() {
let cases = [
("at:DateTime", "TimestamptzSqlite"),
("naive:NaiveDateTime", "Timestamp"),
("token:Uuid", "Text"),
("cover:Attachment", "Nullable<Text>"),
("data:Bytea", "Binary"),
("price:decimal{10,2}", "Text"),
("big:i64", "Int8"),
("flag:bool", "Bool"),
];
for (token, expected) in cases {
let f = parse_field(token).unwrap();
assert_eq!(
f.schema_type_for(DatabaseBackend::Sqlite),
expected,
"SQLite schema type for `{token}`"
);
}
let opt = parse_field("token:Option<Uuid>").unwrap();
assert_eq!(
opt.schema_type_for(DatabaseBackend::Sqlite),
"Nullable<Text>"
);
for token in [
"at:DateTime",
"token:Uuid",
"cover:Attachment",
"data:Bytea",
] {
let ty = parse_field(token)
.unwrap()
.schema_type_for(DatabaseBackend::Sqlite);
assert!(
!["Timestamptz", "Jsonb", "Uuid", "Bytea", "Numeric"].contains(&ty.as_str()),
"`{token}` -> `{ty}` must not be a Postgres-only diesel type on SQLite"
);
}
}
#[test]
fn every_field_kind_has_a_sqlite_diesel_conversion() {
for token in [
"title:String",
"body:Text",
"rich:richtext",
"count:i32",
"big:i64",
"flag:bool",
"ratio:f32",
"amount:f64",
"naive:NaiveDateTime",
"at:DateTime",
"cover:Attachment",
"data:Bytea",
"post:references",
"payload:json",
"token:Uuid",
"price:decimal{10,2}",
"status:enum{draft,published}",
] {
assert!(
parse_field(token)
.unwrap()
.kind
.sqlite_has_diesel_conversion(),
"`{token}` must be a SQLite-supported kind"
);
}
}
#[test]
fn sqlite_rust_types_wrap_only_uuid_and_decimal() {
let cases = [
("token:Uuid", SQLITE_UUID_RUST_TYPE.to_owned()),
("price:decimal{10,2}", SQLITE_DECIMAL_RUST_TYPE.to_owned()),
(
"owner:Option<Uuid>",
format!("Option<{SQLITE_UUID_RUST_TYPE}>"),
),
("title:String", "String".to_owned()),
("at:DateTime", "chrono::DateTime<chrono::Utc>".to_owned()),
(
"cover:Attachment",
"Option<autumn_web::storage::Blob>".to_owned(),
),
];
for (token, expected) in cases {
let f = parse_field(token).unwrap();
assert_eq!(
f.rust_type_for(DatabaseBackend::Sqlite),
expected,
"SQLite Rust type for `{token}`"
);
assert_eq!(
f.rust_type_for(DatabaseBackend::Postgres),
f.rust_type(),
"Postgres Rust type for `{token}` must be unchanged"
);
}
let e = parse_field("status:enum{draft,published}").unwrap();
assert_eq!(e.rust_type_for(DatabaseBackend::Sqlite), e.rust_type());
}
#[test]
fn sqlite_primary_key_mapping() {
assert_eq!(
IdType::BigSerial.pk_sql_for(DatabaseBackend::Sqlite),
"INTEGER PRIMARY KEY AUTOINCREMENT"
);
assert_eq!(
IdType::Uuid.pk_sql_for(DatabaseBackend::Sqlite),
format!("TEXT PRIMARY KEY NOT NULL DEFAULT ({SQLITE_UUID_V4_SQL})")
);
assert_eq!(
IdType::Uuid.rust_type_for(DatabaseBackend::Sqlite),
SQLITE_UUID_RUST_TYPE
);
assert_eq!(
IdType::Uuid.rust_type_for(DatabaseBackend::Postgres),
"uuid::Uuid"
);
assert_eq!(
IdType::BigSerial.rust_type_for(DatabaseBackend::Sqlite),
"i64"
);
assert_eq!(
IdType::BigSerial.pk_sql_for(DatabaseBackend::Postgres),
IdType::BigSerial.pk_sql()
);
assert_eq!(
IdType::Uuid.pk_sql_for(DatabaseBackend::Postgres),
IdType::Uuid.pk_sql()
);
assert!(
IdType::Uuid
.migration_comment_for(DatabaseBackend::Postgres)
.is_some()
);
assert!(
IdType::Uuid
.migration_comment_for(DatabaseBackend::Sqlite)
.is_none()
);
assert_eq!(
IdType::BigSerial.schema_type_for(DatabaseBackend::Sqlite),
"Int8"
);
assert_eq!(
IdType::Uuid.schema_type_for(DatabaseBackend::Sqlite),
"Text"
);
}
#[test]
fn parse_naive_datetime() {
let f = parse_field("created_at:NaiveDateTime").unwrap();
assert_eq!(f.rust_type(), "chrono::NaiveDateTime");
assert_eq!(f.schema_type(), "Timestamp");
}
#[test]
fn parse_all_numeric_types() {
assert_eq!(parse_field("a:i32").unwrap().sql_type(), "INTEGER");
assert_eq!(parse_field("b:i64").unwrap().sql_type(), "BIGINT");
assert_eq!(parse_field("c:f32").unwrap().sql_type(), "REAL");
assert_eq!(parse_field("d:f64").unwrap().sql_type(), "DOUBLE PRECISION");
assert_eq!(parse_field("e:bool").unwrap().sql_type(), "BOOLEAN");
}
#[test]
fn unknown_type_rejected() {
let err = parse_field("price:Currency").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Currency"));
assert!(msg.contains("Supported:"));
}
#[test]
fn missing_colon_rejected() {
let err = parse_field("title").unwrap_err();
assert!(err.to_string().contains("missing colon"));
}
#[test]
fn empty_name_rejected() {
let err = parse_field(":String").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn pascal_case_name_rejected() {
let err = parse_field("Title:String").unwrap_err();
assert!(err.to_string().contains("snake_case"));
}
#[test]
fn rust_keyword_field_name_rejected() {
let err = parse_field("type:String").unwrap_err();
assert!(err.to_string().contains("Rust keyword"));
}
#[test]
fn other_keywords_also_rejected() {
for kw in ["fn", "match", "struct", "self", "impl", "ref", "move"] {
let token = format!("{kw}:String");
assert!(
parse_field(&token).is_err(),
"expected '{kw}' to be rejected"
);
}
}
#[test]
fn nested_option_is_unsupported() {
let err = parse_field("x:Option<Option<String>>").unwrap_err();
assert!(err.to_string().contains("unsupported type"));
}
#[test]
fn vec_of_other_types_rejected() {
let err = parse_field("xs:Vec<i32>").unwrap_err();
assert!(err.to_string().contains("unsupported type"));
}
#[test]
fn parse_multiple_fields() {
let tokens = vec!["title:String".into(), "count:i64".into()];
let fs = parse_fields(&tokens).unwrap();
assert_eq!(fs.len(), 2);
assert_eq!(fs[0].name, "title");
assert_eq!(fs[1].name, "count");
}
#[test]
fn duplicate_field_names_rejected() {
let tokens = vec!["title:String".into(), "title:Text".into()];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("duplicate"),
"expected duplicate error, got: {msg}"
);
assert!(msg.contains("title"));
}
#[test]
fn whitespace_around_tokens_tolerated() {
let f = parse_field(" name : String ").unwrap();
assert_eq!(f.name, "name");
assert_eq!(f.kind, FieldKind::String);
}
#[test]
fn parse_fields_accepts_slug_deriving_from_earlier_string_field() {
let tokens = vec!["title:String".into(), "slug:slug{from:title}".into()];
let fs = parse_fields(&tokens).unwrap();
assert_eq!(fs[1].constraints.from.as_deref(), Some("title"));
}
#[test]
fn parse_fields_accepts_slug_deriving_from_later_string_field() {
let tokens = vec!["slug:slug{from:title}".into(), "title:String".into()];
assert!(parse_fields(&tokens).is_ok());
}
#[test]
fn parse_fields_rejects_slug_from_unknown_field() {
let tokens = vec!["slug:slug{from:headline}".into()];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("headline"), "unexpected error: {msg}");
}
#[test]
fn parse_fields_rejects_slug_from_non_string_field() {
let tokens = vec!["count:i32".into(), "slug:slug{from:count}".into()];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("count"), "unexpected error: {msg}");
}
#[test]
fn parse_fields_rejects_slug_from_nullable_source_field() {
let tokens = vec![
"title:Option<String>".into(),
"slug:slug{from:title}".into(),
];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("title") && msg.contains("nullable"),
"unexpected error: {msg}"
);
}
#[test]
fn parse_fields_accepts_slug_deriving_from_text_field() {
let tokens = vec!["body:Text".into(), "slug:slug{from:body}".into()];
assert!(parse_fields(&tokens).is_ok());
}
#[test]
fn parse_fields_accepts_slug_deriving_from_richtext_field() {
let tokens = vec!["body:richtext".into(), "slug:slug{from:body}".into()];
assert!(parse_fields(&tokens).is_ok());
}
#[test]
fn parse_fields_rejects_more_than_one_slug_field() {
let tokens = vec![
"title:String".into(),
"slug:slug{from:title}".into(),
"slug2:slug{from:title}".into(),
];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("slug"), "unexpected error: {msg}");
}
#[test]
fn parse_fields_accepts_position_scoped_to_earlier_reference_field() {
let tokens = vec![
"board:references".into(),
"rank:position{scope:board_id}".into(),
];
let fs = parse_fields(&tokens).unwrap();
assert_eq!(fs[1].constraints.scope.as_deref(), Some("board_id"));
}
#[test]
fn parse_fields_accepts_position_scoped_to_later_reference_field() {
let tokens = vec![
"rank:position{scope:board_id}".into(),
"board:references".into(),
];
assert!(parse_fields(&tokens).is_ok());
}
#[test]
fn parse_fields_rejects_position_scope_unknown_field() {
let tokens = vec!["rank:position{scope:board_id}".into()];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("board_id"), "unexpected error: {msg}");
}
#[test]
fn parse_fields_rejects_position_scope_non_reference_field() {
let tokens = vec![
"board_id:i64".into(),
"rank:position{scope:board_id}".into(),
];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("board_id"), "unexpected error: {msg}");
}
#[test]
fn parse_fields_rejects_position_scope_nullable_reference_field() {
let tokens = vec![
"board:references?".into(),
"rank:position{scope:board_id}".into(),
];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("board_id"), "unexpected error: {msg}");
assert!(msg.contains("nullable"), "unexpected error: {msg}");
}
#[test]
fn parse_fields_rejects_more_than_one_position_field() {
let tokens = vec!["rank:position".into(), "rank2:position".into()];
let err = parse_fields(&tokens).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("position"), "unexpected error: {msg}");
}
#[test]
fn parse_fields_accepts_unscoped_position_field() {
let tokens = vec!["rank:position".into()];
let fs = parse_fields(&tokens).unwrap();
assert_eq!(fs[0].constraints.scope, None);
}
#[test]
fn parse_attachment_pascal() {
let f = parse_field("cover_image:Attachment").unwrap();
assert_eq!(f.kind, FieldKind::Attachment);
assert!(f.nullable, "attachment fields must default to nullable");
}
#[test]
fn parse_attachment_lowercase() {
let f = parse_field("cover_image:attachment").unwrap();
assert_eq!(f.kind, FieldKind::Attachment);
}
#[test]
fn parse_richtext_token() {
let f = parse_field("body:richtext").unwrap();
assert_eq!(f.kind, FieldKind::RichText);
assert!(!f.nullable);
assert_eq!(f.name, "body");
}
#[test]
fn parse_richtext_accepts_documented_spellings() {
for token in ["body:richtext", "body:RichText", "body:rich_text"] {
assert_eq!(
parse_field(token).unwrap().kind,
FieldKind::RichText,
"{token} should parse as RichText"
);
}
}
#[test]
fn richtext_stores_markdown_source_in_a_text_column() {
let f = parse_field("body:richtext").unwrap();
assert_eq!(f.rust_type(), "String");
assert_eq!(f.sql_type(), "TEXT");
assert_eq!(f.schema_type(), "Text");
assert_eq!(
f.sql_column_type_for(DatabaseBackend::Sqlite),
"TEXT",
"richtext must work on the SQLite backend too"
);
assert_eq!(f.schema_type_for(DatabaseBackend::Sqlite), "Text");
assert!(FieldKind::RichText.sqlite_has_diesel_conversion());
}
#[test]
fn optional_richtext_parses() {
let f = parse_field("body:Option<richtext>").unwrap();
assert_eq!(f.kind, FieldKind::RichText);
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<String>");
assert_eq!(f.schema_type(), "Nullable<Text>");
}
#[test]
fn richtext_is_rich_text_predicate() {
assert!(FieldKind::RichText.is_rich_text());
assert!(!FieldKind::Text.is_rich_text());
assert!(!FieldKind::String.is_rich_text());
}
#[test]
fn richtext_accepts_length_constraints() {
let f = parse_field("body:richtext{min=10,max=50000}").unwrap();
assert_eq!(f.validation_attrs(), vec!["length(min = 10, max = 50000)"]);
}
#[test]
fn richtext_rejects_format_constraints() {
for token in ["body:richtext{email}", "body:richtext{url}"] {
let err = parse_field(token).unwrap_err().to_string();
assert!(
err.contains("email") || err.contains("url"),
"{token} should be rejected, got: {err}"
);
}
}
#[test]
fn richtext_rejects_state_machine_modifier() {
let err = parse_field("body:richtext:states(draft -> live)")
.unwrap_err()
.to_string();
assert!(err.contains("state machine"), "{err}");
}
#[test]
fn richtext_supports_unique_modifier_like_other_text_columns() {
let f = parse_field("body:richtext:unique").unwrap();
assert_eq!(f.kind, FieldKind::RichText);
assert!(f.unique);
}
#[test]
fn richtext_appears_in_supported_types_constants() {
assert!(
SUPPORTED_TYPES.contains("richtext"),
"SUPPORTED_TYPES must list richtext"
);
assert!(
SQLITE_SUPPORTED_KINDS.contains("richtext"),
"SQLITE_SUPPORTED_KINDS must list richtext — it is a plain TEXT column"
);
}
#[test]
fn attachment_rust_type_is_blob() {
let f = parse_field("cover_image:Attachment").unwrap();
assert_eq!(f.rust_type(), "Option<autumn_web::storage::Blob>");
}
#[test]
fn attachment_sql_type_is_jsonb() {
let f = parse_field("cover_image:Attachment").unwrap();
assert_eq!(f.sql_type(), "JSONB");
}
#[test]
fn attachment_schema_type_is_jsonb() {
let f = parse_field("cover_image:Attachment").unwrap();
assert_eq!(f.schema_type(), "Nullable<Jsonb>");
}
#[test]
fn attachment_is_attachment_returns_true() {
assert!(FieldKind::Attachment.is_attachment());
assert!(!FieldKind::String.is_attachment());
assert!(!FieldKind::Uuid.is_attachment());
}
#[test]
fn optional_attachment_parses() {
let f = parse_field("avatar:Option<Attachment>").unwrap();
assert_eq!(f.kind, FieldKind::Attachment);
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<autumn_web::storage::Blob>");
}
#[test]
fn attachment_in_list_of_fields() {
let tokens = vec![
"title:String".into(),
"cover_image:Attachment".into(),
"count:i64".into(),
];
let fields = parse_fields(&tokens).unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[1].kind, FieldKind::Attachment);
}
#[test]
fn attachment_appears_in_supported_types_constant() {
assert!(
SUPPORTED_TYPES.contains("Attachment"),
"SUPPORTED_TYPES must list Attachment"
);
}
#[test]
fn parse_json_lowercase() {
let f = parse_field("config:json").unwrap();
assert_eq!(f.kind, FieldKind::Json);
assert_eq!(f.name, "config");
assert!(
!f.nullable,
"unlike Attachment, a json field is not implicitly nullable"
);
}
#[test]
fn parse_json_pascal_case() {
assert_eq!(parse_field("config:Json").unwrap().kind, FieldKind::Json);
}
#[test]
fn parse_jsonb_alias_lowercase() {
assert_eq!(parse_field("config:jsonb").unwrap().kind, FieldKind::Json);
}
#[test]
fn parse_jsonb_alias_pascal_case() {
assert_eq!(parse_field("config:Jsonb").unwrap().kind, FieldKind::Json);
}
#[test]
fn json_rust_type_is_serde_json_value() {
let f = parse_field("config:json").unwrap();
assert_eq!(f.rust_type(), "serde_json::Value");
}
#[test]
fn json_sql_type_is_jsonb() {
let f = parse_field("config:json").unwrap();
assert_eq!(f.sql_type(), "JSONB");
}
#[test]
fn json_schema_type_is_jsonb() {
let f = parse_field("config:json").unwrap();
assert_eq!(f.schema_type(), "Jsonb");
}
#[test]
fn json_sqlite_sql_type_is_text() {
let f = parse_field("config:json").unwrap();
assert_eq!(f.sql_column_type_for(DatabaseBackend::Sqlite), "TEXT");
}
#[test]
fn json_sqlite_schema_type_is_diesels_json_not_text_or_jsonb() {
let f = parse_field("config:json").unwrap();
assert_eq!(f.schema_type_for(DatabaseBackend::Sqlite), "Json");
}
#[test]
fn json_sqlite_has_diesel_conversion_is_true() {
assert!(FieldKind::Json.sqlite_has_diesel_conversion());
}
#[test]
fn optional_json_parses() {
let f = parse_field("config:Option<json>").unwrap();
assert_eq!(f.kind, FieldKind::Json);
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<serde_json::Value>");
assert_eq!(f.schema_type(), "Nullable<Jsonb>");
assert_eq!(f.schema_type_for(DatabaseBackend::Sqlite), "Nullable<Json>");
}
#[test]
fn optional_jsonb_alias_parses() {
let f = parse_field("config:Option<jsonb>").unwrap();
assert_eq!(f.kind, FieldKind::Json);
assert!(f.nullable);
}
#[test]
fn unique_json_field_parses() {
let f = parse_field("config:json:unique").unwrap();
assert_eq!(f.kind, FieldKind::Json);
assert!(f.unique);
}
#[test]
fn json_in_list_of_fields() {
let tokens = vec![
"title:String".into(),
"config:json".into(),
"count:i64".into(),
];
let fields = parse_fields(&tokens).unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[1].kind, FieldKind::Json);
}
#[test]
fn json_appears_in_supported_types_constant() {
assert!(
SUPPORTED_TYPES.contains("json"),
"SUPPORTED_TYPES must list json/jsonb"
);
}
#[test]
fn json_appears_in_sqlite_supported_kinds_constant() {
assert!(
SQLITE_SUPPORTED_KINDS.contains("json"),
"SQLITE_SUPPORTED_KINDS must list json — diesel supports it natively"
);
}
#[test]
fn json_dsl_token_is_json() {
assert_eq!(FieldKind::Json.dsl_token(), "json");
}
#[test]
fn time_kind_is_a_naive_time_column_on_both_backends() {
for token in ["opens:time", "opens:Time", "opens:NaiveTime"] {
let f = parse_field(token).unwrap();
assert_eq!(f.kind, FieldKind::Time, "{token}");
assert!(!f.nullable, "{token}");
}
let f = parse_field("opens:time").unwrap();
assert_eq!(f.rust_type(), "chrono::NaiveTime");
assert_eq!(f.schema_type(), "Time");
assert_eq!(f.sql_column_type_for(DatabaseBackend::Postgres), "TIME");
assert_eq!(f.sql_column_type_for(DatabaseBackend::Sqlite), "TEXT");
assert_eq!(f.schema_type_for(DatabaseBackend::Sqlite), "Time");
assert!(FieldKind::Time.sqlite_has_diesel_conversion());
assert_eq!(FieldKind::Time.dsl_token(), "time");
let f = parse_field("closes:Option<time>").unwrap();
assert_eq!(f.kind, FieldKind::Time);
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<chrono::NaiveTime>");
assert_eq!(sql_type_to_field_kind("time"), Some(FieldKind::Time));
}
#[test]
fn phone_and_color_are_string_columns_with_a_widget() {
for (token, widget) in [
("mobile:phone", StringWidget::Tel),
("mobile:Phone", StringWidget::Tel),
("mobile:tel", StringWidget::Tel),
("accent:color", StringWidget::Color),
("accent:Color", StringWidget::Color),
] {
let f = parse_field(token).unwrap();
assert_eq!(f.kind, FieldKind::String, "{token}");
assert_eq!(f.constraints.widget, Some(widget), "{token}");
assert_eq!(f.rust_type(), "String", "{token}");
assert_eq!(f.sql_column_type_for(DatabaseBackend::Postgres), "TEXT");
assert!(f.constraints.is_empty(), "{token}");
assert!(f.validation_attrs().is_empty(), "{token}");
}
let f = parse_field("fax:Option<phone>").unwrap();
assert!(f.nullable);
assert_eq!(f.constraints.widget, Some(StringWidget::Tel));
assert_eq!(f.rust_type(), "Option<String>");
let f = parse_field("mobile:phone:unique").unwrap();
assert!(f.unique);
let err = parse_field("accent:Option<color>").unwrap_err().to_string();
assert!(err.contains("Option<String>"), "{err}");
}
#[test]
fn phone_and_color_reject_modifier_blocks() {
for token in [
"mobile:phone{max=20}",
"accent:color{min=7}",
"mobile:phone:states(a -> b)",
] {
let err = parse_field(token).unwrap_err().to_string();
assert!(err.contains("takes no"), "{token}: {err}");
}
}
#[test]
fn money_is_a_decimal_12_2_alias() {
for token in ["price:money", "price:Money"] {
let f = parse_field(token).unwrap();
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 12,
scale: 2
},
"{token}"
);
assert_eq!(
f.sql_column_type_for(DatabaseBackend::Postgres),
"NUMERIC(12,2)"
);
}
let f = parse_field("tip:Option<money>").unwrap();
assert!(f.nullable);
let err = parse_field("price:money{10,2}").unwrap_err().to_string();
assert!(err.contains("decimal{precision,scale}"), "{err}");
let err = parse_field("price:moneys").unwrap_err().to_string();
assert!(err.contains("unsupported type 'moneys'"), "{err}");
}
#[test]
fn native_control_kinds_appear_in_supported_types() {
for kind in ["time", "phone", "tel", "color", "money"] {
assert!(SUPPORTED_TYPES.contains(kind), "{kind}");
assert!(SQLITE_SUPPORTED_KINDS.contains(kind), "{kind}");
}
assert!(SQL_SUPPORTED_TYPES.contains(", time,"));
}
#[test]
fn json_stays_unmapped_for_db_pull_inverse() {
assert!(sql_type_to_field_kind("jsonb").is_none());
assert!(sql_type_to_field_kind("json").is_none());
}
#[test]
fn sql_type_inverse_maps_all_supported_udt_names() {
let cases: &[(&str, FieldKind, &str, &str)] = &[
("text", FieldKind::String, "String", "Text"),
("varchar", FieldKind::String, "String", "Text"),
("bpchar", FieldKind::String, "String", "Text"),
("int4", FieldKind::I32, "i32", "Int4"),
("int8", FieldKind::I64, "i64", "Int8"),
("bool", FieldKind::Bool, "bool", "Bool"),
("float4", FieldKind::F32, "f32", "Float4"),
("float8", FieldKind::F64, "f64", "Float8"),
("uuid", FieldKind::Uuid, "uuid::Uuid", "Uuid"),
(
"timestamp",
FieldKind::NaiveDateTime,
"chrono::NaiveDateTime",
"Timestamp",
),
(
"timestamptz",
FieldKind::DateTime,
"chrono::DateTime<chrono::Utc>",
"Timestamptz",
),
("bytea", FieldKind::Bytea, "Vec<u8>", "Bytea"),
];
for (udt, kind, rust, schema) in cases {
let mapped = sql_type_to_field_kind(udt)
.unwrap_or_else(|| panic!("'{udt}' must map to a FieldKind"));
assert_eq!(mapped, *kind, "kind mismatch for {udt}");
assert_eq!(mapped.rust_type(), *rust, "rust_type mismatch for {udt}");
assert_eq!(
mapped.schema_type(),
*schema,
"schema_type mismatch for {udt}"
);
}
}
#[test]
fn sql_type_inverse_preserves_i64_for_int8() {
assert_eq!(sql_type_to_field_kind("int8"), Some(FieldKind::I64));
assert_eq!(sql_type_to_field_kind("int8").unwrap().rust_type(), "i64");
}
#[test]
fn sql_type_inverse_rejects_unknown_types() {
for udt in ["numeric", "jsonb", "json", "inet", "money", "point"] {
assert!(
sql_type_to_field_kind(udt).is_none(),
"'{udt}' is outside the documented surface and must not map"
);
}
}
#[test]
fn bare_underscore_is_not_a_valid_ident() {
assert!(!is_valid_ident("_"));
assert!(parse_field("_:String").is_err());
assert!(is_valid_ident("_internal"));
}
#[test]
fn sql_type_inverse_round_trips_forward_sql_types() {
for (kind, udt) in [
(FieldKind::String, "text"),
(FieldKind::I32, "int4"),
(FieldKind::I64, "int8"),
(FieldKind::Bool, "bool"),
(FieldKind::F32, "float4"),
(FieldKind::F64, "float8"),
(FieldKind::Uuid, "uuid"),
(FieldKind::NaiveDateTime, "timestamp"),
(FieldKind::DateTime, "timestamptz"),
(FieldKind::Bytea, "bytea"),
] {
let back = sql_type_to_field_kind(udt).unwrap();
assert_eq!(back.rust_type(), kind.rust_type());
assert_eq!(back.schema_type(), kind.schema_type());
}
}
#[test]
fn id_type_default_is_bigserial() {
assert_eq!(IdType::default(), IdType::BigSerial);
}
#[test]
fn id_type_bigserial_mappings() {
let id = IdType::BigSerial;
assert_eq!(id.rust_type(), "i64");
assert_eq!(id.schema_type(), "Int8");
assert_eq!(id.pk_sql(), "BIGSERIAL PRIMARY KEY");
assert!(id.migration_comment().is_none());
}
#[test]
fn id_type_uuid_mappings() {
let id = IdType::Uuid;
assert_eq!(id.rust_type(), "uuid::Uuid");
assert_eq!(id.schema_type(), "Uuid");
assert_eq!(id.pk_sql(), "UUID PRIMARY KEY DEFAULT gen_random_uuid()");
let comment = id
.migration_comment()
.expect("uuid should have a trade-off comment");
assert!(
comment.contains("UUIDv7"),
"comment should mention UUIDv7: {comment}"
);
}
#[test]
fn id_type_parse_accepts_uuid_case_insensitive() {
for token in ["uuid", "Uuid", "UUID"] {
assert_eq!(
IdType::parse(token).unwrap(),
IdType::Uuid,
"'{token}' should parse to Uuid"
);
}
}
#[test]
fn id_type_parse_accepts_bigint_aliases() {
for token in ["bigint", "bigserial", "i64", "BigInt"] {
assert_eq!(
IdType::parse(token).unwrap(),
IdType::BigSerial,
"'{token}' should parse to BigSerial"
);
}
}
#[test]
fn parse_references_resolves_column_name_to_id() {
let f = parse_field("post:references").unwrap();
assert_eq!(f.name, "post_id");
assert_eq!(f.kind, FieldKind::References);
assert!(!f.nullable);
}
#[test]
fn parse_references_pascal_case_also_accepted() {
let f = parse_field("post:References").unwrap();
assert_eq!(f.name, "post_id");
assert_eq!(f.kind, FieldKind::References);
}
#[test]
fn parse_references_does_not_double_suffix_already_named_column() {
let f = parse_field("post_id:references").unwrap();
assert_eq!(f.name, "post_id");
}
#[test]
fn parse_references_multi_word_base_name() {
let f = parse_field("blog_post:references").unwrap();
assert_eq!(f.name, "blog_post_id");
assert_eq!(f.reference_table().as_deref(), Some("blog_posts"));
}
#[test]
fn references_rust_type_is_i64() {
let f = parse_field("post:references").unwrap();
assert_eq!(f.rust_type(), "i64");
}
#[test]
fn references_sql_type_is_bigint() {
let f = parse_field("post:references").unwrap();
assert_eq!(f.sql_type(), "BIGINT");
assert_eq!(f.sql_nullability(), "NOT NULL");
}
#[test]
fn references_schema_type_is_int8() {
let f = parse_field("post:references").unwrap();
assert_eq!(f.schema_type(), "Int8");
}
#[test]
fn references_target_table_derived_via_pluralize() {
let f = parse_field("post:references").unwrap();
assert_eq!(f.reference_table().as_deref(), Some("posts"));
}
#[test]
fn references_nullable_form_with_question_mark() {
let f = parse_field("post:references?").unwrap();
assert_eq!(f.name, "post_id");
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<i64>");
assert_eq!(f.schema_type(), "Nullable<Int8>");
assert_eq!(f.sql_nullability(), "NULL");
}
#[test]
fn references_nullable_form_tolerates_internal_whitespace() {
let f = parse_field("post: references ?").unwrap();
assert_eq!(f.kind, FieldKind::References);
assert!(f.nullable);
}
#[test]
fn question_mark_suffix_is_not_a_general_nullability_marker() {
for token in [
"count:i64?",
"flag:bool?",
"title:String?",
"data:Vec<u8>?",
"id:Uuid?",
] {
let err = parse_field(token).unwrap_err();
assert!(
err.to_string().contains("unsupported type"),
"expected '{token}' to be rejected like before: {err}"
);
}
}
#[test]
fn references_is_reference_predicate() {
assert!(FieldKind::References.is_reference());
assert!(!FieldKind::I64.is_reference());
}
#[test]
fn non_reference_field_has_no_reference_table() {
let f = parse_field("title:String").unwrap();
assert_eq!(f.reference_table(), None);
}
#[test]
fn references_appears_in_supported_types_constant() {
assert!(
SUPPORTED_TYPES.contains("references"),
"SUPPORTED_TYPES must list references"
);
}
#[test]
fn parse_string_length_constraint() {
let f = parse_field("title:String{min=3,max=120}").unwrap();
assert_eq!(f.name, "title");
assert_eq!(f.kind, FieldKind::String);
assert_eq!(f.constraints.min.as_deref(), Some("3"));
assert_eq!(f.constraints.max.as_deref(), Some("120"));
assert_eq!(f.validation_attrs(), vec!["length(min = 3, max = 120)"]);
}
#[test]
fn parse_string_email_constraint() {
let f = parse_field("contact:String{email}").unwrap();
assert!(f.constraints.email);
assert_eq!(f.validation_attrs(), vec!["email"]);
}
#[test]
fn parse_string_url_constraint() {
let f = parse_field("homepage:String{url}").unwrap();
assert!(f.constraints.url);
assert_eq!(f.validation_attrs(), vec!["url"]);
}
#[test]
fn email_and_url_together_are_rejected() {
let err = parse_field("contact:String{email,url}").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("mutually exclusive"),
"must explain the conflict: {msg}"
);
assert!(msg.contains("contact"), "must name the field: {msg}");
}
#[test]
fn parse_numeric_range_constraint() {
let f = parse_field("age:i32{min=0,max=130}").unwrap();
assert_eq!(f.kind, FieldKind::I32);
assert_eq!(f.validation_attrs(), vec!["range(min = 0, max = 130)"]);
}
#[test]
fn i32_range_bound_within_range_is_accepted() {
let f = parse_field("count:i32{max=1000000}").unwrap();
assert_eq!(f.kind, FieldKind::I32);
assert_eq!(f.constraints.max.as_deref(), Some("1000000"));
assert_eq!(f.validation_attrs(), vec!["range(max = 1000000)"]);
}
#[test]
fn i32_range_bound_exceeding_i32_max_is_rejected() {
let err = parse_field("count:i32{max=3000000000}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("3000000000"), "must name the bad bound: {msg}");
assert!(msg.contains("i32"), "must name the field type: {msg}");
assert!(
msg.contains("2147483647"),
"must state the valid range: {msg}"
);
}
#[test]
fn i64_accepts_a_bound_that_would_overflow_i32() {
let f = parse_field("count:i64{max=3000000000}").unwrap();
assert_eq!(f.kind, FieldKind::I64);
assert_eq!(f.validation_attrs(), vec!["range(max = 3000000000)"]);
}
#[test]
fn f32_range_bound_overflowing_the_type_is_rejected() {
let err = parse_field("ratio:f32{max=1e40}").unwrap_err();
assert!(err.to_string().contains("f32"), "{}", err);
}
#[test]
fn i64_min_greater_than_max_beyond_f64_precision_is_rejected() {
let err = parse_field("count:i64{min=9007199254740993,max=9007199254740992}").unwrap_err();
assert!(
err.to_string().contains("cannot be greater than"),
"must report the inverted range: {err}"
);
}
#[test]
fn i64_valid_large_range_is_accepted() {
let f = parse_field("count:i64{min=1,max=9007199254740992}").unwrap();
assert_eq!(f.kind, FieldKind::I64);
assert_eq!(
f.validation_attrs(),
vec!["range(min = 1, max = 9007199254740992)"]
);
}
#[test]
fn float_leading_dot_bound_is_canonicalized_to_a_valid_literal() {
let f = parse_field("ratio:f64{min=.5,max=1}").unwrap();
assert_eq!(f.constraints.min.as_deref(), Some("0.5"));
assert_eq!(f.constraints.max.as_deref(), Some("1.0"));
assert_eq!(f.validation_attrs(), vec!["range(min = 0.5, max = 1.0)"]);
}
#[test]
fn integer_signed_and_zero_padded_bounds_are_canonicalized() {
let f = parse_field("count:i32{min=+1,max=007}").unwrap();
assert_eq!(f.constraints.min.as_deref(), Some("1"));
assert_eq!(f.constraints.max.as_deref(), Some("7"));
assert_eq!(f.validation_attrs(), vec!["range(min = 1, max = 7)"]);
}
#[test]
fn integer_bounds_within_range_are_emitted_verbatim() {
assert_eq!(
parse_field("age:i32{min=0,max=130}")
.unwrap()
.validation_attrs(),
vec!["range(min = 0, max = 130)"]
);
}
#[test]
fn parse_float_range_constraint_emits_decimal_literals() {
let f = parse_field("ratio:f64{min=0,max=1}").unwrap();
assert_eq!(f.validation_attrs(), vec!["range(min = 0.0, max = 1.0)"]);
}
#[test]
fn parse_min_only_constraint() {
let f = parse_field("title:String{min=3}").unwrap();
assert_eq!(f.constraints.min.as_deref(), Some("3"));
assert_eq!(f.constraints.max, None);
assert_eq!(f.validation_attrs(), vec!["length(min = 3)"]);
}
#[test]
fn parse_reference_label_constraint() {
let f = parse_field("post:references{label:title}").unwrap();
assert_eq!(f.name, "post_id");
assert_eq!(f.kind, FieldKind::References);
assert_eq!(f.constraints.label.as_deref(), Some("title"));
assert!(f.validation_attrs().is_empty());
}
#[test]
fn reference_label_does_not_break_unique_detection() {
let f = parse_field("post:references{label:title}").unwrap();
assert!(!f.unique);
}
#[test]
fn constraint_composes_with_unique_modifier() {
let f = parse_field("email:String{email}:unique").unwrap();
assert!(f.unique);
assert!(f.constraints.email);
}
#[test]
fn parse_slug_field_with_from() {
let f = parse_field("slug:slug{from:title}").unwrap();
assert_eq!(f.name, "slug");
assert_eq!(f.kind, FieldKind::Slug);
assert_eq!(f.constraints.from.as_deref(), Some("title"));
assert_eq!(f.rust_type(), "String");
assert_eq!(f.sql_type(), "TEXT");
}
#[test]
fn slug_field_is_implicitly_unique() {
let f = parse_field("slug:slug{from:title}").unwrap();
assert!(f.unique);
}
#[test]
fn slug_field_explicit_unique_modifier_is_harmless() {
let f = parse_field("slug:slug{from:title}:unique").unwrap();
assert!(f.unique);
assert_eq!(f.constraints.from.as_deref(), Some("title"));
}
#[test]
fn slug_field_is_never_nullable() {
let err = parse_field("slug:Option<slug>{from:title}").unwrap_err();
assert!(
err.to_string().contains("slug") && err.to_string().contains("nullable"),
"unexpected error: {err}"
);
}
#[test]
fn slug_field_requires_from_modifier() {
let err = parse_field("slug:slug").unwrap_err();
assert!(err.to_string().contains("from"), "unexpected error: {err}");
}
#[test]
fn slug_field_rejects_empty_constraint_block() {
let err = parse_field("slug:slug{}").unwrap_err();
assert!(err.to_string().contains("empty"), "unexpected error: {err}");
}
#[test]
fn from_constraint_only_applies_to_slug_fields() {
let err = parse_field("title:String{from:body}").unwrap_err();
assert!(err.to_string().contains("from"), "unexpected error: {err}");
}
#[test]
fn slug_field_rejects_non_ident_from_value() {
let err = parse_field("slug:slug{from:not a field}").unwrap_err();
assert!(err.to_string().contains("from"), "unexpected error: {err}");
}
#[test]
fn slug_field_rejects_min_max_constraints() {
let err = parse_field("slug:slug{from:title,min=3}").unwrap_err();
assert!(err.to_string().contains("min"), "unexpected error: {err}");
}
#[test]
fn bare_unique_modifier_still_parses() {
let f = parse_field("email:String:unique").unwrap();
assert!(f.unique);
assert!(f.constraints.is_empty());
}
#[test]
fn parse_position_field() {
let f = parse_field("rank:position").unwrap();
assert_eq!(f.name, "rank");
assert_eq!(f.kind, FieldKind::Position);
assert!(!f.nullable);
assert!(!f.unique);
assert_eq!(f.constraints.scope, None);
assert_eq!(f.rust_type(), "i64");
assert_eq!(f.sql_type(), "BIGINT");
assert_eq!(f.kind.sqlite_sql_type(), "INTEGER");
}
#[test]
fn parse_position_field_with_scope() {
let f = parse_field("rank:position{scope:board_id}").unwrap();
assert_eq!(f.kind, FieldKind::Position);
assert_eq!(f.constraints.scope.as_deref(), Some("board_id"));
}
#[test]
fn position_field_is_never_nullable() {
let err = parse_field("rank:Option<position>").unwrap_err();
assert!(
err.to_string().contains("position") && err.to_string().contains("nullable"),
"unexpected error: {err}"
);
}
#[test]
fn position_field_rejects_unique_modifier() {
let err = parse_field("rank:position:unique").unwrap_err();
assert!(
err.to_string().contains("unique"),
"unexpected error: {err}"
);
}
#[test]
fn scope_constraint_only_applies_to_position_fields() {
let err = parse_field("title:String{scope:board_id}").unwrap_err();
assert!(err.to_string().contains("scope"), "unexpected error: {err}");
}
#[test]
fn position_field_rejects_non_ident_scope_value() {
let err = parse_field("rank:position{scope:not a field}").unwrap_err();
assert!(err.to_string().contains("scope"), "unexpected error: {err}");
}
#[test]
fn position_field_rejects_empty_constraint_block() {
let err = parse_field("rank:position{}").unwrap_err();
assert!(err.to_string().contains("empty"), "unexpected error: {err}");
}
#[test]
fn position_field_rejects_min_max_constraints() {
let err = parse_field("rank:position{min=0}").unwrap_err();
assert!(err.to_string().contains("min"), "unexpected error: {err}");
}
#[test]
fn position_field_accepts_pascal_case_token() {
let f = parse_field("rank:Position").unwrap();
assert_eq!(f.kind, FieldKind::Position);
}
#[test]
fn supported_types_documents_position() {
assert!(
SUPPORTED_TYPES.contains("position"),
"SUPPORTED_TYPES must list position"
);
assert!(
SQLITE_SUPPORTED_KINDS.contains("position"),
"SQLITE_SUPPORTED_KINDS must list position — it is a plain INTEGER column"
);
}
#[test]
fn parse_encrypted_modifier_defaults_to_randomized() {
let f = parse_field("token:String{encrypted}").unwrap();
assert_eq!(f.name, "token");
assert_eq!(f.kind, FieldKind::String);
assert_eq!(f.encrypted_mode(), Some(EncryptedMode::Randomized));
assert!(f.is_encrypted());
assert!(f.is_randomized_encrypted());
}
#[test]
fn parse_encrypted_deterministic_modifier() {
let f = parse_field("email:String{encrypted:deterministic}").unwrap();
assert_eq!(f.encrypted_mode(), Some(EncryptedMode::Deterministic));
assert!(f.is_encrypted());
assert!(!f.is_randomized_encrypted());
}
#[test]
fn parse_encrypted_randomized_modifier_is_explicit_default() {
let f = parse_field("token:String{encrypted:randomized}").unwrap();
assert_eq!(f.encrypted_mode(), Some(EncryptedMode::Randomized));
}
#[test]
fn encrypted_modifier_accepts_the_text_alias() {
let f = parse_field("notes:Text{encrypted}").unwrap();
assert_eq!(f.kind, FieldKind::Text);
assert!(f.is_encrypted());
}
#[test]
fn encrypted_modifier_composes_with_validation_constraints() {
let f = parse_field("email:String{encrypted:deterministic,max=254,email}").unwrap();
assert_eq!(f.encrypted_mode(), Some(EncryptedMode::Deterministic));
assert_eq!(
f.validation_attrs(),
vec!["length(max = 254)".to_owned(), "email".to_owned()]
);
}
#[test]
fn unencrypted_field_reports_no_encryption() {
let f = parse_field("title:String").unwrap();
assert_eq!(f.encrypted_mode(), None);
assert!(!f.is_encrypted());
assert!(!f.is_randomized_encrypted());
}
#[test]
fn encrypted_only_block_is_not_an_empty_constraint_set() {
let f = parse_field("token:String{encrypted}").unwrap();
assert!(!f.constraints.is_empty());
assert!(f.validation_attrs().is_empty());
}
#[test]
fn encrypted_modifier_on_nullable_field_is_rejected() {
let err = parse_field("token:Option<String>{encrypted}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("encrypted"), "must name the modifier: {msg}");
assert!(msg.contains("nullable"), "must explain the limit: {msg}");
}
#[test]
fn encrypted_modifier_on_non_string_kinds_is_rejected() {
for (token, kind_name) in [
("payload:Bytea{encrypted}", "Bytea"),
("payload:Vec<u8>{encrypted}", "Bytea"),
("count:i64{encrypted}", "i64"),
("ratio:f64{encrypted}", "f64"),
("flag:bool{encrypted}", "bool"),
("at:DateTime{encrypted}", "DateTime"),
("body:richtext{encrypted}", "richtext"),
("post:references{encrypted}", "references"),
("handle:slug{from:title,encrypted}", "slug{…}"),
] {
let err = parse_field(token).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("the `encrypted` modifier only applies to `String`/`Text` fields"),
"`{token}` must be rejected by the kind guard: {msg}"
);
assert!(
msg.contains(&format!("Got a `{kind_name}` field")),
"`{token}` must name the declared kind `{kind_name}`: {msg}"
);
}
}
#[test]
fn encrypted_modifier_on_nullable_text_alias_is_rejected() {
let err = parse_field("notes:Option<Text>{encrypted}").unwrap_err();
assert!(
err.to_string().contains("cannot be nullable"),
"unexpected error: {err}"
);
}
#[test]
fn encrypted_modifier_tolerates_whitespace_around_the_mode() {
let f = parse_field("email:String{ encrypted : deterministic }").unwrap();
assert_eq!(f.encrypted_mode(), Some(EncryptedMode::Deterministic));
}
#[test]
fn repeated_encrypted_modifier_is_rejected() {
for token in [
"token:String{encrypted,encrypted:deterministic}",
"token:String{encrypted:deterministic,encrypted}",
"token:String{encrypted,encrypted}",
] {
let err = parse_field(token).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("declared more than once"),
"`{token}` must be refused as ambiguous: {msg}"
);
}
}
#[test]
fn encrypted_mode_error_prefers_the_kind_diagnosis() {
let err = parse_field("count:i64{encrypted:bogus}").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("only applies to `String`/`Text` fields"),
"unexpected error: {msg}"
);
}
#[test]
fn encrypted_modifier_on_enum_field_is_rejected() {
let err = parse_field("status:enum{draft,published}{encrypted}").unwrap_err();
assert!(
err.to_string()
.contains("not a valid snake_case identifier")
|| err
.to_string()
.contains("only applies to `String`/`Text` fields"),
"unexpected error: {err}"
);
assert!(
reject_encrypted_on_kind(FieldKind::Enum)
.unwrap_err()
.contains("Got a `enum{…}` field")
);
}
#[test]
fn encrypted_modifier_rejects_unknown_mode() {
let err = parse_field("token:String{encrypted:determinstic}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("determinstic"), "must name the token: {msg}");
assert!(
msg.contains("deterministic") && msg.contains("randomized"),
"must list the accepted modes: {msg}"
);
}
#[test]
fn encrypted_modifier_rejects_attribute_only_options() {
for opt in ["admin_visible", "versioned_ciphertext"] {
let err = parse_field(&format!("token:String{{encrypted:{opt}}}")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(opt), "must name the option: {msg}");
assert!(
msg.contains("#[encrypted"),
"must point at the attribute to hand-edit: {msg}"
);
}
}
#[test]
fn encrypted_modifier_with_state_machine_is_rejected() {
let err = parse_field("status:String{encrypted}:states(draft -> published)").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("bypasses encryption"),
"must name the concrete blocker (the transition handler's raw write): {msg}"
);
}
#[test]
fn randomized_encrypted_unique_field_is_rejected() {
let err = parse_field("token:String{encrypted}:unique").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("token"), "must name the field: {msg}");
assert!(
msg.contains("deterministic"),
"must point at the fix: {msg}"
);
}
#[test]
fn deterministic_encrypted_unique_field_is_allowed() {
let f = parse_field("email:String{encrypted:deterministic}:unique").unwrap();
assert!(f.unique);
assert_eq!(f.encrypted_mode(), Some(EncryptedMode::Deterministic));
}
#[test]
fn slug_cannot_derive_from_an_encrypted_field() {
for mode in ["encrypted", "encrypted:deterministic"] {
let err = parse_fields(&[
format!("api_token:String{{{mode}}}"),
"handle:slug{from:api_token}".to_owned(),
])
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("api_token"), "must name the source: {msg}");
assert!(msg.contains("encrypted"), "must name the reason: {msg}");
}
let fields = parse_fields(&[
"title:String".to_owned(),
"api_token:String{encrypted}".to_owned(),
"handle:slug{from:title}".to_owned(),
])
.unwrap();
assert_eq!(fields.len(), 3);
}
#[test]
fn encrypted_column_sql_type_is_unbounded_text() {
let f = parse_field("token:String{encrypted}").unwrap();
assert_eq!(f.sql_column_type(), "TEXT");
assert_eq!(
f.sql_column_type_for(autumn_web::config::DatabaseBackend::Sqlite),
"TEXT"
);
assert_eq!(f.schema_type(), "Text");
assert_eq!(f.rust_type(), "String");
}
#[test]
fn supported_types_help_mentions_the_encrypted_modifier() {
assert!(
SUPPORTED_TYPES.contains("encrypted"),
"supported-type help must advertise the encrypted modifier: {SUPPORTED_TYPES}"
);
}
#[test]
fn unknown_constraint_modifier_is_rejected_by_name() {
let err = parse_field("title:String{maxx=5}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("maxx"), "must name the bad token: {msg}");
}
#[test]
fn email_constraint_on_numeric_field_is_rejected() {
let err = parse_field("age:i32{email}").unwrap_err();
assert!(err.to_string().contains("email"));
}
#[test]
fn label_constraint_on_non_reference_is_rejected() {
let err = parse_field("title:String{label:foo}").unwrap_err();
assert!(err.to_string().contains("references"));
}
#[test]
fn min_greater_than_max_is_rejected() {
let err = parse_field("title:String{min=10,max=3}").unwrap_err();
assert!(err.to_string().contains("min"));
}
#[test]
fn non_integer_length_bound_is_rejected() {
let err = parse_field("title:String{max=abc}").unwrap_err();
assert!(err.to_string().contains("abc"));
}
#[test]
fn constraint_on_unconstrainable_kind_is_rejected() {
let err = parse_field("active:bool{min=0}").unwrap_err();
assert!(err.to_string().contains("not supported") || err.to_string().contains("bool"));
}
#[test]
fn empty_constraint_block_is_rejected() {
let err = parse_field("title:String{}").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn nullable_string_with_constraint_parses() {
let f = parse_field("bio:Option<String>{max=200}").unwrap();
assert!(f.nullable);
assert_eq!(f.constraints.max.as_deref(), Some("200"));
assert_eq!(f.validation_attrs(), vec!["length(max = 200)"]);
}
#[test]
fn bare_field_has_empty_constraints() {
let f = parse_field("title:String").unwrap();
assert!(f.constraints.is_empty());
assert!(f.validation_attrs().is_empty());
}
#[test]
fn id_type_parse_rejects_unknown_with_accepted_values_listed() {
for bad in ["guid", "serial4", "int", "ulid"] {
let err = IdType::parse(bad).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains(bad),
"error must echo the bad value '{bad}': {msg}"
);
assert!(msg.contains("uuid"), "error must list 'uuid': {msg}");
assert!(msg.contains("bigint"), "error must list 'bigint': {msg}");
}
}
#[test]
fn parse_enum_field() {
let f = parse_field("status:enum{draft,published,archived}").unwrap();
assert_eq!(f.name, "status");
assert_eq!(f.kind, FieldKind::Enum);
assert_eq!(f.variants, vec!["draft", "published", "archived"]);
assert!(!f.nullable);
assert_eq!(f.rust_type(), "Status");
assert_eq!(f.enum_type_name().as_deref(), Some("Status"));
assert_eq!(f.schema_type(), "Text");
assert_eq!(f.sql_type(), "TEXT");
assert_eq!(f.sql_nullability(), "NOT NULL");
assert!(f.is_enum());
assert!(FieldKind::Enum.is_enum());
assert!(!FieldKind::String.is_enum());
}
#[test]
fn parse_enum_field_multiword_name_pascalizes_type() {
let f = parse_field("review_state:enum{open,closed}").unwrap();
assert_eq!(f.rust_type(), "ReviewState");
assert_eq!(f.enum_type_name().as_deref(), Some("ReviewState"));
}
#[test]
fn parse_enum_field_trims_variant_whitespace() {
let f = parse_field("status:enum{ draft , published }").unwrap();
assert_eq!(f.variants, vec!["draft", "published"]);
}
#[test]
fn parse_nullable_enum_field() {
let f = parse_field("status:Option<enum{draft,published}>").unwrap();
assert_eq!(f.kind, FieldKind::Enum);
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<Status>");
assert_eq!(f.schema_type(), "Nullable<Text>");
assert_eq!(f.sql_nullability(), "NULL");
}
#[test]
fn enum_rejects_non_ident_variant() {
let err = parse_field("status:enum{2fa,ok}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("2fa"), "must name the bad variant: {msg}");
assert!(
msg.contains("identifier"),
"must explain the identifier rule: {msg}"
);
}
#[test]
fn enum_rejects_variant_pascalizing_to_leading_digit() {
let err = parse_field("status:enum{_2fa,ok}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("_2fa"), "must name the bad variant: {msg}");
assert!(
msg.contains("identifier"),
"must explain the identifier rule: {msg}"
);
}
#[test]
fn enum_rejects_variant_pascalizing_to_empty() {
let err = parse_field("status:enum{__,ok}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("__"), "must name the bad variant: {msg}");
assert!(
msg.contains("identifier"),
"must explain the identifier rule: {msg}"
);
}
#[test]
fn enum_rejects_uppercase_variant() {
let err = parse_field("status:enum{Draft,ok}").unwrap_err();
assert!(err.to_string().contains("snake_case identifier"));
}
#[test]
fn enum_rejects_keyword_variant() {
let err = parse_field("status:enum{type,ok}").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("type"), "must name the bad variant: {msg}");
assert!(msg.contains("keyword"), "must explain why: {msg}");
}
#[test]
fn enum_rejects_duplicate_variants() {
let err = parse_field("status:enum{draft,draft}").unwrap_err();
assert!(err.to_string().contains("duplicate"));
}
#[test]
fn enum_rejects_pascal_colliding_variants() {
let err = parse_field("status:enum{in_review,in__review}").unwrap_err();
assert!(err.to_string().contains("InReview"));
}
#[test]
fn enum_rejects_empty_body() {
let err = parse_field("status:enum{}").unwrap_err();
assert!(err.to_string().contains("variant"));
}
#[test]
fn enum_rejects_single_variant() {
let err = parse_field("status:enum{draft}").unwrap_err();
assert!(err.to_string().contains("at least two"));
}
#[test]
fn enum_rejects_unclosed_brace() {
let err = parse_field("status:enum{draft,published").unwrap_err();
assert!(err.to_string().contains("enum{"));
}
#[test]
fn enum_error_hints_about_shell_brace_expansion() {
let err = parse_field("status:enumdraft").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("quote"), "must suggest quoting: {msg}");
}
#[test]
fn enum_prefixed_typo_still_lists_supported_types() {
let err = parse_field("status:enumerable").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("quote"), "got: {msg}");
assert!(msg.contains("Supported:"), "got: {msg}");
assert!(msg.contains("String"), "got: {msg}");
}
#[test]
fn enum_appears_in_supported_types_constant() {
assert!(
SUPPORTED_TYPES.contains("enum{"),
"SUPPORTED_TYPES must list enum{{…}}"
);
}
#[test]
fn parse_unique_string_field() {
let f = parse_field("email:String:unique").unwrap();
assert_eq!(f.name, "email");
assert_eq!(f.kind, FieldKind::String);
assert!(f.unique);
assert!(!f.nullable);
}
#[test]
fn parse_non_unique_field_defaults_to_false() {
let f = parse_field("title:String").unwrap();
assert!(!f.unique);
}
#[test]
fn parse_unique_option_field_keeps_nullable_and_unique() {
let f = parse_field("nickname:Option<String>:unique").unwrap();
assert!(f.nullable);
assert!(f.unique);
}
#[test]
fn parse_unique_i64_field() {
let f = parse_field("external_id:i64:unique").unwrap();
assert_eq!(f.kind, FieldKind::I64);
assert!(f.unique);
}
#[test]
fn parse_unique_enum_field() {
let f = parse_field("slug:enum{a,b}:unique").unwrap();
assert_eq!(f.kind, FieldKind::Enum);
assert!(f.unique);
}
#[test]
fn parse_unique_references_field() {
let f = parse_field("profile:references:unique").unwrap();
assert_eq!(f.name, "profile_id");
assert_eq!(f.kind, FieldKind::References);
assert!(f.unique);
}
#[test]
fn parse_unknown_modifier_is_rejected() {
let err = parse_field("email:String:bogus").unwrap_err();
assert!(
err.to_string().contains("bogus") && err.to_string().contains("unique"),
"got: {err}"
);
}
#[test]
fn parse_unique_trims_whitespace_around_modifier() {
let f = parse_field("email:String: unique ").unwrap();
assert!(f.unique);
}
#[test]
fn unique_appears_in_supported_types_constant() {
assert!(
SUPPORTED_TYPES.contains("unique"),
"SUPPORTED_TYPES must document the :unique modifier"
);
}
#[test]
fn parse_field_without_state_machine_defaults_to_none() {
let f = parse_field("title:String").unwrap();
assert!(f.state_machine.is_none());
}
#[test]
fn parse_state_machine_modifier_captures_transitions() {
let f = parse_field(
"status:String:states(draft -> published: can_publish, published -> archived)",
)
.unwrap();
assert_eq!(f.kind, FieldKind::String);
assert!(!f.nullable);
let sm = f.state_machine.expect("state machine should be parsed");
assert_eq!(
sm.transitions,
vec![
StateTransition {
from: "draft".to_owned(),
to: "published".to_owned(),
guard: Some("can_publish".to_owned()),
},
StateTransition {
from: "published".to_owned(),
to: "archived".to_owned(),
guard: None,
},
]
);
}
#[test]
fn parse_state_machine_tolerates_trailing_comma_and_whitespace() {
let f = parse_field("status:String:states( draft -> published , )").unwrap();
let sm = f.state_machine.unwrap();
assert_eq!(sm.transitions.len(), 1);
assert_eq!(sm.transitions[0].from, "draft");
assert_eq!(sm.transitions[0].to, "published");
assert!(sm.transitions[0].guard.is_none());
}
#[test]
fn state_machine_composes_with_unique_and_text() {
let f = parse_field("stage:Text:unique:states(a -> b)").unwrap();
assert_eq!(f.kind, FieldKind::Text);
assert!(f.unique);
assert_eq!(f.state_machine.unwrap().transitions.len(), 1);
}
#[test]
fn state_machine_on_nullable_string_is_rejected() {
let err = parse_field("status:Option<String>:states(a -> b)").unwrap_err();
assert!(err.to_string().contains("non-nullable"), "got: {err}");
}
#[test]
fn state_machine_on_non_string_field_is_rejected() {
let err = parse_field("count:i64:states(a -> b)").unwrap_err();
assert!(err.to_string().contains("String"), "got: {err}");
}
#[test]
fn state_machine_on_enum_field_is_rejected() {
let err = parse_field("status:enum{a,b}:states(a -> b)").unwrap_err();
assert!(err.to_string().contains("String"), "got: {err}");
}
#[test]
fn empty_state_machine_modifier_is_rejected() {
let err = parse_field("status:String:states()").unwrap_err();
assert!(err.to_string().contains("at least one"), "got: {err}");
}
#[test]
fn state_machine_missing_arrow_is_rejected() {
let err = parse_field("status:String:states(draft published)").unwrap_err();
assert!(err.to_string().contains("->"), "got: {err}");
}
#[test]
fn state_machine_invalid_state_name_is_rejected() {
let err = parse_field("status:String:states(Draft -> published)").unwrap_err();
assert!(err.to_string().contains("state name"), "got: {err}");
}
#[test]
fn parse_decimal_field_lowercase() {
let f = parse_field("price:decimal").unwrap();
assert_eq!(f.name, "price");
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 12,
scale: 2
}
);
assert!(!f.nullable);
}
#[test]
fn parse_decimal_field_pascal_case() {
let f = parse_field("price:Decimal").unwrap();
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 12,
scale: 2
}
);
}
#[test]
fn decimal_defaults_to_precision_12_scale_2() {
let f = parse_field("price:decimal").unwrap();
assert_eq!(f.sql_column_type(), "NUMERIC(12,2)");
}
#[test]
fn decimal_custom_precision_and_scale() {
let f = parse_field("price:decimal{10,2}").unwrap();
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 10,
scale: 2
}
);
assert_eq!(f.sql_column_type(), "NUMERIC(10,2)");
}
#[test]
fn decimal_rust_type_is_rust_decimal() {
let f = parse_field("price:decimal").unwrap();
assert_eq!(f.rust_type(), "rust_decimal::Decimal");
}
#[test]
fn decimal_schema_type_is_numeric() {
let f = parse_field("price:decimal").unwrap();
assert_eq!(f.schema_type(), "Numeric");
}
#[test]
fn decimal_sql_type_is_numeric() {
let f = parse_field("price:decimal").unwrap();
assert_eq!(f.sql_type(), "NUMERIC");
}
#[test]
fn optional_decimal_parses() {
let f = parse_field("balance:Option<decimal>").unwrap();
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 12,
scale: 2
}
);
assert!(f.nullable);
assert_eq!(f.rust_type(), "Option<rust_decimal::Decimal>");
assert_eq!(f.schema_type(), "Nullable<Numeric>");
assert_eq!(f.sql_nullability(), "NULL");
}
#[test]
fn optional_decimal_with_precision_scale_parses() {
let f = parse_field("balance:Option<decimal{10,2}>").unwrap();
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 10,
scale: 2
}
);
assert!(f.nullable);
assert_eq!(f.sql_column_type(), "NUMERIC(10,2)");
}
#[test]
fn unique_decimal_field_parses() {
let f = parse_field("price:decimal:unique").unwrap();
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 12,
scale: 2
}
);
assert!(f.unique);
}
#[test]
fn decimal_rejects_non_numeric_precision() {
let err = parse_field("price:decimal{abc,2}").unwrap_err();
assert!(err.to_string().contains("precision"));
}
#[test]
fn decimal_rejects_non_numeric_scale() {
let err = parse_field("price:decimal{10,xyz}").unwrap_err();
assert!(err.to_string().contains("scale"));
}
#[test]
fn decimal_rejects_scale_greater_than_precision() {
let err = parse_field("price:decimal{2,10}").unwrap_err();
assert!(err.to_string().contains("scale"));
}
#[test]
fn decimal_rejects_zero_precision() {
let err = parse_field("price:decimal{0,0}").unwrap_err();
assert!(err.to_string().contains("precision"));
}
#[test]
fn decimal_rejects_precision_over_rust_decimal_max() {
let err = parse_field("price:decimal{29,2}").unwrap_err();
assert!(err.to_string().contains("precision"));
}
#[test]
fn decimal_accepts_precision_at_rust_decimal_max() {
let f = parse_field("price:decimal{28,2}").unwrap();
assert_eq!(
f.kind,
FieldKind::Decimal {
precision: 28,
scale: 2
}
);
}
#[test]
fn decimal_rejects_missing_closing_brace() {
let err = parse_field("price:decimal{10,2").unwrap_err();
assert!(err.to_string().contains("decimal{"));
}
#[test]
fn decimal_error_hints_about_shell_brace_expansion() {
let err = parse_field("price:decimal10").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("quote"), "must suggest quoting: {msg}");
assert!(msg.contains("Supported:"), "got: {msg}");
}
#[test]
fn decimal_rejects_wrong_number_of_brace_args() {
let err = parse_field("price:decimal{10}").unwrap_err();
assert!(err.to_string().contains("precision,scale"));
}
#[test]
fn decimal_prefixed_typo_still_lists_supported_types() {
let err = parse_field("price:decimalize").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("quote"), "got: {msg}");
assert!(msg.contains("Supported:"), "got: {msg}");
assert!(msg.contains("String"), "got: {msg}");
}
#[test]
fn parse_decimal_type_tolerates_untrimmed_input() {
assert_eq!(
parse_decimal_type("decimal{10,2} ").unwrap(),
Some((10, 2, false))
);
}
#[test]
fn decimal_appears_in_supported_types_constant() {
assert!(
SUPPORTED_TYPES.contains("decimal"),
"SUPPORTED_TYPES must list decimal{{p,s}}"
);
}
#[test]
fn decimal_in_list_of_fields() {
let tokens = vec!["title:String".into(), "price:decimal".into()];
let fields = parse_fields(&tokens).unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(
fields[1].kind,
FieldKind::Decimal {
precision: 12,
scale: 2
}
);
}
#[test]
fn decimal_numeric_stays_unmapped_for_db_pull_inverse() {
assert!(sql_type_to_field_kind("numeric").is_none());
}
}
#[cfg(test)]
mod schema_core_parity {
use super::{Field, FieldConstraints, FieldKind, IdType};
use autumn_schema_core::{Backend, ColumnType, IdKind};
use autumn_web::config::DatabaseBackend;
fn field_of(kind: FieldKind, variants: Vec<String>) -> Field {
Field {
name: "col".to_owned(),
kind,
nullable: false,
variants,
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
}
}
#[test]
fn field_kind_mappings_match_schema_core() {
let cases: Vec<(FieldKind, ColumnType, Vec<String>)> = vec![
(FieldKind::String, ColumnType::Text, vec![]),
(FieldKind::Text, ColumnType::Text, vec![]),
(FieldKind::RichText, ColumnType::Text, vec![]),
(FieldKind::I32, ColumnType::Int32, vec![]),
(FieldKind::I64, ColumnType::Int64, vec![]),
(FieldKind::References, ColumnType::Int64, vec![]),
(FieldKind::Bool, ColumnType::Bool, vec![]),
(FieldKind::F32, ColumnType::Float32, vec![]),
(FieldKind::F64, ColumnType::Float64, vec![]),
(FieldKind::Uuid, ColumnType::Uuid, vec![]),
(FieldKind::NaiveDateTime, ColumnType::Timestamp, vec![]),
(FieldKind::DateTime, ColumnType::TimestampTz, vec![]),
(FieldKind::Bytea, ColumnType::Bytes, vec![]),
(FieldKind::Attachment, ColumnType::Attachment, vec![]),
(FieldKind::Json, ColumnType::Json, vec![]),
(
FieldKind::Decimal {
precision: 12,
scale: 2,
},
ColumnType::Decimal {
precision: 12,
scale: 2,
},
vec![],
),
(
FieldKind::Enum,
ColumnType::Enum {
variants: vec!["a".to_owned(), "b".to_owned(), "c".to_owned()],
},
vec!["a".to_owned(), "b".to_owned(), "c".to_owned()],
),
];
for (fk, ct, variants) in cases {
let field = field_of(fk, variants);
assert_eq!(
fk.rust_type(),
ct.rust_type(),
"rust_type parity for {fk:?}"
);
assert_eq!(
fk.schema_type_for(DatabaseBackend::Postgres),
ct.diesel_type(Backend::Postgres),
"pg diesel token parity for {fk:?}"
);
assert_eq!(
fk.schema_type_for(DatabaseBackend::Sqlite),
ct.diesel_type(Backend::Sqlite),
"sqlite diesel token parity for {fk:?}"
);
assert_eq!(
field.sql_column_type_for(DatabaseBackend::Postgres),
ct.sql_type(Backend::Postgres),
"pg sql type parity for {fk:?}"
);
assert_eq!(
field.sql_column_type_for(DatabaseBackend::Sqlite),
ct.sql_type(Backend::Sqlite),
"sqlite sql type parity for {fk:?}"
);
assert_eq!(
fk.sqlite_has_diesel_conversion(),
ct.sqlite_has_diesel_conversion(),
"sqlite diesel-conversion parity for {fk:?}"
);
assert_eq!(
fk.rust_type_for(DatabaseBackend::Sqlite),
ct.rust_type_for(Backend::Sqlite),
"sqlite rust_type parity for {fk:?}"
);
assert_eq!(
fk.rust_type_for(DatabaseBackend::Postgres),
ct.rust_type_for(Backend::Postgres),
"pg rust_type parity for {fk:?}"
);
}
}
#[test]
fn id_type_mappings_match_schema_core() {
for (idt, idk) in [
(IdType::BigSerial, IdKind::BigSerial),
(IdType::Uuid, IdKind::Uuid),
] {
assert_eq!(
idt.rust_type(),
idk.rust_type(),
"id rust_type parity for {idt:?}"
);
for (db, be) in [
(DatabaseBackend::Postgres, Backend::Postgres),
(DatabaseBackend::Sqlite, Backend::Sqlite),
] {
assert_eq!(
idt.pk_sql_for(db),
idk.pk_sql(be),
"pk_sql parity for {idt:?} on {db:?}"
);
assert_eq!(
idt.schema_type_for(db),
idk.diesel_type(be),
"id diesel token parity for {idt:?} on {db:?}"
);
}
}
}
}