use autumn_web::config::DatabaseBackend;
use super::GenerateError;
use super::naming;
#[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>,
}
impl FieldConstraints {
#[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()
}
}
#[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 {
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 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 => {
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 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,
I32,
I64,
Bool,
F32,
F64,
Uuid,
NaiveDateTime,
DateTime,
Bytea,
Attachment,
References,
Enum,
Decimal {
precision: u32,
scale: u32,
},
}
impl FieldKind {
#[must_use]
pub const fn rust_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::Enum => "String",
Self::I32 => "i32",
Self::I64 | Self::References => "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::Bytea => "Vec<u8>",
Self::Attachment => "autumn_web::storage::Blob",
Self::Decimal { .. } => "rust_decimal::Decimal",
}
}
#[must_use]
pub const fn schema_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::Enum => "Text",
Self::I32 => "Int4",
Self::I64 | Self::References => "Int8",
Self::Bool => "Bool",
Self::F32 => "Float4",
Self::F64 => "Float8",
Self::Uuid => "Uuid",
Self::NaiveDateTime => "Timestamp",
Self::DateTime => "Timestamptz",
Self::Bytea => "Bytea",
Self::Attachment => "Jsonb",
Self::Decimal { .. } => "Numeric",
}
}
#[must_use]
pub const fn sql_type(self) -> &'static str {
match self {
Self::String | Self::Text | Self::Enum => "TEXT",
Self::I32 => "INTEGER",
Self::I64 | Self::References => "BIGINT",
Self::Bool => "BOOLEAN",
Self::F32 => "REAL",
Self::F64 => "DOUBLE PRECISION",
Self::Uuid => "UUID",
Self::NaiveDateTime => "TIMESTAMP",
Self::DateTime => "TIMESTAMPTZ",
Self::Bytea => "BYTEA",
Self::Attachment => "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::Enum => "TEXT",
Self::I32 | Self::I64 | Self::References | Self::Bool => "INTEGER",
Self::F32 | Self::F64 => "REAL",
Self::Uuid => "TEXT",
Self::NaiveDateTime | Self::DateTime => "TEXT",
Self::Bytea => "BLOB",
Self::Attachment => "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::Enum => "Text",
Self::I32 => "Int4",
Self::I64 | Self::References => "Int8",
Self::Bool => "Bool",
Self::F32 => "Float4",
Self::F64 => "Float8",
Self::Uuid => "Text",
Self::NaiveDateTime | Self::DateTime => "Timestamp",
Self::Bytea => "Binary",
Self::Attachment => "Text",
Self::Decimal { .. } => "Text",
}
}
#[must_use]
pub const fn sqlite_has_diesel_conversion(self) -> bool {
!matches!(
self,
Self::Uuid | Self::Attachment | Self::Decimal { .. } | Self::DateTime | Self::Enum
)
}
#[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 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_decimal(self) -> bool {
matches!(self, Self::Decimal { .. })
}
}
#[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 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 => "TEXT PRIMARY KEY",
},
}
}
#[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, i32, i64, bool, f32, f64, \
Uuid, NaiveDateTime, DateTime, Vec<u8>, Bytea, Attachment, references, \
enum{a,b,…}, decimal{precision,scale}, Option<…>, :unique";
pub const SQLITE_SUPPORTED_KINDS: &str = "String, Text, i32, i64, bool, f32, f64, \
NaiveDateTime, Vec<u8>, Bytea, references, Option<…>, :unique";
pub const SQL_SUPPORTED_TYPES: &str = "text, varchar, bpchar (-> String), int4 (-> i32), \
int8 (-> i64), bool, float4 (-> f32), float8 (-> f64), uuid, timestamp, timestamptz, 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),
"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"
)]
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()
),
});
}
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 {
name.to_owned()
};
Ok(Field {
name,
kind,
nullable,
variants: Vec::new(),
unique,
constraints,
state_machine,
})
}
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 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();
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(':') {
if key.trim() == "label" {
set_label_constraint(&mut c, value.trim(), is_reference)?;
} else {
return Err(unknown_constraint_message(key.trim(), 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;
}
}
_ => 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()) && !string_like && !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 => {
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()),
_ => Err(unknown_constraint_message(key, kind)),
}
}
fn parse_bound(value: &str, kind: FieldKind) -> Result<String, String> {
match kind {
FieldKind::String | FieldKind::Text => {
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 unknown_constraint_message(token: &str, kind: FieldKind) -> String {
let accepted = match kind {
FieldKind::String | FieldKind::Text => "min=N, max=N, email, url",
FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64 => "min=N, max=N",
FieldKind::References => "label:col",
_ => "(none — this field type takes no constraint modifiers)",
};
format!(
"unknown constraint '{token}' for {} fields; supported: {accepted}",
kind.rust_type()
)
}
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)))
}
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();
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);
}
Ok(fields)
}
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),
"Bytea" => Some(FieldKind::Bytea),
"Attachment" | "attachment" => Some(FieldKind::Attachment),
"References" | "references" => Some(FieldKind::References),
_ => {
strip_wrapper(ty, "Vec").and_then(|inner| {
if inner.trim() == "u8" {
Some(FieldKind::Bytea)
} else {
None
}
})
}
}
}
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 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", "Timestamp"),
("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",
"TimestamptzSqlite",
"Jsonb",
"Uuid",
"Bytea",
"Numeric"
]
.contains(&ty.as_str()),
"`{token}` -> `{ty}` must not be a Postgres-only or sqlite-feature-gated \
diesel type on SQLite"
);
}
}
#[test]
fn sqlite_has_diesel_conversion_rejects_uuid_attachment_decimal_datetime_enum() {
for token in [
"title:String",
"body:Text",
"count:i32",
"big:i64",
"flag:bool",
"ratio:f32",
"amount:f64",
"naive:NaiveDateTime",
"data:Bytea",
"post:references",
] {
assert!(
parse_field(token)
.unwrap()
.kind
.sqlite_has_diesel_conversion(),
"`{token}` must be a SQLite-supported kind"
);
}
for token in [
"token:Uuid",
"cover:Attachment",
"price:decimal{10,2}",
"at:DateTime",
"status:enum{draft,published}",
] {
assert!(
!parse_field(token)
.unwrap()
.kind
.sqlite_has_diesel_conversion(),
"`{token}` must have no working diesel SQLite conversion"
);
}
}
#[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),
"TEXT PRIMARY KEY"
);
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:Money").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Money"));
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_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 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 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 bare_unique_modifier_still_parses() {
let f = parse_field("email:String:unique").unwrap();
assert!(f.unique);
assert!(f.constraints.is_empty());
}
#[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::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::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:?}"
);
}
}
#[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:?}"
);
}
}
}
}