use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use super::dsl::{
EncryptedMode, Field, FieldConstraints, FieldKind, IdType, parse_fields,
randomized_equality_lookup_reason,
};
use super::emit::{Action, Plan, Revert};
use super::naming::{pascal, pluralize, snake};
use super::schema_edit::{
add_mod_declaration, add_search_down_sql_for, add_search_up_sql_for,
append_schema_table_with_id_for, create_table_sql_with_metadata_and_id_for, drop_table_sql,
ensure_autumn_web_feature, link_models_into_seed_bin, position_triggers_down_sql_for,
position_triggers_up_sql_for,
};
use super::{GenerateError, detect_backend, ensure_project_root, read_or_empty};
use autumn_web::config::DatabaseBackend;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelOptions {
pub indexes: Vec<String>,
pub uniques: Vec<String>,
pub validations: Vec<String>,
pub defaults: Vec<String>,
pub soft_delete: bool,
pub sharded: bool,
pub shard_key: Option<String>,
pub id_type: IdType,
pub searchable: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelMetadata {
indexes: BTreeSet<String>,
validations: BTreeMap<String, Vec<String>>,
defaults: BTreeMap<String, String>,
search_language: Option<String>,
searchable: Vec<(String, char)>,
commentable_author: Option<String>,
}
impl ModelMetadata {
pub fn set_commentable_author(&mut self, author: Option<&str>) {
self.commentable_author = author.map(str::to_owned);
}
#[must_use]
pub fn has_validator_rules(&self) -> bool {
!self.validations.is_empty()
}
#[must_use]
pub fn search_language(&self) -> Option<&str> {
self.search_language.as_deref()
}
#[must_use]
pub fn searchable(&self) -> &[(String, char)] {
&self.searchable
}
#[must_use]
pub const fn indexes(&self) -> &BTreeSet<String> {
&self.indexes
}
#[must_use]
pub const fn defaults(&self) -> &BTreeMap<String, String> {
&self.defaults
}
#[must_use]
pub const fn validations(&self) -> &BTreeMap<String, Vec<String>> {
&self.validations
}
}
#[allow(dead_code)]
pub fn plan_model(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
) -> Result<Plan, GenerateError> {
plan_model_with_options(
project_root,
name,
field_tokens,
timestamp,
&ModelOptions::default(),
)
}
pub fn plan_model_with_options(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ModelOptions,
) -> Result<Plan, GenerateError> {
plan_model_with_options_impl(project_root, name, field_tokens, timestamp, options, false)
}
pub fn plan_model_with_options_for_revert(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ModelOptions,
) -> Result<Plan, GenerateError> {
plan_model_with_options_impl(project_root, name, field_tokens, timestamp, options, true)
}
#[allow(
clippy::too_many_lines,
reason = "linear sequence of independent file/revert steps mirroring the files this \
generator emits; splitting it up would not make any single step clearer"
)]
fn plan_model_with_options_impl(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ModelOptions,
for_revert: bool,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
validate_resource_name(name)?;
let mut fields = parse_fields(field_tokens)?;
apply_unique_flags(&mut fields, &options.uniques)?;
validate_field_names(&fields)?;
if !for_revert {
validate_encrypted_fields(&fields, options)?;
validate_translatable_fields(&fields, options)?;
}
let pascal_name = pascal(name);
validate_enum_field_collisions(&pascal_name, &fields)?;
let backend = detect_backend(project_root);
let mut metadata = parse_model_metadata_for(backend, &fields, options)?;
metadata.set_commentable_author(super::commentable::detect_author_model(project_root));
if options.id_type == IdType::Uuid && fields.iter().any(|f| f.kind.is_commentable()) {
return Err(super::uuid_pk_commentable_unsupported_error());
}
if backend == autumn_web::config::DatabaseBackend::Sqlite {
super::reject_sqlite_unsupported_field_kinds(&fields)?;
}
if backend == autumn_web::config::DatabaseBackend::Sqlite && options.sharded {
return Err(super::sqlite_sharded_unsupported_error());
}
let snake_name = snake(name);
let table = pluralize(&snake_name);
let schema_fields = augment_fields_for_soft_delete(&fields, options.soft_delete)?;
if !for_revert {
validate_lock_version_field(&fields, &options.defaults)?;
}
if !for_revert
&& lock_version_field(&fields).is_some()
&& fields
.iter()
.all(|f| metadata.defaults().contains_key(&f.name))
{
return Err(GenerateError::Config(format!(
"this model has no insertable columns: `{LOCK_VERSION_COLUMN}` is managed by the \
database (and so is every `--default` column), so the generated \
New{pascal_name} struct would have no fields at all and the project would not \
compile. Declare at least one ordinary column alongside `{LOCK_VERSION_COLUMN}`."
)));
}
let mut plan = Plan::new(project_root);
if lock_version_field(&fields).is_some() {
plan.warn(format!(
"`{LOCK_VERSION_COLUMN}` opts this model into optimistic locking: the column is \
managed by the database (excluded from New{pascal_name}, carried on \
Update{pascal_name} as the expected version, defaulted to 0 in the migration) and \
a scaffolded form carries it in a hidden field rather than an editable control. \
Rename the column if you wanted an ordinary integer you set yourself."
));
}
if let Some(warning) = encryption_key_material_warning(&fields) {
plan.warn(warning);
}
check_reference_targets(
&mut plan,
project_root,
&fields,
&table,
Some(options.id_type),
)?;
if !for_revert
&& table == super::commentable::COMMENTS_TABLE
&& (fields.iter().any(|f| f.kind.is_commentable())
|| super::commentable::already_migrated(project_root))
{
return Err(GenerateError::Config(format!(
"cannot create a `{table}` table for `{pascal_name}`: the shared table of \
`comments:commentable` uses that name. A second `CREATE TABLE {table}` stops \
`autumn migrate`. Use a different model name, for example `Remark`."
)));
}
if fields.iter().any(|f| f.kind.is_commentable())
|| (for_revert && super::commentable::model_declares_commentable(project_root, &snake_name))
{
let revert_would_orphan_another_model = for_revert
&& super::commentable::another_model_is_still_commentable(project_root, &snake_name);
let emitted = !revert_would_orphan_another_model
&& super::commentable::push_commentable_migration(
&mut plan,
project_root,
timestamp,
backend,
for_revert,
)?;
if !for_revert {
plan.warn(if emitted {
format!(
"Added the shared `{table}` table. Every `#[commentable]` model \
attaches to it, so later models need no migration of their own. \
Mount the framework's comment routes once, e.g. \
`.nest(\"/comments\", autumn_web::commentable::router(Default::default()))\
.declare_plugin_routes(autumn_web::commentable::route_infos(\"/comments\"))`, \
and render a thread with \
`autumn_web::widgets::comment_thread`.",
table = super::commentable::COMMENTS_TABLE,
)
} else {
format!(
"Reusing the existing `{table}` table — the polymorphic comments table \
is shared across every `#[commentable]` model, so no migration was \
added for {pascal_name}.",
table = super::commentable::COMMENTS_TABLE,
)
});
plan.warn(metadata.commentable_author.as_deref().map_or_else(
|| {
"This project has no `User` model, so the generated \
`#[commentable]` names no author model. Add `by = <AuthorModel>` \
(and `author_name = <column>`) once you have one — until then \
threads render authors as `user #id`."
.to_owned()
},
|author| {
format!(
"`#[commentable(by = {author}, ...)]` on the generated model \
names this app's author model. Add `author_name = <column>` \
to render display names instead of `user #id`."
)
},
));
}
}
let models_dir = project_root.join("src").join("models");
let model_file = models_dir.join(format!("{snake_name}.rs"));
plan.create(
model_file,
render_model_file(
&pascal_name,
&table,
&fields,
&metadata,
options.soft_delete,
if options.sharded {
options.shard_key.as_deref()
} else {
None
},
options.id_type,
backend,
),
);
let mod_path = models_dir.join("mod.rs");
let mod_existing = read_or_empty(&mod_path);
plan.modify(
mod_path.clone(),
add_mod_declaration(&mod_existing, &snake_name),
);
plan.push_revert(crate::generate::emit::Revert::ModDecl {
path: mod_path,
name: snake_name,
});
let migration_dir_name = format!("{timestamp}_create_{table}");
let migration_dir = project_root.join("migrations").join(&migration_dir_name);
let table_sql = create_table_sql_with_metadata_and_id_for(
backend,
&table,
&schema_fields,
metadata.indexes(),
metadata.defaults(),
options.id_type,
);
let up_sql = if options.sharded {
format!(
"-- Sharded model: this migration runs against the control DB by default.\n\
-- To apply to shards, run: autumn migrate --shard <name>\n\
-- See: autumn migrate --help\n\
{table_sql}"
)
} else {
table_sql
};
let (up_sql, down_sql) = if metadata.searchable().is_empty() {
(up_sql, drop_table_sql(&table))
} else {
let language = metadata.search_language().unwrap_or("english");
let search_up = add_search_up_sql_for(backend, &table, language, metadata.searchable())?;
let search_down = add_search_down_sql_for(backend, &table);
(
format!("{up_sql}\n{search_up}"),
format!("{search_down}{}", drop_table_sql(&table)),
)
};
let position_up = position_triggers_up_sql_for(backend, &table, &schema_fields);
let position_down = position_triggers_down_sql_for(backend, &table, &schema_fields);
let up_sql = if position_up.is_empty() {
up_sql
} else {
format!("{up_sql}\n{position_up}")
};
let down_sql = if position_down.is_empty() {
down_sql
} else {
format!("{position_down}{down_sql}")
};
let commentable_up = if fields.iter().any(|f| f.kind.is_commentable()) {
super::commentable::parent_cleanup_sql(backend, &table, &pascal_name)
} else {
String::new()
};
let (up_sql, down_sql) = if commentable_up.is_empty() {
(up_sql, down_sql)
} else {
(
format!("{up_sql}{commentable_up}"),
format!(
"{}{down_sql}",
super::commentable::parent_cleanup_down_sql(backend, &table)
),
)
};
plan.create(migration_dir.join("up.sql"), up_sql);
plan.create(migration_dir.join("down.sql"), down_sql);
let schema_path = project_root.join("src").join("schema.rs");
let schema_existing = read_or_empty(&schema_path);
plan.modify(
schema_path.clone(),
append_schema_table_with_id_for(
backend,
&schema_existing,
&table,
&schema_fields,
options.id_type,
),
);
plan.push_revert(crate::generate::emit::Revert::SchemaTable {
path: schema_path,
table: table.clone(),
expected_block: append_schema_table_with_id_for(
backend,
"",
&table,
&schema_fields,
options.id_type,
),
});
let mut deps: Vec<(&str, &str)> = model_deps(backend).to_vec();
if metadata.has_validator_rules() {
deps.push((
"validator",
"{ version = \"0.21\", features = [\"derive\"] }",
));
}
if schema_fields.iter().any(|f| f.kind.is_decimal()) {
deps.push((
"rust_decimal",
match backend {
DatabaseBackend::Postgres => {
"{ version = \"1\", features = [\"db-diesel2-postgres\", \"serde\"] }"
}
DatabaseBackend::Sqlite => "{ version = \"1\", features = [\"serde\"] }",
},
));
let existing_cargo_toml = read_or_empty(&project_root.join("Cargo.toml"));
warn_if_existing_dep_missing_features(
&mut plan,
&existing_cargo_toml,
"rust_decimal",
decimal_dep_features(backend),
);
}
plan_cargo_deps(
&mut plan,
project_root,
&deps,
&project_root.join("src/models"),
);
if backend == DatabaseBackend::Sqlite {
plan_autumn_web_feature(&mut plan, project_root, "sqlite");
}
plan_seed_bin_linking(&mut plan, project_root);
if schema_fields.iter().any(Field::is_translatable) {
plan_autumn_web_feature(&mut plan, project_root, "i18n");
}
Ok(plan)
}
fn plan_autumn_web_feature(plan: &mut Plan, project_root: &Path, feature: &str) {
let cargo_path = project_root.join("Cargo.toml");
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &cargo_path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&cargo_path));
let updated = ensure_autumn_web_feature(&base, feature);
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path, updated);
}
plan.push_revert(Revert::CargoAutumnWebFeature {
path: project_root.join("Cargo.toml"),
feature: feature.to_owned(),
owner_dir: Some(project_root.join("src/models")),
});
}
fn plan_seed_bin_linking(plan: &mut Plan, project_root: &Path) {
let seed_path = project_root.join("src").join("bin").join("seed.rs");
if !seed_path.exists() {
return;
}
let existing = read_or_empty(&seed_path);
let linked = link_models_into_seed_bin(&existing);
if linked != existing {
plan.modify(seed_path.clone(), linked);
}
if seed_path.exists() {
plan.push_revert(crate::generate::emit::Revert::SeedBinLinks {
path: seed_path,
owner_dir: project_root.join("src").join("models"),
});
}
}
pub(super) fn model_file_exists(project_root: &Path, table: &str, base: &str) -> bool {
let per_resource = project_root
.join("src")
.join("models")
.join(format!("{base}.rs"));
if per_resource.exists() {
return true;
}
let single_file = project_root.join("src").join("models.rs");
single_file.exists() && declares_schema_table(&read_or_empty(&single_file), table)
}
fn declares_schema_table(content: &str, table: &str) -> bool {
for (idx, _) in content.match_indices("schema::") {
let after = &content[idx + "schema::".len()..];
if let Some(rest) = after.strip_prefix('{') {
let Some(end) = rest.find('}') else {
continue;
};
if rest[..end].split(',').any(|name| name.trim() == table) {
return true;
}
} else if after.starts_with(table)
&& after[table.len()..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric() && c != '_')
{
return true;
}
}
false
}
fn model_struct_has_uuid_pk(project_root: &Path, base: &str) -> bool {
let pascal_name = pascal(base);
let per_resource = project_root
.join("src")
.join("models")
.join(format!("{base}.rs"));
let content = if per_resource.exists() {
read_or_empty(&per_resource)
} else {
let single_file = project_root.join("src").join("models.rs");
if !single_file.exists() {
return false;
}
read_or_empty(&single_file)
};
let Ok(file) = syn::parse_file(&content) else {
return false;
};
let Some(item_struct) = file.items.iter().find_map(|item| match item {
syn::Item::Struct(s) if s.ident == pascal_name => Some(s),
_ => None,
}) else {
return false;
};
let syn::Fields::Named(fields) = &item_struct.fields else {
return false;
};
fields
.named
.iter()
.find(|field| field.attrs.iter().any(|a| a.path().is_ident("id")))
.is_some_and(|field| type_is_uuid(&field.ty))
}
fn type_is_uuid(ty: &syn::Type) -> bool {
let syn::Type::Path(type_path) = ty else {
return false;
};
if type_path.qself.is_some() {
return false;
}
if type_path
.path
.segments
.last()
.is_some_and(|seg| seg.ident == "SqliteUuid")
{
return true;
}
match type_path.path.segments.len() {
1 => type_path.path.segments[0].ident == "Uuid",
2 => {
type_path.path.segments[0].ident == "uuid" && type_path.path.segments[1].ident == "Uuid"
}
_ => false,
}
}
pub(super) const RICH_TEXT_MARKER_DOC: &str =
"Markdown source (rich text) — render with `autumn_web::markdown::render_user_content`.";
fn has_rich_text_marker(field: &syn::Field) -> bool {
field.attrs.iter().any(|attr| {
let syn::Meta::NameValue(nv) = &attr.meta else {
return false;
};
if !nv.path.is_ident("doc") {
return false;
}
let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
else {
return false;
};
s.value().trim() == RICH_TEXT_MARKER_DOC
})
}
pub(super) fn model_string_columns(project_root: &Path, base: &str) -> Vec<(String, bool)> {
let pascal_name = pascal(base);
let per_resource = project_root
.join("src")
.join("models")
.join(format!("{base}.rs"));
let content = if per_resource.exists() {
read_or_empty(&per_resource)
} else {
let single_file = project_root.join("src").join("models.rs");
if !single_file.exists() {
return Vec::new();
}
read_or_empty(&single_file)
};
let Ok(file) = syn::parse_file(&content) else {
return Vec::new();
};
let Some(item_struct) = file.items.iter().find_map(|item| match item {
syn::Item::Struct(s) if s.ident == pascal_name => Some(s),
_ => None,
}) else {
return Vec::new();
};
let syn::Fields::Named(fields) = &item_struct.fields else {
return Vec::new();
};
let mut out = Vec::new();
for field in &fields.named {
let Some(ident) = field.ident.as_ref() else {
continue;
};
if has_rich_text_marker(field) {
continue;
}
if let Some(nullable) = string_like_nullability(&field.ty) {
out.push((ident.to_string(), nullable));
}
}
out
}
fn string_like_nullability(ty: &syn::Type) -> Option<bool> {
if let Some(inner) = option_inner_type(ty) {
return type_is_string(inner).then_some(true);
}
type_is_string(ty).then_some(false)
}
fn option_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
let syn::Type::Path(tp) = ty else {
return None;
};
let seg = tp.path.segments.last()?;
if seg.ident != "Option" {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
return None;
};
args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(t) => Some(t),
_ => None,
})
}
fn type_is_string(ty: &syn::Type) -> bool {
let syn::Type::Path(tp) = ty else {
return false;
};
tp.qself.is_none() && tp.path.segments.last().is_some_and(|s| s.ident == "String")
}
pub(super) fn check_reference_targets(
plan: &mut Plan,
project_root: &Path,
fields: &[Field],
own_table: &str,
own_id_type: Option<IdType>,
) -> Result<(), GenerateError> {
for f in fields {
if !f.kind.is_reference() {
continue;
}
let Some(table) = f.reference_table() else {
continue;
};
let base = f.name.strip_suffix("_id").unwrap_or(&f.name);
if table == own_table {
match own_id_type {
Some(IdType::Uuid) => {
return Err(GenerateError::Config(format!(
"'{}' is a self-referential foreign key, but this model uses a UUID \
primary key (`--id uuid`). `references` fields only support \
i64/BIGSERIAL-keyed targets (issue #1026).",
f.name
)));
}
Some(_) => continue,
None => {
if model_struct_has_uuid_pk(project_root, base) {
return Err(GenerateError::Config(format!(
"'{}' is a self-referential foreign key, but the existing model \
declares a UUID primary key. `references` fields only support \
i64/BIGSERIAL-keyed targets (issue #1026).",
f.name
)));
}
continue;
}
}
}
if !model_file_exists(project_root, &table, base) {
plan.warn(format!(
"'{}' references model '{base}', but src/models/{base}.rs (or a matching \
src/models.rs declaration) was not found — assuming table '{table}' \
already exists.",
f.name
));
continue;
}
if model_struct_has_uuid_pk(project_root, base) {
return Err(GenerateError::Config(format!(
"'{}' references model '{base}', which declares a UUID primary key. \
`references` fields only support i64/BIGSERIAL-keyed targets \
(issue #1026) — hand-write the migration for a UUID foreign key instead.",
f.name
)));
}
}
Ok(())
}
pub(super) fn augment_fields_for_soft_delete(
fields: &[Field],
soft_delete: bool,
) -> Result<std::borrow::Cow<'_, [Field]>, GenerateError> {
if !soft_delete {
return Ok(std::borrow::Cow::Borrowed(fields));
}
if fields.iter().any(|f| f.name == "deleted_at") {
return Err(GenerateError::InvalidField {
token: "deleted_at".to_owned(),
reason: "'deleted_at' is managed by --soft-delete; remove it from the field list"
.to_owned(),
});
}
let mut augmented = fields.to_vec();
augmented.push(Field {
name: "deleted_at".to_owned(),
kind: FieldKind::NaiveDateTime,
nullable: true,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
});
Ok(std::borrow::Cow::Owned(augmented))
}
pub(super) const MODEL_DEPS: &[(&str, &str)] = &[
("chrono", "{ version = \"0.4\", features = [\"serde\"] }"),
(
"diesel",
"{ version = \"2\", features = [\"postgres\", \"chrono\", \"uuid\"] }",
),
(
"diesel-async",
"{ version = \"0.9\", features = [\"postgres\"] }",
),
(
"pq-sys",
"{ version = \"0.7\", features = [\"bundled_without_openssl\"] }",
),
("diesel_migrations", "\"2\""),
("serde", "{ version = \"1\", features = [\"derive\"] }"),
("serde_json", "\"1\""),
("uuid", "{ version = \"1\", features = [\"serde\"] }"),
];
pub(super) const MODEL_DEPS_SQLITE: &[(&str, &str)] = &[
("chrono", "{ version = \"0.4\", features = [\"serde\"] }"),
(
"diesel",
"{ version = \"2\", features = [\"sqlite\", \"chrono\", \"serde_json\", \
\"returning_clauses_for_sqlite_3_35\"] }",
),
(
"diesel-async",
"{ version = \"0.9\", features = [\"sync-connection-wrapper\"] }",
),
(
"libsqlite3-sys",
"{ version = \"0.38\", features = [\"bundled\"] }",
),
("diesel_migrations", "\"2\""),
("serde", "{ version = \"1\", features = [\"derive\"] }"),
("serde_json", "\"1\""),
("uuid", "{ version = \"1\", features = [\"serde\"] }"),
];
#[must_use]
pub(super) const fn model_deps(
backend: DatabaseBackend,
) -> &'static [(&'static str, &'static str)] {
match backend {
DatabaseBackend::Postgres => MODEL_DEPS,
DatabaseBackend::Sqlite => MODEL_DEPS_SQLITE,
}
}
#[must_use]
pub(super) const fn decimal_dep_features(backend: DatabaseBackend) -> &'static [&'static str] {
match backend {
DatabaseBackend::Postgres => &["db-diesel2-postgres", "serde"],
DatabaseBackend::Sqlite => &["serde"],
}
}
pub(super) fn plan_cargo_deps(
plan: &mut Plan,
project_root: &Path,
deps: &[(&str, &str)],
owner_dir: &Path,
) {
let cargo_toml_path = project_root.join("Cargo.toml");
let existing = read_or_empty(&cargo_toml_path);
let updated = ensure_cargo_dependencies(&existing, deps);
if updated != existing {
plan.modify(cargo_toml_path.clone(), updated);
}
let names: Vec<String> = deps
.iter()
.map(|(name, _)| *name)
.filter(|name| !TEMPLATE_SHIPPED_CARGO_DEPS.contains(name))
.map(str::to_owned)
.collect();
if !names.is_empty() {
plan.push_revert(crate::generate::emit::Revert::CargoDeps {
path: cargo_toml_path,
names,
owner_dir: owner_dir.to_path_buf(),
});
}
}
pub(super) const TEMPLATE_SHIPPED_CARGO_DEPS: &[&str] =
&["autumn-web", "maud", "diesel_migrations"];
pub fn ensure_cargo_dependencies(existing: &str, deps: &[(&str, &str)]) -> String {
let lines: Vec<&str> = existing.lines().collect();
let Some(deps_idx) = lines
.iter()
.position(|l| is_table_header(l, "dependencies"))
else {
use std::fmt::Write as _;
let mut out = String::with_capacity(existing.len() + 64);
out.push_str(existing);
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str("[dependencies]\n");
for (name, spec) in deps {
let _ = writeln!(out, "{name} = {spec}");
}
return out;
};
let scan_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l) && !is_dep_subtable_boundary_marker(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let insert_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let dep_section = &lines[deps_idx + 1..scan_end];
let to_add: Vec<(&str, &str)> = deps
.iter()
.copied()
.filter(|(name, _)| !dep_section_has(dep_section, name))
.collect();
if to_add.is_empty() {
return existing.to_owned();
}
let mut insert_at = insert_end;
while insert_at > deps_idx + 1 && lines[insert_at - 1].trim().is_empty() {
insert_at -= 1;
}
let inserted: Vec<String> = to_add
.iter()
.map(|(name, spec)| format!("{name} = {spec}"))
.collect();
let mut out = String::with_capacity(
existing.len() + inserted.iter().map(String::len).sum::<usize>() + 16,
);
for line in &lines[..insert_at] {
out.push_str(line);
out.push('\n');
}
for entry in &inserted {
out.push_str(entry);
out.push('\n');
}
for line in &lines[insert_at..] {
out.push_str(line);
out.push('\n');
}
if !existing.ends_with('\n') {
out.pop();
}
out
}
#[must_use]
pub fn remove_cargo_dependencies(existing: &str, names: &[&str]) -> String {
let mut lines: Vec<&str> = existing.lines().collect();
let Some(deps_idx) = lines
.iter()
.position(|l| is_table_header(l, "dependencies"))
else {
return existing.to_owned();
};
let mut scan_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l) && !is_dep_subtable_boundary_marker(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let mut removed_any = false;
let mut i = deps_idx + 1;
while i < scan_end {
let trimmed = lines[i].trim_start();
let is_target = names.iter().any(|name| {
trimmed
.strip_prefix(name)
.is_some_and(|rest| rest.trim_start().starts_with('='))
});
if is_target {
lines.remove(i);
scan_end -= 1;
removed_any = true;
continue;
}
i += 1;
}
if !removed_any {
return existing.to_owned();
}
if lines[deps_idx + 1..scan_end]
.iter()
.all(|l| l.trim().is_empty())
{
lines.drain(deps_idx..scan_end);
if deps_idx > 0 && lines[deps_idx - 1].trim().is_empty() {
lines.remove(deps_idx - 1);
}
}
let mut out = lines.join("\n");
if existing.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out
}
fn is_table_header(line: &str, table: &str) -> bool {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix('[') else {
return false;
};
let Some(close_idx) = rest.find(']') else {
return false;
};
if rest[..close_idx].trim() != table {
return false;
}
let after = rest[close_idx + 1..].trim_start();
after.is_empty() || after.starts_with('#')
}
fn is_any_table_header(line: &str) -> bool {
let trimmed = line.trim_start();
let after_open = trimmed
.strip_prefix("[[")
.or_else(|| trimmed.strip_prefix('['));
let Some(rest) = after_open else {
return false;
};
let Some(close_idx) = rest.find(']') else {
return false;
};
if rest[..close_idx].trim().is_empty() {
return false;
}
let after = rest[close_idx + 1..].trim_start();
let after = after.strip_prefix(']').unwrap_or(after).trim_start();
after.is_empty() || after.starts_with('#')
}
fn dep_subtable_crate_name(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix('[')?;
let close_idx = rest.find(']')?;
let after = rest[close_idx + 1..].trim_start();
if !after.is_empty() && !after.starts_with('#') {
return None;
}
let inner = rest[..close_idx].trim();
let dep_name = inner.strip_prefix("dependencies")?.trim_start();
let dep_name = dep_name.strip_prefix('.')?.trim_start();
if dep_name.is_empty() {
return None;
}
Some(dep_name)
}
fn is_dep_subtable_boundary_marker(line: &str) -> bool {
dep_subtable_crate_name(line).is_some()
}
fn dep_section_has(dep_section: &[&str], crate_name: &str) -> bool {
dep_section.iter().any(|l| {
let t = l.trim_start();
if t.starts_with('#') {
return false;
}
if let Some(name) = dep_subtable_crate_name(l) {
return name == crate_name;
}
t.split_once('=')
.is_some_and(|(name, _)| name.trim() == crate_name)
})
}
fn existing_dep_declared_without_feature(existing: &str, crate_name: &str, feature: &str) -> bool {
let lines: Vec<&str> = existing.lines().collect();
let Some(deps_idx) = lines
.iter()
.position(|l| is_table_header(l, "dependencies"))
else {
return false;
};
let scan_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l) && !is_dep_subtable_boundary_marker(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let dep_section = &lines[deps_idx + 1..scan_end];
if let Some(sub_idx) = dep_section
.iter()
.position(|l| dep_subtable_crate_name(l) == Some(crate_name))
{
let sub_body_end = dep_section[sub_idx + 1..]
.iter()
.position(|l| is_any_table_header(l))
.map_or(dep_section.len(), |off| sub_idx + 1 + off);
let sub_body = dep_section[sub_idx + 1..sub_body_end].join("\n");
return !sub_body.contains(feature);
}
dep_section
.iter()
.find(|l| {
let t = l.trim_start();
!t.starts_with('#')
&& t.split_once('=')
.is_some_and(|(name, _)| name.trim() == crate_name)
})
.is_some_and(|line| !line.contains(feature))
}
pub(super) fn warn_if_existing_dep_missing_features(
plan: &mut Plan,
existing_cargo_toml: &str,
crate_name: &str,
features: &[&str],
) {
let missing: Vec<&str> = features
.iter()
.copied()
.filter(|feature| {
existing_dep_declared_without_feature(existing_cargo_toml, crate_name, feature)
})
.collect();
if !missing.is_empty() {
let feature_list = missing
.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ");
plan.warn(format!(
"Cargo.toml already declares '{crate_name}' without the generated code's \
required feature(s) — add {feature_list} to its `features` list by hand \
or the generated code may fail to compile."
));
}
}
fn extract_version_literal(rhs: &str) -> Option<&str> {
let rhs = rhs.trim();
let after = if let Some(idx) = rhs.find("version") {
&rhs[idx + "version".len()..]
} else if rhs.starts_with('"') {
rhs
} else {
return None;
};
let start = after.find('"')? + 1;
let end = after[start..].find('"')? + start;
Some(&after[start..end])
}
fn parse_major_minor(ver: &str) -> Option<(u64, u64)> {
let ver = ver.trim_start_matches(['^', '~', '=', '>', '<', ' ']);
let mut it = ver.split('.');
let major = it.next()?.trim().parse::<u64>().ok()?;
let minor = it
.next()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(0);
Some((major, minor))
}
fn existing_dep_version(existing: &str, crate_name: &str) -> Option<(u64, u64)> {
let lines: Vec<&str> = existing.lines().collect();
let deps_idx = lines
.iter()
.position(|l| is_table_header(l, "dependencies"))?;
let scan_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l) && !is_dep_subtable_boundary_marker(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let dep_section = &lines[deps_idx + 1..scan_end];
if let Some(sub_idx) = dep_section
.iter()
.position(|l| dep_subtable_crate_name(l) == Some(crate_name))
{
let sub_body_end = dep_section[sub_idx + 1..]
.iter()
.position(|l| is_any_table_header(l))
.map_or(dep_section.len(), |off| sub_idx + 1 + off);
for l in &dep_section[sub_idx + 1..sub_body_end] {
let t = l.trim_start();
if t.starts_with('#') {
continue;
}
if let Some((key, rhs)) = t.split_once('=')
&& key.trim() == "version"
{
return extract_version_literal(rhs).and_then(parse_major_minor);
}
}
return None;
}
for l in dep_section {
let t = l.trim_start();
if t.starts_with('#') {
continue;
}
let Some((name, rhs)) = t.split_once('=') else {
continue;
};
if name.trim() != crate_name {
continue;
}
return extract_version_literal(rhs).and_then(parse_major_minor);
}
None
}
fn existing_dep_version_below(
existing: &str,
crate_name: &str,
min_major: u64,
min_minor: u64,
) -> bool {
let lines: Vec<&str> = existing.lines().collect();
let Some(deps_idx) = lines
.iter()
.position(|l| is_table_header(l, "dependencies"))
else {
return false;
};
let scan_end = lines[deps_idx + 1..]
.iter()
.position(|l| is_any_table_header(l) && !is_dep_subtable_boundary_marker(l))
.map_or(lines.len(), |off| deps_idx + 1 + off);
let dep_section = &lines[deps_idx + 1..scan_end];
if !dep_section_has(dep_section, crate_name) {
return false;
}
existing_dep_version(existing, crate_name)
.is_none_or(|version| version < (min_major, min_minor))
}
pub(super) fn warn_if_existing_dep_below_version(
plan: &mut Plan,
existing_cargo_toml: &str,
crate_name: &str,
min_major: u64,
min_minor: u64,
) {
if existing_dep_version_below(existing_cargo_toml, crate_name, min_major, min_minor) {
plan.warn(format!(
"Cargo.toml already declares '{crate_name}', but not provably at \
version >= {min_major}.{min_minor} — the generated passkeys/2FA \
handlers use the '{crate_name}' 0.21+ `Engine`/\
`engine::general_purpose` API; make sure '{crate_name}' >= \
{min_major}.{min_minor} by hand or the generated code may fail to \
compile."
));
}
}
const RESERVED_RESOURCE_NAMES: &[&str] = &["main", "lib"];
pub(super) fn validate_resource_name(name: &str) -> Result<(), GenerateError> {
if name.is_empty() {
return Err(GenerateError::InvalidName(
name.to_owned(),
"name cannot be empty".into(),
));
}
let first = name.chars().next().expect("non-empty");
if !first.is_ascii_alphabetic() {
return Err(GenerateError::InvalidName(
name.to_owned(),
"must start with a letter".into(),
));
}
if let Some(bad) = name
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '_')
{
return Err(GenerateError::InvalidName(
name.to_owned(),
format!("contains invalid character '{bad}'"),
));
}
let snake_name = super::naming::snake(name);
if super::dsl::is_rust_keyword(&snake_name) {
return Err(GenerateError::InvalidName(
name.to_owned(),
format!(
"'{name}' is a Rust keyword (its snake_case form '{snake_name}' cannot be a module name)"
),
));
}
if RESERVED_RESOURCE_NAMES.contains(&snake_name.as_str()) {
return Err(GenerateError::InvalidName(
name.to_owned(),
format!(
"'{name}' is reserved — its snake_case form ('{snake_name}') collides with a special file"
),
));
}
Ok(())
}
const RESERVED_FIELD_NAMES: &[&str] = &["id", "created_at"];
fn validate_field_names(fields: &[Field]) -> Result<(), GenerateError> {
for f in fields {
if RESERVED_FIELD_NAMES.contains(&f.name.as_str()) {
return Err(GenerateError::InvalidField {
token: format!("{}:{}", f.name, f.rust_type()),
reason: format!(
"'{}' is reserved — the generator always emits this column",
f.name
),
});
}
}
Ok(())
}
const PRELUDE_TYPE_NAMES: &[&str] = &["String", "Vec", "Option", "Result", "Box", "Into"];
fn validate_enum_field_collisions(
pascal_name: &str,
fields: &[Field],
) -> Result<(), GenerateError> {
let mut seen_enum_types: Vec<(String, String)> = Vec::new();
for f in fields {
let Some(enum_ty) = f.enum_type_name() else {
continue;
};
if PRELUDE_TYPE_NAMES.contains(&enum_ty.as_str()) {
return Err(GenerateError::InvalidField {
token: format!("{}:enum{{...}}", f.name),
reason: format!(
"the generated enum type '{enum_ty}' would shadow the standard library's \
`{enum_ty}` for the rest of the file; rename the field"
),
});
}
let reserved = [
pascal_name.to_owned(),
format!("New{pascal_name}"),
format!("Update{pascal_name}"),
format!("{pascal_name}Field"),
format!("{pascal_name}DraftExt"),
format!("{pascal_name}Preload"),
format!("{pascal_name}Associations"),
format!("{pascal_name}Factory"),
format!("{pascal_name}Repository"),
format!("Pg{pascal_name}Repository"),
"DecodedForm".to_owned(),
];
if reserved.contains(&enum_ty) {
return Err(GenerateError::InvalidField {
token: format!("{}:enum{{...}}", f.name),
reason: format!(
"the generated enum type '{enum_ty}' collides with a type the generator \
already emits for '{pascal_name}'; rename the field"
),
});
}
if let Some((other_name, _)) = seen_enum_types.iter().find(|(_, ty)| *ty == enum_ty) {
return Err(GenerateError::InvalidField {
token: format!("{}:enum{{...}}", f.name),
reason: format!(
"the generated enum type '{enum_ty}' collides with the one generated for \
field '{other_name}'; rename one of the fields"
),
});
}
seen_enum_types.push((f.name.clone(), enum_ty));
}
Ok(())
}
#[cfg(test)]
pub fn parse_model_metadata(
fields: &[Field],
options: &ModelOptions,
) -> Result<ModelMetadata, GenerateError> {
parse_model_metadata_for(DatabaseBackend::Postgres, fields, options)
}
#[allow(
clippy::too_many_lines,
reason = "one validation pass per `--flag` that contributes model metadata; \
splitting it would scatter the shared `metadata` accumulator"
)]
pub fn parse_model_metadata_for(
backend: DatabaseBackend,
fields: &[Field],
options: &ModelOptions,
) -> Result<ModelMetadata, GenerateError> {
let mut metadata = ModelMetadata::default();
for index in &options.indexes {
let field_name = index.trim();
validate_known_field(fields, field_name, index)?;
metadata.indexes.insert(field_name.to_owned());
}
for validation in &options.validations {
let (field_name, rule) = split_key_value(validation, '=')?;
let field =
field_by_name(fields, field_name).ok_or_else(|| GenerateError::InvalidField {
token: validation.clone(),
reason: format!("unknown field '{field_name}'"),
})?;
let attr =
render_validation_attr(field, rule).map_err(|reason| GenerateError::InvalidField {
token: validation.clone(),
reason,
})?;
metadata
.validations
.entry(field_name.to_owned())
.or_default()
.push(attr);
}
for field in fields {
if field.constraints.is_empty() {
continue;
}
for attr in field.validation_attrs() {
let entry = metadata.validations.entry(field.name.clone()).or_default();
if !entry.contains(&attr) {
entry.push(attr);
}
}
}
for default in &options.defaults {
let (field_name, value) = split_key_value(default, '=')?;
let field =
field_by_name(fields, field_name).ok_or_else(|| GenerateError::InvalidField {
token: default.clone(),
reason: format!("unknown field '{field_name}'"),
})?;
if field.unique {
return Err(GenerateError::InvalidField {
token: default.clone(),
reason: format!(
"field '{field_name}' cannot be both `unique` and have a `--default` \
value — a defaulted unique column either only supports one row ever \
(a constant default collides with itself on every later insert) or \
has no form control to show a duplicate-value error against (the \
generated form omits defaulted fields). Remove one of the two."
),
});
}
let sql = sql_default_literal(field, value, backend).map_err(|reason| {
GenerateError::InvalidField {
token: default.clone(),
reason,
}
})?;
metadata.defaults.insert(field_name.to_owned(), sql);
}
if fields.iter().any(is_lock_version_column) {
metadata
.defaults
.entry(LOCK_VERSION_COLUMN.to_owned())
.or_insert_with(|| "0".to_owned());
}
for f in fields {
if f.kind.is_server_managed() {
metadata
.defaults
.entry(f.name.clone())
.or_insert_with(|| "0".to_owned());
}
}
if !options.searchable.is_empty() && options.id_type != IdType::BigSerial {
return Err(GenerateError::Config(format!(
"`--searchable` requires an i64 (bigint) primary key; full-text search does not \
yet support `{}` ids (the repository's `search_page` is hardcoded to `i64`). \
Re-run without `--searchable`, or use the default `--id bigint` primary key.",
options.id_type.rust_type()
)));
}
if !options.searchable.is_empty()
&& let Some(field) = fields
.iter()
.find(|f| f.name.eq_ignore_ascii_case("search_vector"))
{
return Err(GenerateError::Config(format!(
"`{}` is a reserved column name for `--searchable` models: `search_vector` is \
the generated tsvector column the FTS migration adds, so a model field of the \
same name collides with it (duplicate column at `autumn migrate`). Rename the \
field, or drop `--searchable`.",
field.name
)));
}
for (i, name) in options.searchable.iter().enumerate() {
let field_name = name.trim();
let field = field_by_name(fields, field_name).ok_or_else(|| {
GenerateError::Config(format!(
"--searchable names '{field_name}', which is not a field of this model. \
Only declared text fields can be full-text searchable."
))
})?;
if !is_string_like(field) {
return Err(GenerateError::Config(format!(
"--searchable field '{field_name}' is `{}`, but only text fields \
(`String`/`Text`) can be full-text searchable. Remove it from \
--searchable (numbers, bools, dates, and references are not text).",
field.rust_type()
)));
}
let weight = b"ABCD"[i.min(3)] as char;
metadata.searchable.push((field_name.to_owned(), weight));
}
if !metadata.searchable.is_empty() {
metadata.search_language = Some("english".to_owned());
}
Ok(metadata)
}
pub fn validate_translatable_fields(
fields: &[Field],
options: &ModelOptions,
) -> Result<(), GenerateError> {
for field in fields.iter().filter(|f| f.is_translatable()) {
let name = &field.name;
if field.unique {
return Err(GenerateError::Config(format!(
"field '{name}' is `{{translatable}}` and 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_{name}` lookup could never match. Put the uniqueness on a non-translatable column (e.g. a `slug`)."
)));
}
if options.indexes.iter().any(|i| i.trim() == *name) {
return Err(GenerateError::Config(format!(
"field '{name}' is `{{translatable}}` and cannot be `--index`ed: an equality index over a JSON container matches whole containers, never a single locale's value (the `#[model]` macro refuses `#[indexed]` + `#[translatable]` for the same reason). Drop it from `--index`."
)));
}
if options.searchable.iter().any(|s| s.trim() == *name) {
return Err(GenerateError::Config(format!(
"field '{name}' is `{{translatable}}` and cannot be `--searchable`: full-text search indexes the stored column, which is a JSON container — the index would match locale tags and JSON punctuation, not the prose (the `#[model]` macro refuses `#[searchable]` + `#[translatable]`). Drop it from `--searchable`, or keep a separate non-translatable column to search."
)));
}
if options.shard_key.as_deref().map(str::trim) == Some(name.as_str()) {
return Err(GenerateError::Config(format!(
"field '{name}' is `{{translatable}}` and cannot be the `--shard-key`: the shard is chosen by hashing the column value, and a container whose bytes change every time any locale is edited would move the row between shards. Shard on a stable column (e.g. `tenant_id`)."
)));
}
}
Ok(())
}
pub fn validate_encrypted_fields(
fields: &[Field],
options: &ModelOptions,
) -> Result<(), GenerateError> {
for field in fields {
if !field.is_encrypted() {
continue;
}
if field.is_randomized_encrypted() && field.unique {
return Err(GenerateError::InvalidField {
token: field.name.clone(),
reason: randomized_equality_lookup_reason(&field.name, "is `unique`"),
});
}
if field.is_randomized_encrypted() && options.indexes.iter().any(|i| i.trim() == field.name)
{
return Err(GenerateError::InvalidField {
token: field.name.clone(),
reason: randomized_equality_lookup_reason(&field.name, "has an `--index`"),
});
}
if options.shard_key.as_deref().map(str::trim) == Some(field.name.as_str()) {
return Err(GenerateError::InvalidField {
token: field.name.clone(),
reason: format!(
"field '{}' is `{{encrypted}}` and cannot be the `--shard-key`: the shard is \
chosen by hashing the column value, which is ciphertext on disk — a \
randomized column hashes differently on every write, and a deterministic \
one would leak plaintext equality through shard placement. Shard on a \
non-encrypted column (e.g. `tenant_id`).",
field.name
),
});
}
if options.searchable.iter().any(|s| s.trim() == field.name) {
return Err(GenerateError::InvalidField {
token: field.name.clone(),
reason: format!(
"field '{}' is `{{encrypted}}` and cannot be `--searchable`: full-text \
search indexes the stored column, which holds ciphertext, so plaintext \
searches would never match (the `#[model]` macro refuses `#[searchable]` \
+ `#[encrypted]`). Drop it from `--searchable`, or keep a separate \
non-encrypted column to search.",
field.name
),
});
}
if options
.defaults
.iter()
.filter_map(|d| d.split_once('=').map(|(name, _)| name.trim()))
.any(|name| name == field.name)
{
return Err(GenerateError::InvalidField {
token: field.name.clone(),
reason: format!(
"field '{}' is `{{encrypted}}` and cannot also have a `--default`: a \
defaulted column bypasses the insert path that encrypts the value, so the \
column would store unencrypted data the decrypting reader then rejects \
(the `#[model]` macro refuses `#[default]` + `#[encrypted]`). Set the \
value explicitly on insert instead.",
field.name
),
});
}
}
Ok(())
}
#[must_use]
pub fn encryption_key_material_warning(fields: &[Field]) -> Option<String> {
if !fields.iter().any(Field::is_encrypted) {
return None;
}
let deterministic = fields
.iter()
.any(|f| f.encrypted_mode() == Some(EncryptedMode::Deterministic));
let extra = if deterministic {
" and `active_record_encryption.deterministic_key` (required by the \
deterministic column(s) declared here)"
} else {
""
};
Some(format!(
"This model has at-rest encrypted column(s), which are inert until key material \
exists: reads and writes of them fail in dev and the app refuses to boot in \
production. Run `autumn credentials edit` and set \
`active_record_encryption.primary_key`, \
`active_record_encryption.key_derivation_salt`{extra} — each a fresh \
`openssl rand -hex 32` (16 for the salt). \
See docs/guide/attribute-encryption.md."
))
}
fn split_key_value(token: &str, sep: char) -> Result<(&str, &str), GenerateError> {
let (key, value) = token
.split_once(sep)
.ok_or_else(|| GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("expected `field{sep}value`"),
})?;
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
return Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("expected non-empty field and value in `field{sep}value`"),
});
}
Ok((key, value))
}
pub fn field_by_name<'a>(fields: &'a [Field], name: &str) -> Option<&'a Field> {
fields.iter().find(|field| field.name == name)
}
pub const LOCK_VERSION_COLUMN: &str = "lock_version";
#[must_use]
pub fn lock_version_field(fields: &[Field]) -> Option<&Field> {
field_by_name(fields, LOCK_VERSION_COLUMN)
}
#[must_use]
pub fn is_lock_version_column(field: &Field) -> bool {
field.name == LOCK_VERSION_COLUMN
&& !field.nullable
&& matches!(field.kind, FieldKind::I32 | FieldKind::I64)
}
pub fn validate_lock_version_field(
fields: &[Field],
defaults: &[String],
) -> Result<(), GenerateError> {
let Some(field) = lock_version_field(fields) else {
return Ok(());
};
if !is_lock_version_column(field) {
return Err(GenerateError::InvalidField {
token: format!("{}:{}", field.name, field.rust_type()),
reason: format!(
"the `{LOCK_VERSION_COLUMN}` column opts the model into optimistic locking \
(issue #575), so it must be a non-nullable `i32` or `i64` counter — the \
generated comparison reads it as an integer. Declare it as \
`{LOCK_VERSION_COLUMN}:i32` (or `{LOCK_VERSION_COLUMN}:i64`), or rename the \
column if it was not meant to be a lock version."
),
});
}
if field.unique {
return Err(GenerateError::InvalidField {
token: format!("{}:unique", field.name),
reason: format!(
"`{LOCK_VERSION_COLUMN}` cannot be `unique`: it is managed by the database \
and defaults to 0 on every insert, so a unique index on it would reject the \
second row ever created. Drop the `unique` marker."
),
});
}
let ceiling = if field.kind == FieldKind::I64 {
i64::MAX
} else {
i64::from(i32::MAX)
};
for default in defaults {
let Some((name, value)) = default.split_once('=') else {
continue;
};
if name.trim() != LOCK_VERSION_COLUMN {
continue;
}
if value.trim().parse::<i64>() == Ok(ceiling) {
return Err(GenerateError::InvalidField {
token: default.clone(),
reason: format!(
"`{LOCK_VERSION_COLUMN}` cannot be seeded at {ceiling}, the largest value \
`{ty}` can hold: the generated UPDATE increments the column in SQL, so the \
first save on every row would fail with `integer out of range`. Seed a \
lower value, or declare `{LOCK_VERSION_COLUMN}:i64` for more headroom.",
ty = field.rust_type(),
),
});
}
}
Ok(())
}
pub fn apply_unique_flags(fields: &mut [Field], uniques: &[String]) -> Result<(), GenerateError> {
for unique in uniques {
let field_name = unique.trim();
validate_known_field(fields, field_name, unique)?;
if let Some(field) = fields.iter_mut().find(|f| f.name == field_name) {
field.unique = true;
}
}
Ok(())
}
fn validate_known_field(
fields: &[Field],
field_name: &str,
token: &str,
) -> Result<(), GenerateError> {
if field_by_name(fields, field_name).is_some() {
Ok(())
} else {
Err(GenerateError::InvalidField {
token: token.to_owned(),
reason: format!("unknown field '{field_name}'"),
})
}
}
fn render_validation_attr(field: &Field, rule: &str) -> Result<String, String> {
if rule == "url" || rule == "email" {
if !is_string_like(field) || field.kind.is_rich_text() {
return Err(format!("{rule} validation requires String or Text fields"));
}
return Ok(rule.to_owned());
}
let Some(rest) = rule.strip_prefix("length:") else {
return Err("supported validation rules: url, email, length:min=N,max=N".to_owned());
};
if !is_string_like(field) {
return Err("length validation requires String or Text fields".to_owned());
}
let mut min = None;
let mut max = None;
for part in rest.split(',') {
let (key, value) = part
.split_once('=')
.ok_or_else(|| "length validation expects min=N and/or max=N".to_owned())?;
let parsed = value
.trim()
.parse::<u64>()
.map_err(|_| "length validation bounds must be unsigned integers".to_owned())?;
match key.trim() {
"min" => min = Some(parsed),
"max" => max = Some(parsed),
other => return Err(format!("unsupported length validation option '{other}'")),
}
}
if min.is_none() && max.is_none() {
return Err("length validation needs at least min=N or max=N".to_owned());
}
if let (Some(min), Some(max)) = (min, max)
&& min > max
{
return Err(format!(
"length validation's min ({min}) cannot be greater than its max ({max})"
));
}
let mut args = Vec::new();
if let Some(min) = min {
args.push(format!("min = {min}"));
}
if let Some(max) = max {
args.push(format!("max = {max}"));
}
Ok(format!("length({})", args.join(", ")))
}
const fn is_string_like(field: &Field) -> bool {
matches!(
field.kind,
FieldKind::String | FieldKind::Text | FieldKind::RichText
)
}
fn unquote_default_value(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(value)
}
fn validate_decimal_default_fits(value: &str, precision: u32, scale: u32) -> Result<(), String> {
let unsigned = value
.strip_prefix('-')
.or_else(|| value.strip_prefix('+'))
.unwrap_or(value);
let (int_part, frac_part) = unsigned.split_once('.').unwrap_or((unsigned, ""));
let frac_digits = frac_part.trim_end_matches('0').len();
if frac_digits > scale as usize {
return Err(format!(
"decimal default '{value}' has {frac_digits} fractional digit(s), but \
decimal{{{precision},{scale}}} only allows {scale}"
));
}
let int_digits = int_part.trim_start_matches('0').len();
let max_int_digits = precision - scale;
if int_digits > max_int_digits as usize {
return Err(format!(
"decimal default '{value}' has {int_digits} integer digit(s), but \
decimal{{{precision},{scale}}} only allows {max_int_digits}"
));
}
Ok(())
}
fn sql_default_literal(
field: &Field,
value: &str,
backend: DatabaseBackend,
) -> Result<String, String> {
match field.kind {
FieldKind::Bool => match value.to_ascii_lowercase().as_str() {
"true" => Ok("TRUE".to_owned()),
"false" => Ok("FALSE".to_owned()),
_ => Err("bool defaults must be true or false".to_owned()),
},
FieldKind::String | FieldKind::Text | FieldKind::RichText => {
let unquoted = unquote_default_value(value);
Ok(format!("'{}'", unquoted.replace('\'', "''")))
}
FieldKind::I32 => value
.parse::<i32>()
.map(|_| value.to_owned())
.map_err(|_| "i32 defaults must fit the SQL INTEGER range".to_owned()),
FieldKind::I64 => value
.parse::<i64>()
.map(|_| value.to_owned())
.map_err(|_| "integer defaults must be valid integers".to_owned()),
FieldKind::F32 | FieldKind::F64 => value
.parse::<f64>()
.map(|_| value.to_owned())
.map_err(|_| "float defaults must be valid numbers".to_owned()),
FieldKind::Decimal { precision, scale } => {
let parsed: f64 = value
.parse()
.map_err(|_| "decimal defaults must be valid numbers".to_owned())?;
if !parsed.is_finite() {
return Err("decimal defaults must be finite numbers (not NaN/infinity)".to_owned());
}
if value.contains(['e', 'E']) {
return Err(
"decimal defaults must be written in plain notation (e.g. '19.99'), \
not scientific notation"
.to_owned(),
);
}
validate_decimal_default_fits(value, precision, scale)?;
match backend {
DatabaseBackend::Postgres => Ok(value.to_owned()),
DatabaseBackend::Sqlite => {
use autumn_web::reexports::rust_decimal::Decimal;
let decimal = value.parse::<Decimal>().map_err(|err| {
format!(
"decimal default '{value}' is not representable as a \
rust_decimal::Decimal, the Rust type a SQLite decimal \
column round-trips through: {err}"
)
})?;
Ok(format!("'{}'", decimal.normalize()))
}
}
}
FieldKind::Json => {
serde_json::from_str::<serde_json::Value>(value)
.map_err(|err| format!("json default '{value}' is not valid JSON: {err}"))?;
Ok(format!("'{}'", value.replace('\'', "''")))
}
FieldKind::Uuid
| FieldKind::NaiveDateTime
| FieldKind::DateTime
| FieldKind::Bytea
| FieldKind::Attachment
| FieldKind::References
| FieldKind::Slug
| FieldKind::Position
| FieldKind::Commentable => Err(format!(
"defaults for {} fields are not supported by `autumn generate` yet",
field.rust_type()
)),
FieldKind::Enum => {
let unquoted = unquote_default_value(value);
if field.variants.iter().any(|v| v == unquoted) {
Ok(format!("'{unquoted}'"))
} else {
Err(format!(
"'{unquoted}' is not a variant of this enum; expected one of: {}",
field.variants.join(", ")
))
}
}
}
}
#[cfg(test)]
#[must_use]
pub(super) fn render_model_file_for_test(name: &str, table: &str, fields: &[Field]) -> String {
render_model_file(
name,
table,
fields,
&ModelMetadata::default(),
false,
None,
IdType::BigSerial,
DatabaseBackend::Postgres,
)
}
#[must_use]
#[allow(
clippy::too_many_lines,
reason = "This is a single template emitting one enum type plus its trait \
impls — splitting it produces less readable output, not more."
)]
fn render_enum_decl(
field: &Field,
default_variant: Option<&str>,
backend: DatabaseBackend,
) -> String {
use std::fmt::Write as _;
let ty = field
.enum_type_name()
.expect("render_enum_decl called on a non-enum field");
let variants: Vec<String> = field.variants.iter().map(|v| pascal(v)).collect();
let mut out = String::new();
let default_raw = default_variant.unwrap_or_else(|| {
field
.variants
.first()
.expect("an enum field always has at least one variant")
});
let derives = [
"Debug",
"Clone",
"Copy",
"PartialEq",
"Eq",
"Default",
"serde::Serialize",
"serde::Deserialize",
"diesel::expression::AsExpression",
"diesel::deserialize::FromSqlRow",
];
let _ = writeln!(out, "#[derive({})]", derives.join(", "));
out.push_str("#[diesel(sql_type = diesel::sql_types::Text)]\n");
let _ = writeln!(out, "pub enum {ty} {{");
for (raw, variant) in field.variants.iter().zip(&variants) {
if raw == default_raw {
out.push_str(" #[default]\n");
}
let _ = writeln!(out, " #[serde(rename = \"{raw}\")]");
let _ = writeln!(out, " {variant},");
}
out.push_str("}\n\n");
let _ = writeln!(out, "impl {ty} {{");
let _ = writeln!(
out,
" pub const VARIANTS: [Self; {}] = [{}];",
variants.len(),
variants
.iter()
.map(|v| format!("Self::{v}"))
.collect::<Vec<_>>()
.join(", ")
);
out.push('\n');
out.push_str(" #[must_use]\n");
out.push_str(" pub const fn as_str(&self) -> &'static str {\n");
out.push_str(" match self {\n");
for (raw, variant) in field.variants.iter().zip(&variants) {
let _ = writeln!(out, " Self::{variant} => \"{raw}\",");
}
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str("}\n\n");
let _ = writeln!(out, "impl std::fmt::Display for {ty} {{");
out.push_str(" fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n");
out.push_str(" f.write_str(self.as_str())\n");
out.push_str(" }\n");
out.push_str("}\n\n");
let _ = writeln!(out, "impl std::str::FromStr for {ty} {{");
out.push_str(" type Err = String;\n\n");
out.push_str(" fn from_str(s: &str) -> Result<Self, Self::Err> {\n");
out.push_str(" match s {\n");
for (raw, variant) in field.variants.iter().zip(&variants) {
let _ = writeln!(out, " \"{raw}\" => Ok(Self::{variant}),");
}
let _ = writeln!(
out,
" _ => Err(\"must be one of {}\".to_owned()),",
field.variants.join(", ")
);
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str("}\n\n");
match backend {
DatabaseBackend::Postgres => {
let _ = writeln!(
out,
"impl diesel::serialize::ToSql<diesel::sql_types::Text, diesel::pg::Pg> for {ty} {{"
);
out.push_str(
" fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {\n",
);
out.push_str(
" <str as diesel::serialize::ToSql<diesel::sql_types::Text, diesel::pg::Pg>>::to_sql(self.as_str(), out)\n",
);
out.push_str(" }\n");
out.push_str("}\n\n");
let _ = writeln!(
out,
"impl diesel::deserialize::FromSql<diesel::sql_types::Text, diesel::pg::Pg> for {ty} {{"
);
out.push_str(
" fn from_sql(bytes: diesel::pg::PgValue<'_>) -> diesel::deserialize::Result<Self> {\n",
);
let _ = writeln!(
out,
" let s = <String as diesel::deserialize::FromSql<diesel::sql_types::Text, diesel::pg::Pg>>::from_sql(bytes)?;"
);
out.push_str(" s.parse().map_err(Into::into)\n");
out.push_str(" }\n");
out.push_str("}\n");
}
DatabaseBackend::Sqlite => {
let _ = writeln!(
out,
"impl diesel::serialize::ToSql<diesel::sql_types::Text, diesel::sqlite::Sqlite> for {ty} {{"
);
out.push_str(
" fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result {\n",
);
out.push_str(" out.set_value(self.as_str());\n");
out.push_str(" Ok(diesel::serialize::IsNull::No)\n");
out.push_str(" }\n");
out.push_str("}\n\n");
let _ = writeln!(
out,
"impl diesel::deserialize::FromSql<diesel::sql_types::Text, diesel::sqlite::Sqlite> for {ty} {{"
);
out.push_str(
" fn from_sql(bytes: <diesel::sqlite::Sqlite as diesel::backend::Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {\n",
);
let _ = writeln!(
out,
" let s = <String as diesel::deserialize::FromSql<diesel::sql_types::Text, diesel::sqlite::Sqlite>>::from_sql(bytes)?;"
);
out.push_str(" s.parse().map_err(Into::into)\n");
out.push_str(" }\n");
out.push_str("}\n");
}
}
out
}
fn push_decimal_shape_attr(out: &mut String, kind: FieldKind) {
use std::fmt::Write as _;
if let FieldKind::Decimal { precision, scale } = kind {
let _ = writeln!(
out,
" #[decimal_shape(precision = {precision}, scale = {scale})]"
);
}
}
#[allow(
clippy::too_many_arguments,
reason = "one parameter per axis of the emitted model file; a struct here would \
only rename the same list"
)]
fn render_model_file(
name: &str,
table: &str,
fields: &[Field],
metadata: &ModelMetadata,
soft_delete: bool,
shard_key: Option<&str>,
id_type: IdType,
backend: DatabaseBackend,
) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(fields.len() * 128 + 256);
out.push_str("//! Generated by `autumn generate`.\n");
out.push_str("//!\n");
out.push_str("//! Edit this file freely — once a generator has run, the\n");
out.push_str("//! framework treats this as ordinary user code.\n\n");
let _ = writeln!(out, "use crate::schema::{table};");
out.push('\n');
for f in fields {
if f.is_enum() {
let default_variant = metadata
.defaults
.get(&f.name)
.and_then(|literal| literal.strip_prefix('\''))
.and_then(|s| s.strip_suffix('\''));
out.push_str(&render_enum_decl(f, default_variant, backend));
out.push('\n');
}
}
if fields.iter().any(|f| f.kind.is_commentable()) {
out.push_str(
"// Threaded, polymorphic comments (#1367): one shared `comments` table,\n\
// keyed on `(commentable_type, commentable_id)`, attaches to any number of\n\
// models. Point `by` at this app's author model, and add\n\
// `author_name = <column>` to render display names instead of `user #id`.\n",
);
}
out.push_str("#[autumn_web::model]\n");
if let Some(counter) = fields.iter().find(|f| f.kind.is_commentable()) {
let by = metadata
.commentable_author
.as_deref()
.map_or_else(String::new, |author| format!("by = {author}, "));
let _ = writeln!(out, "#[commentable({by}counter_cache = {})]", counter.name);
}
if let Some(language) = metadata.search_language() {
let _ = writeln!(out, "#[searchable(language = \"{language}\")]");
}
if let Some(key) = shard_key {
let _ = writeln!(out, "#[shard_key = \"{key}\"]");
}
let _ = writeln!(out, "pub struct {name} {{");
out.push_str(" #[id]\n");
let _ = writeln!(out, " pub id: {},", id_type.rust_type_for(backend));
for f in fields {
if metadata.indexes.contains(&f.name) {
out.push_str(" #[indexed]\n");
}
if let Some((_, weight)) = metadata.searchable().iter().find(|(n, _)| n == &f.name) {
let _ = writeln!(out, " #[searchable(weight = \"{weight}\")]");
}
if let Some(validations) = metadata.validations.get(&f.name) {
for validation in validations {
let _ = writeln!(out, " #[validate({validation})]");
}
}
if is_lock_version_column(f) {
out.push_str(" #[lock_version]\n");
} else if f.kind.is_position() {
out.push_str(" #[position]\n");
} else if metadata.defaults.contains_key(&f.name) {
out.push_str(" #[default]\n");
}
if let Some(sm) = &f.state_machine {
let mut inner = String::new();
for (i, t) in sm.transitions.iter().enumerate() {
if i > 0 {
inner.push_str(", ");
}
let _ = write!(inner, "{} -> {}", t.from, t.to);
if let Some(guard) = &t.guard {
let _ = write!(inner, ": \"{guard}\"");
}
}
let _ = writeln!(out, " #[state_machine(transitions({inner}))]");
}
match f.encrypted_mode() {
Some(EncryptedMode::Randomized) => out.push_str(" #[encrypted]\n"),
Some(EncryptedMode::Deterministic) => {
out.push_str(" #[encrypted(deterministic)]\n");
}
None => {}
}
if f.is_translatable() {
out.push_str(" #[translatable]\n");
}
push_decimal_shape_attr(&mut out, f.kind);
if f.kind.is_rich_text() {
let _ = writeln!(out, " /// {RICH_TEXT_MARKER_DOC}");
}
let _ = writeln!(out, " pub {}: {},", f.name, f.rust_type_for(backend));
}
if soft_delete {
out.push_str(" #[default]\n");
out.push_str(" pub deleted_at: Option<chrono::NaiveDateTime>,\n");
}
out.push_str(" #[default]\n");
out.push_str(" pub created_at: chrono::NaiveDateTime,\n");
out.push_str("}\n");
out
}
#[cfg(test)]
#[allow(clippy::literal_string_with_formatting_args)]
mod tests {
use super::*;
use crate::generate::Flags;
use crate::generate::emit::Action;
use std::fs;
use tempfile::TempDir;
fn project() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
tmp
}
fn project_with_autumn_web_dep() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = { version = \"0.6\" }\n",
)
.unwrap();
tmp
}
fn paths(plan: &Plan) -> Vec<String> {
plan.actions
.iter()
.map(|a| {
a.path()
.strip_prefix(&plan.project_root)
.unwrap()
.display()
.to_string()
.replace('\\', "/")
})
.collect()
}
#[test]
fn plan_creates_expected_file_set() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.unwrap();
let p = paths(&plan);
assert!(p.contains(&"src/models/post.rs".into()));
assert!(p.contains(&"src/models/mod.rs".into()));
assert!(p.contains(&"migrations/20260427000000_create_posts/up.sql".into()));
assert!(p.contains(&"migrations/20260427000000_create_posts/down.sql".into()));
assert!(p.contains(&"src/schema.rs".into()));
}
#[test]
fn plan_records_reverts_for_mod_decl_schema_table_and_cargo_deps() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
assert!(plan.reverts.iter().any(|r| matches!(
r,
crate::generate::emit::Revert::ModDecl { name, .. } if name == "post"
)));
assert!(plan.reverts.iter().any(|r| matches!(
r,
crate::generate::emit::Revert::SchemaTable { table, .. } if table == "posts"
)));
assert!(plan.reverts.iter().any(|r| matches!(
r,
crate::generate::emit::Revert::CargoDeps { names, .. } if names.iter().any(|n| n == "diesel")
)));
}
#[test]
fn generate_then_destroy_model_round_trips_to_original_project_state() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\ndiesel_migrations = \"2\"\n",
)
.unwrap();
let cargo_path = tmp.path().join("Cargo.toml");
let original_cargo = fs::read_to_string(&cargo_path).unwrap();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
assert!(tmp.path().join("src/models/post.rs").exists());
let destroy_plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"99999999999999",
)
.unwrap();
destroy_plan.revert(Flags::default()).unwrap();
assert!(!tmp.path().join("src/models/post.rs").exists());
assert!(!tmp.path().join("src/models/mod.rs").exists());
assert!(!tmp.path().join("src/schema.rs").exists());
assert!(
fs::read_dir(tmp.path().join("migrations")).map_or(true, |mut d| d.next().is_none()),
"migration directory must be removed"
);
assert_eq!(fs::read_to_string(&cargo_path).unwrap(), original_cargo);
}
#[test]
fn destroying_one_of_two_models_keeps_shared_dep_the_other_still_needs() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\ndiesel_migrations = \"2\"\n",
)
.unwrap();
let cargo_path = tmp.path().join("Cargo.toml");
plan_model(tmp.path(), "Post", &["owner:Uuid".into()], "20260427000000")
.unwrap()
.execute(Flags::default())
.unwrap();
plan_model(
tmp.path(),
"Comment",
&["owner:Uuid".into()],
"20260427000001",
)
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(fs::read_to_string(&cargo_path).unwrap().contains("uuid"));
plan_model(tmp.path(), "Post", &["owner:Uuid".into()], "99999999999999")
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("src/models/post.rs").exists());
assert!(tmp.path().join("src/models/comment.rs").exists());
let cargo_after = fs::read_to_string(&cargo_path).unwrap();
assert!(
cargo_after.contains("uuid"),
"uuid must survive — Comment's model still uses it: {cargo_after}"
);
plan_model(
tmp.path(),
"Comment",
&["owner:Uuid".into()],
"99999999999998",
)
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("src/models/comment.rs").exists());
assert!(
!fs::read_to_string(&cargo_path).unwrap().contains("uuid"),
"uuid must be removed once no model uses it anymore"
);
}
#[test]
fn destroying_last_model_keeps_dep_still_used_by_hand_written_code() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\ndiesel_migrations = \"2\"\n",
)
.unwrap();
let cargo_path = tmp.path().join("Cargo.toml");
plan_model(tmp.path(), "Post", &["owner:Uuid".into()], "20260427000000")
.unwrap()
.execute(Flags::default())
.unwrap();
fs::create_dir_all(tmp.path().join("src/tasks")).unwrap();
fs::write(
tmp.path().join("src/tasks/cleanup.rs"),
"pub fn new_id() -> uuid::Uuid {\n uuid::Uuid::new_v4()\n}\n",
)
.unwrap();
assert!(fs::read_to_string(&cargo_path).unwrap().contains("uuid"));
plan_model(tmp.path(), "Post", &["owner:Uuid".into()], "99999999999999")
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("src/models/post.rs").exists());
assert!(
fs::read_to_string(&cargo_path).unwrap().contains("uuid"),
"uuid must survive — hand-written src/tasks/cleanup.rs still uses it"
);
}
#[test]
fn plan_rejects_lowercase_first_char() {
let tmp = project();
let err = plan_model(tmp.path(), "123Bad", &[], "20260427000000").unwrap_err();
assert!(matches!(err, GenerateError::InvalidName(_, _)));
}
#[test]
fn plan_rejects_reserved_resource_names() {
for name in ["mod", "main", "lib", "Mod"] {
let tmp = project();
let err = plan_model(tmp.path(), name, &[], "20260427000000").unwrap_err();
assert!(
matches!(err, GenerateError::InvalidName(_, _)),
"expected '{name}' to be rejected"
);
}
}
#[test]
fn plan_rejects_keyword_resource_names() {
for name in ["Type", "type", "Match", "match", "Self", "Trait"] {
let tmp = project();
let err = plan_model(tmp.path(), name, &[], "20260427000000").unwrap_err();
assert!(
matches!(err, GenerateError::InvalidName(_, _)),
"expected '{name}' to be rejected as a keyword"
);
assert!(
err.to_string().contains("keyword"),
"expected keyword error for '{name}'; got: {err}"
);
}
}
#[test]
fn plan_rejects_id_or_created_at_as_user_field() {
for token in ["id:i64", "created_at:NaiveDateTime"] {
let tmp = project();
let err =
plan_model(tmp.path(), "Post", &[token.into()], "20260427000000").unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected '{token}' to be rejected"
);
assert!(
err.to_string().contains("reserved"),
"expected reserved-field error for '{token}'; got: {err}"
);
}
}
#[test]
fn plan_rejects_unsupported_field_type() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&["price:Money".into()],
"20260427000000",
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn plan_outside_project_root_errors() {
let tmp = TempDir::new().unwrap();
let err = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap_err();
assert!(matches!(err, GenerateError::NotInProject));
}
#[test]
fn model_file_declares_enum_type() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:enum{draft,published,archived}".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(model.contains("pub enum Status"), "got:\n{model}");
assert!(
model.contains("#[diesel(sql_type = diesel::sql_types::Text)]"),
"got:\n{model}"
);
assert!(
model.contains("diesel::expression::AsExpression"),
"got:\n{model}"
);
assert!(
model.contains("diesel::deserialize::FromSqlRow"),
"got:\n{model}"
);
assert!(
model.contains("#[serde(rename = \"draft\")]"),
"got:\n{model}"
);
assert!(
model.contains("#[serde(rename = \"published\")]"),
"got:\n{model}"
);
assert!(
model.contains("#[serde(rename = \"archived\")]"),
"got:\n{model}"
);
assert!(model.contains("Draft,"), "got:\n{model}");
assert!(model.contains("Published,"), "got:\n{model}");
assert!(model.contains("Archived,"), "got:\n{model}");
assert!(model.contains("pub status: Status,"), "got:\n{model}");
}
#[test]
fn model_file_without_state_machine_emits_no_attribute() {
let fields = crate::generate::dsl::parse_fields(&[
"title:String".into(),
"body:Text".into(),
"published:bool".into(),
])
.unwrap();
let model = render_model_file_for_test("Post", "posts", &fields);
assert!(
!model.contains("#[state_machine"),
"no state machine declared, so no attribute should render; got:\n{model}"
);
}
#[test]
fn model_file_emits_state_machine_attribute() {
let fields = crate::generate::dsl::parse_fields(&[
"status:String:states(draft -> published: can_publish, published -> archived)".into(),
])
.unwrap();
let model = render_model_file_for_test("Page", "pages", &fields);
assert!(
model.contains(
"#[state_machine(transitions(draft -> published: \"can_publish\", \
published -> archived))]"
),
"got:\n{model}"
);
assert!(model.contains("pub status: String,"), "got:\n{model}");
}
#[test]
fn model_file_without_translatable_field_emits_no_attribute() {
let fields =
crate::generate::dsl::parse_fields(&["title:String".into(), "body:Text".into()])
.unwrap();
let model = render_model_file_for_test("Post", "posts", &fields);
assert!(
!model.contains("#[translatable"),
"nothing declared translatable, so no attribute should render; got:\n{model}"
);
assert!(!model.contains("Translated"), "got:\n{model}");
}
#[test]
fn model_file_emits_translatable_attribute_and_container_type() {
let fields = crate::generate::dsl::parse_fields(&[
"title:String{translatable}".into(),
"body:Text{translatable}".into(),
"slug:String".into(),
])
.unwrap();
let model = render_model_file_for_test("Post", "posts", &fields);
assert!(
model.contains(" #[translatable]\n pub title: autumn_web::i18n::Translated,"),
"got:\n{model}"
);
assert!(
model.contains(" #[translatable]\n pub body: autumn_web::i18n::Translated,"),
"got:\n{model}"
);
assert!(model.contains(" pub slug: String,"), "got:\n{model}");
assert!(
!model.contains("#[translatable]\n pub slug"),
"plain column must not pick up the attribute; got:\n{model}"
);
}
#[test]
fn translatable_model_plan_emits_model_schema_migration_and_feature() {
let tmp = project_with_autumn_web_dep();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String{translatable}".into(), "slug:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains(" #[translatable]\n pub title: autumn_web::i18n::Translated,"),
"model: {model}"
);
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(schema.contains("title -> Text,"), "schema: {schema}");
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("title TEXT NOT NULL DEFAULT '{}'"),
"up.sql: {up}"
);
assert!(
crate::migrate::safety::is_safe(&crate::migrate::safety::classify_sql(&up)),
"generated migration must classify safe: {up}"
);
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo.contains("i18n"),
"generate model must enable autumn-web's `i18n` feature: {cargo}"
);
}
#[test]
fn a_translatable_model_registers_a_revert_for_the_i18n_feature() {
let tmp = project_with_autumn_web_dep();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String{translatable}".into()],
"20260427000000",
)
.unwrap();
let has_feature_revert = plan.reverts.iter().any(|r| {
matches!(
r,
crate::generate::emit::Revert::CargoAutumnWebFeature { feature, owner_dir, .. }
if feature == "i18n"
&& owner_dir.as_deref() == Some(&tmp.path().join("src/models"))
)
});
assert!(
has_feature_revert,
"expected a CargoAutumnWebFeature revert owned by src/models, got {:?}",
plan.reverts
);
plan.execute(Flags::default()).unwrap();
let replanned = plan_model(
tmp.path(),
"Post",
&["title:String{translatable}".into()],
"20260427000000",
)
.unwrap();
assert!(
replanned.reverts.iter().any(|r| matches!(
r,
crate::generate::emit::Revert::CargoAutumnWebFeature { feature, .. }
if feature == "i18n"
)),
"the revert must survive a replan where the feature is already present"
);
}
#[test]
fn a_plain_model_plan_does_not_enable_the_i18n_feature() {
let tmp = project_with_autumn_web_dep();
let before = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap_or_default();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let after = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap_or_default();
assert_eq!(before.contains("i18n"), after.contains("i18n"));
}
#[test]
fn translatable_columns_refuse_the_flag_spellings_of_their_restrictions() {
let cases: [(&str, ModelOptions); 4] = [
(
"--unique",
ModelOptions {
uniques: vec!["title".into()],
..ModelOptions::default()
},
),
(
"--index",
ModelOptions {
indexes: vec!["title".into()],
..ModelOptions::default()
},
),
(
"--searchable",
ModelOptions {
searchable: vec!["title".into()],
..ModelOptions::default()
},
),
(
"--shard-key",
ModelOptions {
shard_key: Some("title".into()),
..ModelOptions::default()
},
),
];
for (flag, options) in cases {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Post",
&["title:String{translatable}".into()],
"20260427000000",
&options,
)
.unwrap_err()
.to_string();
assert!(err.contains("translatable"), "{flag}: {err}");
assert!(err.contains("title"), "{flag}: {err}");
}
}
#[test]
fn model_file_without_encrypted_field_emits_no_attribute() {
let fields = crate::generate::dsl::parse_fields(&[
"title:String".into(),
"body:Text".into(),
"published:bool".into(),
])
.unwrap();
let model = render_model_file_for_test("Post", "posts", &fields);
assert!(
!model.contains("#[encrypted"),
"no encryption declared, so no attribute should render; got:\n{model}"
);
}
#[test]
fn model_file_emits_encrypted_attributes_for_both_modes() {
let fields = crate::generate::dsl::parse_fields(&[
"api_token:String{encrypted}".into(),
"email:String{encrypted:deterministic}".into(),
"username:String".into(),
])
.unwrap();
let model = render_model_file_for_test("Account", "accounts", &fields);
assert!(
model.contains(" #[encrypted]\n pub api_token: String,"),
"randomized column must carry a bare `#[encrypted]`; got:\n{model}"
);
assert!(
model.contains(" #[encrypted(deterministic)]\n pub email: String,"),
"deterministic column must carry the mode; got:\n{model}"
);
assert!(
model.contains(" pub username: String,"),
"plain column must be untouched; got:\n{model}"
);
assert!(
!model.contains("#[encrypted]\n pub username"),
"plain column must not pick up the attribute; got:\n{model}"
);
}
#[test]
fn model_file_emits_encrypted_alongside_validation_attributes() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Account",
&["email:String{encrypted:deterministic,max=254,email}".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/account.rs")).unwrap();
assert!(
model.contains("#[validate(length(max = 254))]"),
"got:\n{model}"
);
assert!(model.contains("#[validate(email)]"), "got:\n{model}");
assert!(
model.contains("#[encrypted(deterministic)]"),
"got:\n{model}"
);
assert!(model.contains("pub email: String,"), "got:\n{model}");
}
#[test]
fn encrypted_column_migration_is_text_with_envelope_comment() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Account",
&[
"username:String".into(),
"api_token:String{encrypted}".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_accounts/up.sql"),
)
.unwrap();
assert!(up.contains("api_token TEXT NOT NULL"), "up.sql: {up}");
let (head, _) = up
.split_once("CREATE TABLE")
.unwrap_or_else(|| panic!("up.sql: {up}"));
assert!(
head.lines()
.all(|l| l.trim().is_empty() || l.trim_start().starts_with("--")),
"only comments may precede CREATE TABLE: {up}"
);
assert!(
head.contains("api_token"),
"migration must name the encrypted column in a comment: {up}"
);
assert!(
head.contains("base64") && head.contains("envelope"),
"migration comment must explain the ciphertext envelope sizing: {up}"
);
assert!(
head.contains("VARCHAR"),
"migration comment must warn against narrowing to a bounded type: {up}"
);
}
#[test]
fn unencrypted_model_migration_has_no_encryption_comment() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(!up.contains("envelope"), "up.sql: {up}");
assert!(up.starts_with("CREATE TABLE posts ("), "up.sql: {up}");
}
#[test]
fn unique_flag_on_randomized_encrypted_field_is_rejected() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
&ModelOptions {
uniques: vec!["api_token".into()],
..ModelOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("api_token"), "must name the field: {msg}");
assert!(
msg.contains("deterministic"),
"must point at the fix: {msg}"
);
}
#[test]
fn unique_flag_on_deterministic_encrypted_field_is_allowed() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Account",
&["email:String{encrypted:deterministic}".into()],
"20260427000000",
&ModelOptions {
uniques: vec!["email".into()],
..ModelOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_accounts/up.sql"),
)
.unwrap();
assert!(up.contains("CREATE UNIQUE INDEX"), "up.sql: {up}");
}
#[test]
fn searchable_flag_on_encrypted_field_is_rejected() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Account",
&["notes:Text{encrypted}".into()],
"20260427000000",
&ModelOptions {
searchable: vec!["notes".into()],
..ModelOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("notes"), "must name the field: {msg}");
assert!(
msg.contains("encrypted") && msg.contains("searchable"),
"must name both sides of the conflict: {msg}"
);
}
#[test]
fn default_flag_on_encrypted_field_is_rejected() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
&ModelOptions {
defaults: vec!["api_token=none".into()],
..ModelOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("api_token"), "must name the field: {msg}");
assert!(msg.contains("encrypted"), "must name the conflict: {msg}");
}
#[test]
fn encrypted_model_warns_about_missing_key_material() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
)
.unwrap();
let warning = plan
.warnings
.iter()
.find(|w| w.contains("encrypt"))
.unwrap_or_else(|| panic!("expected an encryption warning; got {:?}", plan.warnings));
assert!(
warning.contains("autumn credentials edit"),
"warning must name the command: {warning}"
);
assert!(
warning.contains("active_record_encryption.primary_key"),
"warning must name the credential: {warning}"
);
assert!(
!warning.contains("deterministic_key"),
"a randomized-only model needs no deterministic key: {warning}"
);
}
#[test]
fn deterministic_encrypted_model_warns_about_the_deterministic_key() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Account",
&["email:String{encrypted:deterministic}".into()],
"20260427000000",
)
.unwrap();
let warning = plan
.warnings
.iter()
.find(|w| w.contains("encrypt"))
.unwrap_or_else(|| panic!("expected an encryption warning; got {:?}", plan.warnings));
assert!(
warning.contains("deterministic_key"),
"a deterministic column needs the deterministic key: {warning}"
);
}
#[test]
fn unencrypted_model_emits_no_encryption_warning() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
assert!(
!plan.warnings.iter().any(|w| w.contains("encrypt")),
"got: {:?}",
plan.warnings
);
}
#[test]
fn model_file_enum_impls_display_fromstr_tosql_fromsql() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["status:enum{draft,published}".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("impl std::fmt::Display for Status"),
"got:\n{model}"
);
assert!(
model.contains("impl std::str::FromStr for Status"),
"got:\n{model}"
);
assert!(
model.contains("must be one of draft, published"),
"got:\n{model}"
);
assert!(
model.contains(
"impl diesel::serialize::ToSql<diesel::sql_types::Text, diesel::pg::Pg> for Status"
),
"got:\n{model}"
);
assert!(
model.contains("impl diesel::deserialize::FromSql<diesel::sql_types::Text, diesel::pg::Pg> for Status"),
"got:\n{model}"
);
assert!(model.contains("const VARIANTS"), "got:\n{model}");
assert!(model.contains("pub const fn as_str"), "got:\n{model}");
}
#[test]
fn enum_field_name_colliding_with_model_name_is_rejected() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Status",
&["status:enum{draft,published}".into()],
"20260427000000",
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
assert!(err.to_string().contains("Status"), "got: {err}");
}
#[test]
fn enum_field_name_colliding_with_generated_companion_type_is_rejected() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Field",
&["field:enum{a,b}".into()],
"20260427000000",
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn enum_field_name_colliding_with_preload_associations_or_factory_is_rejected() {
for field_name in ["post_preload", "post_associations", "post_factory"] {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&[format!("{field_name}:enum{{a,b}}")],
"20260427000000",
)
.unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected '{field_name}' to be rejected"
);
}
}
#[test]
fn two_enum_fields_colliding_with_each_other_are_rejected() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&["in_review:enum{a,b}".into(), "in__review:enum{c,d}".into()],
"20260427000000",
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn distinct_enum_fields_with_non_colliding_names_are_accepted() {
let tmp = project();
plan_model(
tmp.path(),
"Post",
&[
"status:enum{draft,published}".into(),
"priority:enum{low,high}".into(),
],
"20260427000000",
)
.unwrap();
}
#[test]
fn enum_field_name_shadowing_prelude_type_is_rejected() {
for field_name in ["string", "vec", "option", "result", "box", "into"] {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&[format!("{field_name}:enum{{a,b}}")],
"20260427000000",
)
.unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected '{field_name}' to be rejected"
);
}
}
#[test]
fn decimal_default_within_precision_and_scale_is_accepted() {
let fields = parse_fields(&["price:decimal{12,2}".into()]).unwrap();
let metadata = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["price=19.99".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(
metadata.defaults().get("price").map(String::as_str),
Some("19.99")
);
}
#[test]
fn decimal_default_rejected_when_integer_part_overflows_precision() {
let fields = parse_fields(&["amount:decimal{2,2}".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["amount=1".into()],
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("integer digit"), "got: {msg}");
}
#[test]
fn decimal_default_rejected_when_fractional_part_exceeds_scale() {
let fields = parse_fields(&["amount:decimal{3,2}".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["amount=1.999".into()],
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("fractional digit"), "got: {msg}");
}
#[test]
fn decimal_default_tolerates_insignificant_trailing_zeros() {
let fields = parse_fields(&["amount:decimal{4,2}".into()]).unwrap();
let metadata = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["amount=1.500".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(
metadata.defaults().get("amount").map(String::as_str),
Some("1.500")
);
}
fn with_no_db_env<R>(f: impl FnOnce() -> R) -> R {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
f,
)
}
fn project_with_db_url(url: &str) -> TempDir {
let tmp = project();
fs::write(
tmp.path().join("autumn.toml"),
format!("[database]\nprimary_url = \"{url}\"\n"),
)
.unwrap();
tmp
}
#[test]
fn sqlite_app_emits_sqlite_migration_ddl() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
let plan = plan_model(
tmp.path(),
"Post",
&[
"title:String".into(),
"views:i64".into(),
"naive:NaiveDateTime".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("id INTEGER PRIMARY KEY AUTOINCREMENT"),
"up.sql: {up}"
);
assert!(up.contains("title TEXT NOT NULL"), "up.sql: {up}");
assert!(up.contains("views INTEGER NOT NULL"), "up.sql: {up}");
assert!(up.contains("naive TEXT NOT NULL"), "up.sql: {up}");
assert!(
up.contains("created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"),
"up.sql: {up}"
);
for leak in ["BIGSERIAL", "TIMESTAMPTZ", "NOW()", "JSONB", "BIGINT"] {
assert!(!up.contains(leak), "SQLite up.sql leaked `{leak}`: {up}");
}
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(schema.contains("naive -> Timestamp,"), "schema: {schema}");
assert!(
!schema.contains("Timestamptz"),
"SQLite schema.rs leaked a timestamptz sql-type: {schema}"
);
assert!(
!schema.contains("Jsonb"),
"SQLite schema.rs leaked `Jsonb`: {schema}"
);
});
}
#[derive(diesel::QueryableByName)]
struct TextCell {
#[diesel(sql_type = diesel::sql_types::Text)]
sql: String,
}
fn products_sql(conn: &mut diesel::SqliteConnection) -> String {
use diesel::RunQueryDsl as _;
let rows: Vec<TextCell> =
diesel::sql_query("SELECT sql FROM sqlite_master WHERE name = 'products'")
.load(conn)
.expect("read sqlite_master");
rows.into_iter().next().expect("products exists").sql
}
fn insert_price(conn: &mut diesel::SqliteConnection, price: &str) -> bool {
use diesel::connection::SimpleConnection as _;
conn.batch_execute(&format!(
"INSERT INTO products (title, price) VALUES ('x', {price});"
))
.is_ok()
}
fn assert_price_check_holds(conn: &mut diesel::SqliteConnection, when: &str) {
use crate::generate::schema_edit::{SQLITE_DECIMAL_10_2_REJECTS, sqlite_decimal_check};
assert!(
products_sql(conn).contains(&sqlite_decimal_check("price", 10, 2)),
"{when}: the table has no generator CHECK"
);
for value in SQLITE_DECIMAL_10_2_REJECTS {
assert!(
!insert_price(conn, &format!("'{value}'")),
"{when}: the CHECK accepts '{value}'"
);
}
assert!(
!insert_price(conn, "x'31392e3939'"),
"{when}: the CHECK accepts a blob"
);
}
#[test]
fn sqlite_decimal_check_survives_a_declarative_rebuild() {
use crate::schema::diff::{
DiffOptions, SchemaChange, SchemaContext, diff_schema, emit_down_sql_with_context,
emit_up_sql_with_context, guard_plan,
};
use crate::schema::introspect::introspect_sqlite_conn;
use crate::schema::parse::parse_models_path;
use autumn_schema_core::Backend;
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
plan_model(
tmp.path(),
"Product",
&["title:String".into(), "price:decimal{10,2}".into()],
"20260427000000",
)
.expect("plan")
.execute(Flags::default())
.expect("execute");
let generated_up = fs::read_dir(tmp.path().join("migrations"))
.expect("migrations dir")
.map(|entry| entry.expect("entry").path().join("up.sql"))
.find(|path| path.exists())
.expect("the generator wrote an up.sql");
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite");
conn.batch_execute(&fs::read_to_string(generated_up).expect("read up.sql"))
.expect("apply the generated migration");
conn.batch_execute("INSERT INTO products (title, price) VALUES ('a', '19.99');")
.expect("seed a row");
assert_price_check_holds(&mut conn, "after the generator");
let models = tmp.path().join("src/models");
let model_path = models.join("product.rs");
let model = fs::read_to_string(&model_path).expect("read model");
assert!(model.contains("#[autumn_web::model]\n"), "{model}");
let model = model.replace("#[autumn_web::model]\n", "#[autumn_web::model(managed)]\n");
fs::write(&model_path, &model).expect("mark the model managed");
let desired = parse_models_path(&models, Backend::Sqlite).expect("parse models");
assert!(desired.diagnostics.is_empty(), "{:?}", desired.diagnostics);
let baseline = introspect_sqlite_conn(&mut conn).expect("introspect");
let clean = diff_schema(&baseline, &desired, DiffOptions::default());
assert!(
clean.changes.is_empty(),
"the generator table and the model have a diff: {:?}",
clean.changes
);
assert!(model.contains("pub title: String,"), "{model}");
fs::write(
&model_path,
model.replace("pub title: String,", "pub title: Option<String>,"),
)
.expect("edit model");
let desired = parse_models_path(&models, Backend::Sqlite).expect("parse models");
let plan = diff_schema(&baseline, &desired, DiffOptions::default());
assert!(
matches!(
plan.changes.as_slice(),
[SchemaChange::DropNotNull { column, .. }] if column == "title"
),
"{:?}",
plan.changes
);
guard_plan(&plan, DiffOptions::default()).expect("guard");
let ctx = SchemaContext::from_tables(&desired.tables, &baseline);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
assert!(
up.contains("writes a new decimal CHECK for column(s) price."),
"the up SQL has no advisory: {up}"
);
conn.batch_execute(&up).expect("apply the rebuild");
assert_price_check_holds(&mut conn, "after the rebuild");
assert!(insert_price(&mut conn, "'0.5'"), "the CHECK rejects '0.5'");
let down = emit_down_sql_with_context(&plan, &ctx).expect("emit down");
conn.batch_execute(&down).expect("apply the rollback");
assert_price_check_holds(&mut conn, "after the rollback");
let titles: Vec<TextCell> =
diesel::sql_query("SELECT title AS sql FROM products ORDER BY id")
.load(&mut conn)
.expect("read rows");
let titles: Vec<&str> = titles.iter().map(|row| row.sql.as_str()).collect();
assert_eq!(
titles,
["a", "x"],
"the rebuild and the rollback keep the rows"
);
});
}
#[test]
fn sqlite_app_accepts_uuid_decimal_and_enum_after_1924() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
let plan = plan_model(
tmp.path(),
"Post",
&[
"title:String".into(),
"token:Uuid".into(),
"owner:Option<Uuid>".into(),
"price:decimal{10,2}".into(),
"status:enum{draft,published}".into(),
],
"20260427000000",
)
.expect("Uuid/Decimal/enum fields are accepted on SQLite (#1924)");
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub token: autumn_web::db::sqlite_types::SqliteUuid,"),
"Uuid renders as the SQLite newtype: {model}"
);
assert!(
model.contains("pub owner: Option<autumn_web::db::sqlite_types::SqliteUuid>,"),
"Option<Uuid> renders as the SQLite newtype: {model}"
);
assert!(
model.contains("pub price: autumn_web::db::sqlite_types::SqliteDecimal,"),
"Decimal renders as the SQLite newtype: {model}"
);
for leak in ["uuid::Uuid", "rust_decimal::Decimal"] {
assert!(
!model.contains(leak),
"SQLite model leaked the unconvertible `{leak}`: {model}"
);
}
assert!(
model.contains("diesel::sqlite::Sqlite"),
"enum must emit Sqlite ToSql/FromSql: {model}"
);
assert!(
!model.contains("diesel::pg::Pg"),
"SQLite model must not emit Pg enum conversions: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("token TEXT NOT NULL"), "up.sql: {up}");
assert!(up.contains("price TEXT NOT NULL"), "up.sql: {up}");
assert!(up.contains("status TEXT NOT NULL"), "up.sql: {up}");
assert!(
!up.contains("NUMERIC"),
"SQLite up.sql leaked NUMERIC: {up}"
);
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
for line in [
"token -> Text,",
"owner -> Nullable<Text>,",
"price -> Text,",
"status -> Text,",
] {
assert!(schema.contains(line), "schema missing `{line}`: {schema}");
}
});
}
#[test]
fn destroying_the_last_model_keeps_the_sqlite_feature() {
with_no_db_env(|| {
let tmp = project_with_autumn_web_dep();
fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.expect("plan")
.execute(Flags::default())
.unwrap();
let after_generate = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
after_generate.contains("features = [\"sqlite\"]"),
"generate must add the feature first: {after_generate}"
);
plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000001",
)
.expect("re-plan for revert")
.revert(Flags::default())
.unwrap();
let after_destroy = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
after_destroy.contains("features = [\"sqlite\"]"),
"destroy stripped the backend flip, so the app no longer boots: {after_destroy}"
);
});
}
#[test]
fn sqlite_decimal_column_carries_a_precision_and_scale_check() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
plan_model(
tmp.path(),
"Post",
&["price:decimal{10,2}".into()],
"20260427000000",
)
.expect("plan")
.execute(Flags::default())
.unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("price TEXT NOT NULL CHECK ("), "up.sql: {up}");
assert!(up.contains("<= 2"), "scale bound missing: {up}");
assert!(up.contains("<= 8"), "precision bound missing: {up}");
});
}
#[test]
fn decimal_field_emits_decimal_shape_attr_for_model_macro() {
let fields = parse_fields(&["price:decimal{10,2}".to_owned()]).expect("parse");
let model = render_model_file_for_test("Invoice", "invoices", &fields);
assert!(
model.contains("#[decimal_shape(precision = 10, scale = 2)]"),
"decimal field must carry its shape into #[model]: {model}"
);
assert!(
model.contains("pub price: rust_decimal::Decimal,"),
"decimal field still renders as rust_decimal::Decimal: {model}"
);
let plain = parse_fields(&["title:String".to_owned()]).expect("parse");
let plain_model = render_model_file_for_test("Post", "posts", &plain);
assert!(
!plain_model.contains("decimal_shape"),
"non-decimal fields must not gain the attribute: {plain_model}"
);
}
#[test]
fn postgres_decimal_column_has_no_check_constraint() {
with_no_db_env(|| {
let tmp = project_with_db_url("postgres://localhost/app");
plan_model(
tmp.path(),
"Post",
&["price:decimal{10,2}".into()],
"20260427000000",
)
.expect("plan")
.execute(Flags::default())
.unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("price NUMERIC(10,2) NOT NULL"), "up.sql: {up}");
assert!(
!up.contains("CHECK ("),
"Postgres must not gain a CHECK: {up}"
);
});
}
#[test]
fn sqlite_decimal_default_is_a_quoted_text_literal() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
plan_model(
tmp.path(),
"Post",
&["price:decimal{10,2}".into()],
"20260427000000",
)
.map(|_| ())
.expect("plan without default");
let options = ModelOptions {
defaults: vec!["price=0.10".to_owned()],
..Default::default()
};
let fields = parse_fields(&["price:decimal{10,2}".into()]).unwrap();
let sqlite = parse_model_metadata_for(DatabaseBackend::Sqlite, &fields, &options)
.expect("sqlite metadata");
assert_eq!(
sqlite.defaults().get("price").map(String::as_str),
Some("'0.1'"),
"a SQLite decimal default must be quoted AND normalized — the same \
text `SqliteDecimal` writes, or a row holding its own default \
would not match a `find_by_…` for that value"
);
let postgres = parse_model_metadata_for(DatabaseBackend::Postgres, &fields, &options)
.expect("postgres metadata");
assert_eq!(
postgres.defaults().get("price").map(String::as_str),
Some("0.10"),
"Postgres decimal defaults stay unquoted numeric literals"
);
});
}
#[test]
fn sqlite_app_cargo_deps_target_the_sqlite_backend() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
plan_model(
tmp.path(),
"Post",
&["title:String".into(), "price:decimal{10,2}".into()],
"20260427000000",
)
.expect("plan")
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo.contains("features = [\"sqlite\"]") || cargo.contains("\"sqlite\""),
"autumn-web must carry the sqlite feature: {cargo}"
);
assert!(
cargo.contains("libsqlite3-sys"),
"the bundled SQLite amalgamation must be a dependency: {cargo}"
);
assert!(
!cargo.contains("pq-sys"),
"a SQLite app must not name libpq as a direct dependency: {cargo}"
);
assert!(
!cargo.contains("db-diesel2-postgres"),
"rust_decimal's Postgres diesel feature is wrong here: {cargo}"
);
});
}
#[test]
fn postgres_app_cargo_deps_are_unchanged() {
with_no_db_env(|| {
let tmp = project_with_db_url("postgres://localhost/app");
plan_model(
tmp.path(),
"Post",
&["title:String".into(), "price:decimal{10,2}".into()],
"20260427000000",
)
.expect("plan")
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("pq-sys"), "Cargo.toml: {cargo}");
assert!(cargo.contains("db-diesel2-postgres"), "Cargo.toml: {cargo}");
assert!(
!cargo.contains("libsqlite3-sys"),
"a Postgres app must not link SQLite: {cargo}"
);
});
}
#[test]
fn postgres_app_model_output_for_uuid_decimal_enum_is_unchanged() {
with_no_db_env(|| {
let tmp = project_with_db_url("postgres://localhost/app");
plan_model(
tmp.path(),
"Post",
&[
"token:Uuid".into(),
"price:decimal{10,2}".into(),
"status:enum{draft,published}".into(),
],
"20260427000000",
)
.expect("plan")
.execute(Flags::default())
.unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(model.contains("pub token: uuid::Uuid,"), "model: {model}");
assert!(
model.contains("pub price: rust_decimal::Decimal,"),
"model: {model}"
);
assert!(
model.contains("diesel::pg::Pg"),
"Postgres enum conversions must be unchanged: {model}"
);
assert!(
!model.contains("sqlite_types"),
"Postgres model must not name the SQLite newtypes: {model}"
);
});
}
#[test]
fn sqlite_app_accepts_datetime_and_attachment_after_1924() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
let plan = plan_model(
tmp.path(),
"Post",
&[
"title:String".into(),
"at:DateTime".into(),
"cover:Attachment".into(),
],
"20260427000000",
)
.expect("DateTime + Attachment fields are accepted on SQLite (#1924)");
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("at TEXT NOT NULL"), "up.sql: {up}");
assert!(up.contains("cover TEXT"), "up.sql: {up}");
for leak in ["TIMESTAMPTZ", "JSONB", "NUMERIC"] {
assert!(!up.contains(leak), "SQLite up.sql leaked `{leak}`: {up}");
}
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(
schema.contains("at -> TimestamptzSqlite,"),
"schema: {schema}"
);
assert!(
schema.contains("cover -> Nullable<Text>,"),
"schema: {schema}"
);
assert!(
!schema.contains("Jsonb"),
"SQLite schema.rs leaked `Jsonb`: {schema}"
);
});
}
#[test]
fn postgres_app_field_kinds_without_sqlite_conversion_are_unchanged() {
with_no_db_env(|| {
let tmp = project_with_db_url("postgres://localhost/app");
plan_model(
tmp.path(),
"Post",
&[
"title:String".into(),
"token:Uuid".into(),
"cover:Attachment".into(),
"price:decimal{10,2}".into(),
"at:DateTime".into(),
"status:enum{draft,published}".into(),
],
"20260427000000",
)
.expect("Uuid/Attachment/Decimal/DateTime/enum fields must still generate on Postgres");
});
}
#[test]
fn sqlite_app_uuid_primary_key_generates() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions {
id_type: IdType::Uuid,
..Default::default()
},
)
.expect("--id uuid must generate on SQLite");
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(4)))"),
"up.sql: {up}"
);
assert!(!up.contains("gen_random_uuid"), "up.sql: {up}");
assert!(!up.contains("UUID PRIMARY KEY"), "up.sql: {up}");
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: autumn_web::db::sqlite_types::SqliteUuid,"),
"model: {model}"
);
assert!(!model.contains("uuid::Uuid"), "model: {model}");
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(schema.contains("id -> Text,"), "schema: {schema}");
});
}
#[test]
fn type_is_uuid_accepts_the_sqlite_spelling() {
for ty in [
"uuid::Uuid",
"Uuid",
"SqliteUuid",
"sqlite_types::SqliteUuid",
"autumn_web::db::sqlite_types::SqliteUuid",
] {
let parsed: syn::Type = syn::parse_str(ty).unwrap();
assert!(type_is_uuid(&parsed), "{ty}");
}
for ty in ["i64", "String", "other::Thing"] {
let parsed: syn::Type = syn::parse_str(ty).unwrap();
assert!(!type_is_uuid(&parsed), "{ty}");
}
}
#[test]
fn sqlite_references_to_a_uuid_keyed_model_is_refused() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
plan_model_with_options(
tmp.path(),
"Category",
&["name:String".into()],
"20260427000000",
&ModelOptions {
id_type: IdType::Uuid,
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
let err = plan_model_with_options(
tmp.path(),
"Item",
&["category:references".into()],
"20260427000001",
&ModelOptions::default(),
)
.unwrap_err();
assert!(err.to_string().contains("UUID primary key"), "{err}");
});
}
#[test]
fn sqlite_app_integer_primary_key_still_works() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions::default(),
)
.expect("default INTEGER primary key must still generate on SQLite");
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("id INTEGER PRIMARY KEY AUTOINCREMENT"),
"up.sql: {up}"
);
});
}
#[test]
fn sharded_on_sqlite_app_is_rejected_as_postgres_only() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
let err = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "tenant_id:i64".into()],
"20260427000000",
&ModelOptions {
sharded: true,
shard_key: Some("tenant_id".into()),
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, GenerateError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
msg.contains("Postgres") && msg.contains("--sharded"),
"message must point at the Postgres backend requirement: {msg}"
);
assert!(
msg.contains("single-writer"),
"message must convey the permanent single-host/single-writer constraint: {msg}"
);
assert!(
!msg.contains("issues/"),
"sharding is permanent, not deferred — must not cite a coming-soon issue: {msg}"
);
});
}
#[test]
fn non_sharded_sqlite_model_still_works() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions::default(),
)
.expect("non-sharded SQLite model must still generate");
});
}
#[test]
fn postgres_app_sharded_model_is_unchanged() {
with_no_db_env(|| {
let tmp = project_with_db_url("postgres://localhost/app");
plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "tenant_id:i64".into()],
"20260427000000",
&ModelOptions {
sharded: true,
shard_key: Some("tenant_id".into()),
..Default::default()
},
)
.expect("sharded model must still generate on Postgres");
});
}
#[test]
fn postgres_app_uuid_primary_key_is_unchanged() {
with_no_db_env(|| {
let tmp = project_with_db_url("postgres://localhost/app");
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions {
id_type: IdType::Uuid,
..Default::default()
},
)
.expect("uuid primary key must still generate on Postgres");
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("id UUID PRIMARY KEY DEFAULT gen_random_uuid()"),
"up.sql: {up}"
);
});
}
#[test]
fn postgres_app_generation_is_unchanged_regression_guard() {
with_no_db_env(|| {
let fields = &[
"title:String".into(),
"views:i64".into(),
"at:DateTime".into(),
];
let pg = project_with_db_url("postgres://localhost/app");
plan_model(pg.path(), "Post", fields, "20260427000000")
.unwrap()
.execute(Flags::default())
.unwrap();
let pg_up = fs::read_to_string(
pg.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
let none = project();
plan_model(none.path(), "Post", fields, "20260427000000")
.unwrap()
.execute(Flags::default())
.unwrap();
let none_up = fs::read_to_string(
none.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert_eq!(
pg_up, none_up,
"explicit postgres:// and default (no autumn.toml) must be byte-identical"
);
assert!(
pg_up.contains("id BIGSERIAL PRIMARY KEY"),
"up.sql: {pg_up}"
);
assert!(pg_up.contains("views BIGINT NOT NULL"), "up.sql: {pg_up}");
assert!(pg_up.contains("at TIMESTAMPTZ NOT NULL"), "up.sql: {pg_up}");
assert!(
pg_up.contains("created_at TIMESTAMP NOT NULL DEFAULT NOW()"),
"up.sql: {pg_up}"
);
});
}
#[test]
fn searchable_on_sqlite_app_emits_fts5() {
with_no_db_env(|| {
let tmp = project_with_db_url("sqlite://app.db");
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
&ModelOptions {
searchable: vec!["title".into(), "body".into()],
..Default::default()
},
)
.expect("searchable SQLite model plans without rejection");
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
let down = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/down.sql"),
)
.unwrap();
assert!(
up.contains(
"CREATE VIRTUAL TABLE \"posts__fts\" USING fts5(\"title\", \"body\", \
content='posts', content_rowid='id', tokenize='unicode61');"
),
"up.sql must create the FTS5 vtable: {up}"
);
for trig in ["posts__fts_ai", "posts__fts_ad", "posts__fts_au"] {
assert!(up.contains(trig), "up.sql must create trigger {trig}: {up}");
}
assert!(
up.contains("INSERT INTO \"posts__fts\"(\"posts__fts\") VALUES('rebuild');"),
"up.sql must backfill via 'rebuild': {up}"
);
assert!(
down.contains("DROP TABLE IF EXISTS \"posts__fts\";"),
"down.sql must drop the FTS table: {down}"
);
for trig in ["posts__fts_ai", "posts__fts_ad", "posts__fts_au"] {
assert!(
down.contains(&format!("DROP TRIGGER IF EXISTS \"{trig}\";")),
"down.sql must drop trigger {trig}: {down}"
);
}
for leak in ["tsvector", "to_tsvector", "USING gin", "search_vector"] {
assert!(!up.contains(leak), "SQLite up.sql leaked `{leak}`: {up}");
}
});
}
#[test]
fn searchable_with_uuid_primary_key_is_rejected() {
let fields = parse_fields(&["title:String".into(), "body:Text".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
searchable: vec!["title".into(), "body".into()],
id_type: IdType::Uuid,
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, GenerateError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
msg.contains("--searchable")
&& (msg.contains("i64") || msg.contains("bigint"))
&& msg.contains("uuid::Uuid"),
"error must explain the i64-primary-key requirement and name the uuid type: {msg}"
);
}
#[test]
fn searchable_with_search_vector_field_is_rejected() {
let fields = parse_fields(&["title:String".into(), "search_vector:String".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
searchable: vec!["title".into()],
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, GenerateError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
msg.contains("search_vector") && msg.contains("reserved"),
"error must flag `search_vector` as reserved: {msg}"
);
}
#[test]
fn search_vector_field_without_searchable_is_accepted() {
let fields = parse_fields(&["title:String".into(), "search_vector:String".into()]).unwrap();
let metadata = parse_model_metadata(&fields, &ModelOptions::default())
.expect("search_vector without --searchable must be accepted");
assert!(metadata.searchable().is_empty());
}
#[test]
fn uuid_primary_key_without_searchable_is_accepted() {
let fields = parse_fields(&["title:String".into()]).unwrap();
let metadata = parse_model_metadata(
&fields,
&ModelOptions {
id_type: IdType::Uuid,
..Default::default()
},
)
.expect("uuid without --searchable must be accepted");
assert!(metadata.searchable().is_empty());
}
#[test]
fn decimal_default_tolerates_leading_zeros_in_integer_part() {
let fields = parse_fields(&["amount:decimal{2,2}".into()]).unwrap();
let metadata = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["amount=0.5".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(
metadata.defaults().get("amount").map(String::as_str),
Some("0.5")
);
}
#[test]
fn decimal_default_accepts_negative_value_within_range() {
let fields = parse_fields(&["amount:decimal{2,2}".into()]).unwrap();
let metadata = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["amount=-0.75".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(
metadata.defaults().get("amount").map(String::as_str),
Some("-0.75")
);
}
#[test]
fn decimal_default_rejects_non_numeric_value() {
let fields = parse_fields(&["price:decimal".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["price=abc".into()],
..Default::default()
},
)
.unwrap_err();
assert!(err.to_string().contains("valid numbers"));
}
#[test]
fn decimal_default_rejects_scientific_notation() {
let fields = parse_fields(&["price:decimal".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["price=1e2".into()],
..Default::default()
},
)
.unwrap_err();
assert!(err.to_string().contains("scientific notation"));
}
#[test]
fn decimal_default_rejects_nan_and_infinity() {
let fields = parse_fields(&["price:decimal".into()]).unwrap();
for bad in ["nan", "inf", "infinity"] {
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec![format!("price={bad}")],
..Default::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("finite"),
"'{bad}' should be rejected as non-finite: {err}"
);
}
}
#[test]
fn decimal_field_warns_when_existing_rust_decimal_dep_lacks_diesel_feature() {
let tmp = project();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nrust_decimal = \"1\"\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Product",
&["price:decimal".into()],
"20260427000000",
)
.unwrap();
assert_eq!(plan.warnings.len(), 1, "warnings: {:?}", plan.warnings);
assert!(plan.warnings[0].contains("rust_decimal"));
assert!(plan.warnings[0].contains("db-diesel2-postgres"));
}
#[test]
fn decimal_field_no_warning_when_existing_rust_decimal_dep_already_has_feature() {
let tmp = project();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\n\
rust_decimal = { version = \"1\", features = [\"db-diesel2-postgres\", \"serde\"] }\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Product",
&["price:decimal".into()],
"20260427000000",
)
.unwrap();
assert!(plan.warnings.is_empty(), "warnings: {:?}", plan.warnings);
}
#[test]
fn decimal_field_warns_naming_only_the_missing_feature() {
let tmp = project();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\n\
rust_decimal = { version = \"1\", features = [\"db-diesel2-postgres\"] }\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Product",
&["price:decimal".into()],
"20260427000000",
)
.unwrap();
assert_eq!(plan.warnings.len(), 1, "warnings: {:?}", plan.warnings);
assert!(plan.warnings[0].contains("serde"));
}
#[test]
fn decimal_field_no_warning_when_rust_decimal_not_already_declared() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Product",
&["price:decimal".into()],
"20260427000000",
)
.unwrap();
assert!(plan.warnings.is_empty(), "warnings: {:?}", plan.warnings);
}
#[test]
fn enum_sql_default_literal_quotes_variant() {
let fields = parse_fields(&["status:enum{draft,published,archived}".into()]).unwrap();
let metadata = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["status=draft".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(
metadata.defaults().get("status").map(String::as_str),
Some("'draft'")
);
}
#[test]
fn enum_sql_default_literal_accepts_quoted_variant() {
let fields = parse_fields(&["status:enum{draft,published}".into()]).unwrap();
for token in ["status=\"draft\"", "status='draft'"] {
let metadata = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec![token.into()],
..Default::default()
},
)
.unwrap();
assert_eq!(
metadata.defaults().get("status").map(String::as_str),
Some("'draft'"),
"token '{token}' should unquote to 'draft'"
);
}
}
#[test]
fn enum_default_unknown_variant_errors_at_generate_time() {
let fields = parse_fields(&["status:enum{draft,published,archived}".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["status=bogus".into()],
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bogus"), "got: {msg}");
assert!(msg.contains("draft"), "got: {msg}");
assert!(msg.contains("published"), "got: {msg}");
assert!(msg.contains("archived"), "got: {msg}");
}
#[test]
fn unique_field_with_default_is_rejected() {
let fields = parse_fields(&["email:String:unique".into()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["email='a@b.com'".into()],
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("email"), "got: {msg}");
assert!(msg.contains("unique"), "got: {msg}");
assert!(msg.contains("default"), "got: {msg}");
}
#[test]
fn unique_flag_field_with_default_is_rejected() {
let mut fields = parse_fields(&["email:String".into()]).unwrap();
apply_unique_flags(&mut fields, &["email".to_owned()]).unwrap();
let err = parse_model_metadata(
&fields,
&ModelOptions {
defaults: vec!["email='a@b.com'".into()],
..Default::default()
},
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn enum_default_emits_rust_default_impl_and_sql_default() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["status:enum{draft,published,archived}".into()],
"20260427000000",
&ModelOptions {
defaults: vec!["status=draft".into()],
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("#[derive(")
&& model.contains("Default")
&& model.contains("pub enum Status"),
"expected Default in the enum's derive list: {model}"
);
assert!(
model.contains("#[default]\n #[serde(rename = \"draft\")]\n Draft,"),
"expected #[default] on the Draft variant: {model}"
);
assert!(
model.contains("#[default]\n pub status: Status,"),
"field-level #[default] marker must still be emitted so status is \
excluded from NewPost: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("DEFAULT 'draft'"), "got:\n{up}");
}
#[test]
fn nullable_enum_model_field_is_option() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["status:Option<enum{draft,published}>".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub status: Option<Status>,"),
"got:\n{model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("status TEXT NULL"), "got:\n{up}");
assert!(
up.contains("CHECK (status IN ('draft', 'published'))"),
"got:\n{up}"
);
}
#[test]
fn execute_writes_idiomatic_model() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "published:bool".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(model.contains("#[autumn_web::model]"));
assert!(model.contains("pub struct Post"));
assert!(model.contains("pub title: String,"));
assert!(model.contains("pub published: bool,"));
assert!(model.contains("#[id]"));
assert!(model.contains("pub id: i64,"));
assert!(model.contains("created_at: chrono::NaiveDateTime"));
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("CREATE TABLE posts ("));
assert!(up.contains("title TEXT NOT NULL"));
assert!(up.contains("published BOOLEAN NOT NULL"));
assert!(up.contains("id BIGSERIAL PRIMARY KEY"));
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(schema.contains("posts (id)"));
assert!(schema.contains("title -> Text,"));
}
#[test]
fn rerunning_with_force_overwrites_model_but_appends_schema() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let plan2 = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427100000",
)
.unwrap();
plan2
.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert_eq!(schema.matches("posts (id)").count(), 1);
}
#[test]
fn dry_run_writes_nothing() {
let tmp = project();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!tmp.path().join("src/models/post.rs").exists());
assert!(!tmp.path().join("src/schema.rs").exists());
}
#[test]
fn collision_reports_clean_path() {
let tmp = project();
let dir = tmp.path().join("src/models");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("post.rs"), "// existing").unwrap();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
let err = plan.execute(Flags::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("post.rs"));
}
#[test]
fn modify_actions_marked_correctly() {
let tmp = project();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
let modify_count = plan
.actions
.iter()
.filter(|a| matches!(a, Action::Modify { .. }))
.count();
assert!(modify_count >= 3);
}
#[test]
fn ensure_cargo_dependencies_appends_missing() {
let original = "[package]\n\
name = \"x\"\n\
\n\
[dependencies]\n\
autumn-web = \"0.3\"\n";
let updated = ensure_cargo_dependencies(
original,
&[
("chrono", "\"0.4\""),
("autumn-web", "\"99\""), ],
);
assert!(updated.contains("autumn-web = \"0.3\""));
assert!(updated.contains("chrono = \"0.4\""));
assert_eq!(updated.matches("autumn-web =").count(), 1);
}
#[test]
fn remove_cargo_dependencies_restores_original() {
let original = "[package]\n\
name = \"x\"\n\
\n\
[dependencies]\n\
autumn-web = \"0.3\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
assert_ne!(updated, original);
let reverted = remove_cargo_dependencies(&updated, &["chrono"]);
assert_eq!(reverted, original);
}
#[test]
fn remove_cargo_dependencies_removes_only_named_crates() {
let original = "[dependencies]\nautumn-web = \"0.3\"\n";
let updated =
ensure_cargo_dependencies(original, &[("chrono", "\"0.4\""), ("serde", "\"1\"")]);
let reverted = remove_cargo_dependencies(&updated, &["chrono"]);
assert!(!reverted.contains("chrono"));
assert!(reverted.contains("serde = \"1\""));
assert!(reverted.contains("autumn-web = \"0.3\""));
}
#[test]
fn remove_cargo_dependencies_is_idempotent_when_absent() {
let original = "[dependencies]\nautumn-web = \"0.3\"\n";
assert_eq!(remove_cargo_dependencies(original, &["chrono"]), original);
}
#[test]
fn remove_cargo_dependencies_does_not_touch_prefix_sharing_crate() {
let original = "[dependencies]\ndiesel-async = \"0.8\"\n";
assert_eq!(remove_cargo_dependencies(original, &["diesel"]), original);
}
#[test]
fn remove_cargo_dependencies_collapses_now_empty_section_created_from_scratch() {
let original = "[package]\nname = \"x\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
assert_ne!(updated, original);
let reverted = remove_cargo_dependencies(&updated, &["chrono"]);
assert_eq!(reverted, original);
}
#[test]
fn ensure_cargo_dependencies_idempotent() {
let original = "[package]\nname = \"x\"\n\n[dependencies]\nchrono = \"0.4\"\n";
let once = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let twice = ensure_cargo_dependencies(&once, &[("chrono", "\"0.4\"")]);
assert_eq!(once, twice);
assert_eq!(once, original);
}
#[test]
fn ensure_cargo_dependencies_inserts_before_next_section() {
let original = "[package]\nname = \"x\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n\n\
[dev-dependencies]\ntempfile = \"3\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let chrono_pos = updated.find("chrono = \"0.4\"").unwrap();
let dev_deps_pos = updated.find("[dev-dependencies]").unwrap();
assert!(
chrono_pos < dev_deps_pos,
"chrono must land in [dependencies], not [dev-dependencies]"
);
}
#[test]
fn ensure_cargo_dependencies_treats_array_of_tables_as_boundary() {
let original = "[package]\nname = \"x\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n\n\
[[bin]]\nname = \"app\"\npath = \"src/main.rs\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let chrono_pos = updated.find("chrono = \"0.4\"").unwrap();
let bin_pos = updated.find("[[bin]]").unwrap();
assert!(
chrono_pos < bin_pos,
"chrono must land in [dependencies], not inside [[bin]]:\n{updated}"
);
}
#[test]
fn ensure_cargo_dependencies_recognises_subtable_form() {
let original = "[package]\nname = \"x\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n\n\
[dependencies.chrono]\nversion = \"0.4\"\nfeatures = [\"serde\"]\n";
let updated = ensure_cargo_dependencies(
original,
&[
("chrono", "\"99\""), ("diesel", "\"2\""),
],
);
assert!(
!updated.contains("chrono = \"99\""),
"[dependencies.chrono] subtable form must count as 'chrono is declared':\n{updated}"
);
let diesel_pos = updated.find("diesel = \"2\"").unwrap();
let chrono_subtable_pos = updated.find("[dependencies.chrono]").unwrap();
assert!(
diesel_pos < chrono_subtable_pos,
"new dep must land inside [dependencies], above any [dependencies.X] subtable:\n{updated}"
);
}
#[test]
fn dep_subtable_crate_name_parses_canonical_form() {
assert_eq!(
dep_subtable_crate_name("[dependencies.chrono]"),
Some("chrono")
);
assert_eq!(
dep_subtable_crate_name(" [dependencies.chrono] # opt"),
Some("chrono")
);
assert_eq!(dep_subtable_crate_name("[dependencies]"), None);
assert_eq!(dep_subtable_crate_name("[dev-dependencies.chrono]"), None);
assert_eq!(dep_subtable_crate_name("[package]"), None);
assert_eq!(dep_subtable_crate_name("[[bin]]"), None);
}
#[test]
fn ensure_cargo_dependencies_skips_commented_out_entries() {
let original = "[dependencies]\n# autumn-web = \"0.2\"\n";
let updated = ensure_cargo_dependencies(original, &[("autumn-web", "\"0.3\"")]);
assert!(updated.contains("autumn-web = \"0.3\""));
}
#[test]
fn ensure_cargo_dependencies_handles_header_with_trailing_comment() {
let original = "[dependencies] # shared deps\nautumn-web = \"0.3\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
assert_eq!(
updated.matches("[dependencies]").count(),
1,
"duplicate [dependencies] table appended:\n{updated}"
);
assert!(updated.contains("chrono = \"0.4\""));
}
#[test]
fn ensure_cargo_dependencies_treats_indented_section_as_a_header() {
let original = "[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.3\"\n\n [dev-dependencies]\ntempfile = \"3\"\n";
let updated = ensure_cargo_dependencies(original, &[("chrono", "\"0.4\"")]);
let chrono_pos = updated.find("chrono = \"0.4\"").unwrap();
let dev_deps_pos = updated.find("[dev-dependencies]").unwrap();
assert!(
chrono_pos < dev_deps_pos,
"chrono must land in [dependencies], not [dev-dependencies]"
);
}
#[test]
fn existing_dep_version_below_detects_old_base64() {
assert!(existing_dep_version_below(
"[dependencies]\nbase64 = \"0.13\"\n",
"base64",
0,
22
));
assert!(existing_dep_version_below(
"[dependencies]\nbase64 = { version = \"0.20\" }\n",
"base64",
0,
22
));
assert!(existing_dep_version_below(
"[dependencies]\nbase64 = \"^0.21\"\n",
"base64",
0,
22
));
assert!(!existing_dep_version_below(
"[dependencies]\nbase64 = \"0.22\"\n",
"base64",
0,
22
));
assert!(!existing_dep_version_below(
"[dependencies]\nbase64 = \"0.22.1\"\n",
"base64",
0,
22
));
assert!(!existing_dep_version_below(
"[dependencies]\nbase64 = \"1.0\"\n",
"base64",
0,
22
));
assert!(!existing_dep_version_below(
"[dependencies]\nserde = \"1\"\n",
"base64",
0,
22
));
assert!(existing_dep_version_below(
"[dependencies]\n[dependencies.base64]\nversion = \"0.13\"\n",
"base64",
0,
22
));
assert!(existing_dep_version_below(
"[dependencies]\nserde = \"1\"\n\n[dependencies.base64]\nversion = \"0.13\"\nfeatures = [\"std\"]\n",
"base64",
0,
22
));
assert!(!existing_dep_version_below(
"[dependencies]\n[dependencies.base64]\nversion = \"0.22\"\n",
"base64",
0,
22
));
assert!(existing_dep_version_below(
"[dependencies]\n[dependencies.base64]\nworkspace = true\n",
"base64",
0,
22
));
assert!(existing_dep_version_below(
"[dependencies]\nbase64 = { workspace = true }\n",
"base64",
0,
22
));
assert!(!existing_dep_version_below(
"[dependencies]\n# base64 = \"0.13\"\n",
"base64",
0,
22
));
}
#[test]
fn warn_if_existing_dep_below_version_records_one_warning() {
let mut plan = Plan::new(std::path::PathBuf::from("."));
warn_if_existing_dep_below_version(
&mut plan,
"[dependencies]\nbase64 = \"0.13\"\n",
"base64",
0,
22,
);
assert_eq!(plan.warnings.len(), 1, "warnings: {:?}", plan.warnings);
assert!(plan.warnings[0].contains("base64"));
let mut plan = Plan::new(std::path::PathBuf::from("."));
warn_if_existing_dep_below_version(
&mut plan,
"[dependencies]\nbase64 = \"0.22\"\n",
"base64",
0,
22,
);
assert!(plan.warnings.is_empty(), "warnings: {:?}", plan.warnings);
let mut plan = Plan::new(std::path::PathBuf::from("."));
warn_if_existing_dep_below_version(
&mut plan,
"[dependencies]\n[dependencies.base64]\nversion = \"0.13\"\n",
"base64",
0,
22,
);
assert_eq!(plan.warnings.len(), 1, "warnings: {:?}", plan.warnings);
assert!(plan.warnings[0].contains("base64"));
}
#[test]
fn plan_includes_cargo_toml_modification() {
let tmp = project();
let plan = plan_model(tmp.path(), "Post", &[], "20260427000000").unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("Cargo.toml")),
"plan must touch Cargo.toml so generated code compiles"
);
}
#[test]
fn execute_adds_chrono_and_diesel_to_cargo_toml() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let cargo_toml = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
for dep in [
"chrono",
"diesel",
"diesel-async",
"serde",
"serde_json",
"diesel_migrations",
] {
assert!(
cargo_toml.contains(&format!("{dep} =")),
"missing '{dep}' in Cargo.toml after `generate model`:\n{cargo_toml}"
);
}
}
#[test]
fn execute_adds_uuid_dependencies_for_uuid_fields() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n\n\
[dependencies]\nautumn-web = \"0.3\"\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"ApiToken",
&["token:Uuid".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let cargo_toml = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo_toml.contains("uuid = { version = \"1\", features = [\"serde\"] }"),
"uuid::Uuid fields need a direct uuid dependency with serde support:\n{cargo_toml}"
);
assert!(
cargo_toml.contains(
"diesel = { version = \"2\", features = [\"postgres\", \"chrono\", \"uuid\"] }"
),
"Diesel schema Uuid fields need diesel's uuid feature:\n{cargo_toml}"
);
}
#[test]
fn plan_model_with_soft_delete_emits_deleted_at_migration_column() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions {
soft_delete: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("deleted_at"),
"soft_delete migration must include deleted_at column: {up}"
);
assert!(
up.contains("NULL"),
"soft_delete deleted_at must be nullable (no NOT NULL): {up}"
);
}
#[test]
fn plan_model_with_soft_delete_emits_deleted_at_field_in_struct() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions {
soft_delete: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("deleted_at"),
"soft_delete model struct must include deleted_at field: {model}"
);
assert!(
model.contains("Option<"),
"soft_delete deleted_at field must be Option<...>: {model}"
);
}
#[test]
fn plan_model_without_soft_delete_does_not_emit_deleted_at() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
!model.contains("deleted_at"),
"model without soft_delete must not contain deleted_at: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
!up.contains("deleted_at"),
"migration without soft_delete must not contain deleted_at: {up}"
);
}
#[test]
fn plan_model_soft_delete_rejects_explicit_deleted_at_field() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "deleted_at:NaiveDateTime".into()],
"20260427000000",
&ModelOptions {
soft_delete: true,
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("deleted_at"),
"providing deleted_at with soft_delete must error; got: {msg}"
);
}
#[test]
fn plan_model_soft_delete_schema_includes_deleted_at_column() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions {
soft_delete: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(
schema.contains("deleted_at"),
"schema.rs must include deleted_at column when soft_delete is enabled: {schema}"
);
}
#[test]
fn model_emits_shard_key_attr() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Account",
&["tenant_id:i64".into(), "name:String".into()],
"20260427000000",
&ModelOptions {
sharded: true,
shard_key: Some("tenant_id".into()),
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/account.rs")).unwrap();
assert!(
model.contains("#[shard_key = \"tenant_id\"]"),
"sharded model must emit #[shard_key] attribute: {model}"
);
}
#[test]
fn model_no_shard_key_attr_when_not_sharded() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
!model.contains("shard_key"),
"non-sharded model must not emit shard_key: {model}"
);
}
#[test]
fn migration_notes_shard_target_when_sharded() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Account",
&["tenant_id:i64".into()],
"20260427000000",
&ModelOptions {
sharded: true,
shard_key: Some("tenant_id".into()),
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up_sql = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_accounts/up.sql"),
)
.unwrap();
assert!(
up_sql.contains("autumn migrate --shard"),
"sharded migration up.sql must note autumn migrate --shard: {up_sql}"
);
assert!(
up_sql.contains("control DB"),
"sharded migration up.sql must note control DB default: {up_sql}"
);
}
#[test]
fn migration_no_shard_comment_when_not_sharded() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up_sql = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
!up_sql.contains("autumn migrate --shard"),
"non-sharded migration must not have shard comment: {up_sql}"
);
}
#[test]
fn plan_default_id_type_emits_bigserial_and_i64() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions::default(),
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: i64,"),
"default must emit i64: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("id BIGSERIAL PRIMARY KEY"),
"default must emit BIGSERIAL: {up}"
);
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(
schema.contains("id -> Int8,"),
"default schema must emit Int8: {schema}"
);
}
#[test]
fn plan_uuid_id_type_emits_uuid_type_in_all_outputs() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ModelOptions {
id_type: IdType::Uuid,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("pub id: uuid::Uuid,"),
"uuid must emit uuid::Uuid: {model}"
);
assert!(
!model.contains("pub id: i64"),
"uuid model must not contain i64: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("id UUID PRIMARY KEY DEFAULT gen_random_uuid()"),
"uuid migration: {up}"
);
assert!(
!up.contains("BIGSERIAL"),
"uuid migration must not contain BIGSERIAL: {up}"
);
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(
schema.contains("id -> Uuid,"),
"uuid schema must emit Uuid type: {schema}"
);
assert!(
!schema.contains("id -> Int8"),
"uuid schema must not contain Int8: {schema}"
);
}
#[test]
fn plan_uuid_id_migration_has_uuidv7_comment() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Post",
&[],
"20260427000000",
&ModelOptions {
id_type: IdType::Uuid,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("UUIDv7"),
"uuid migration must document UUIDv7 upgrade path: {up}"
);
}
#[test]
fn uuid_dep_always_present_in_model_deps() {
let uuid_dep = MODEL_DEPS.iter().find(|(k, _)| *k == "uuid");
assert!(
uuid_dep.is_some(),
"MODEL_DEPS must always include the uuid crate (AC5)"
);
let (_, spec) = uuid_dep.unwrap();
assert!(
spec.contains("serde"),
"uuid dep must include serde feature"
);
}
#[test]
fn fk_field_uuid_generates_uuid_column() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Comment",
&["author_id:Uuid".into(), "body:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/comment.rs")).unwrap();
assert!(
model.contains("pub author_id: uuid::Uuid,"),
"FK Uuid field: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_comments/up.sql"),
)
.unwrap();
assert!(
up.contains("author_id UUID NOT NULL"),
"FK Uuid migration: {up}"
);
}
#[test]
fn references_field_emits_i64_struct_field_fk_constraint_and_index() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/comment.rs")).unwrap();
assert!(
model.contains("pub post_id: i64,"),
"references field must render as i64: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_comments/up.sql"),
)
.unwrap();
assert!(
up.contains("post_id BIGINT NOT NULL REFERENCES posts(id)"),
"up.sql must emit the FK column + constraint: {up}"
);
assert!(
up.contains("CREATE INDEX idx_comments_post_id ON comments (post_id);"),
"up.sql must emit an automatic FK index: {up}"
);
let down = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_comments/down.sql"),
)
.unwrap();
assert!(
down.contains("DROP TABLE comments"),
"down.sql drops the whole table, cleanly reversing the FK/index/column: {down}"
);
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(
schema.contains("post_id -> Int8,"),
"schema.rs must use Int8 for the FK column: {schema}"
);
}
#[test]
fn nullable_references_field_emits_option_i64() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references?".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/comment.rs")).unwrap();
assert!(
model.contains("pub post_id: Option<i64>,"),
"nullable references field must render as Option<i64>: {model}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_comments/up.sql"),
)
.unwrap();
assert!(
up.contains("post_id BIGINT NULL REFERENCES posts(id)"),
"{up}"
);
}
#[test]
fn references_field_warns_when_target_model_is_missing() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert_eq!(plan.warnings.len(), 1, "warnings: {:?}", plan.warnings);
assert!(plan.warnings[0].contains("post"));
assert!(plan.warnings[0].contains("posts"));
}
#[test]
fn references_field_no_warning_when_target_model_exists() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(models_dir.join("post.rs"), "// existing Post model\n").unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings.is_empty(),
"no warning expected once src/models/post.rs exists: {:?}",
plan.warnings
);
}
#[test]
fn no_references_field_means_no_warnings() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
assert!(plan.warnings.is_empty());
}
#[test]
fn references_field_errors_when_target_model_has_uuid_pk() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: uuid::Uuid,\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("post"), "error should name the field: {msg}");
assert!(
msg.contains("UUID"),
"error should explain the UUID PK mismatch: {msg}"
);
}
#[test]
fn references_field_no_error_when_target_model_has_bigserial_pk() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: i64,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert!(plan.warnings.is_empty());
}
#[test]
fn references_field_no_warning_when_target_declared_in_single_file_models_rs() {
let tmp = project();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
"use crate::schema::posts;\n\n#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: i64,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings.is_empty(),
"no warning expected once Post is declared in src/models.rs: {:?}",
plan.warnings
);
}
#[test]
fn references_field_no_warning_for_grouped_schema_import_in_single_file_models_rs() {
let tmp = project();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
"use crate::schema::{comments, posts, subreddits, users, votes};\n\n\
#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: i64,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings.is_empty(),
"grouped schema import must be recognized: {:?}",
plan.warnings
);
}
#[test]
fn references_field_detects_uuid_pk_with_grouped_schema_import() {
let tmp = project();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
"use crate::schema::{comments, posts};\n\n\
#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: uuid::Uuid,\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_grouped_schema_import_not_confused_by_table_name_prefix() {
let tmp = project();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
"use crate::schema::{posts_tags, users};\n\n\
#[autumn_web::model]\npub struct PostsTag {\n #[id]\n pub id: i64,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert_eq!(
plan.warnings.len(),
1,
"'posts_tags' in a grouped import must not satisfy a reference to 'posts': {:?}",
plan.warnings
);
}
#[test]
fn references_field_no_warning_for_multiline_grouped_schema_import() {
let tmp = project();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
"use crate::schema::{\n comments,\n posts,\n users,\n};\n\n\
#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: i64,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings.is_empty(),
"multi-line grouped schema import must be recognized: {:?}",
plan.warnings
);
}
#[test]
fn references_field_still_warns_when_single_file_models_rs_lacks_the_model() {
let tmp = project();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
"use crate::schema::users;\n\n#[autumn_web::model]\npub struct User {\n #[id]\n pub id: i64,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert_eq!(plan.warnings.len(), 1, "warnings: {:?}", plan.warnings);
}
#[test]
fn references_field_not_confused_by_table_name_prefix_in_single_file_models_rs() {
let tmp = project();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
"use crate::schema::posts_tags;\n\n#[autumn_web::model]\npub struct PostsTag {\n #[id]\n pub id: i64,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert_eq!(
plan.warnings.len(),
1,
"'posts_tags' must not satisfy a reference to 'posts': {:?}",
plan.warnings
);
}
#[test]
fn references_field_detects_uuid_pk_even_when_id_field_is_renamed() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n pub uid: uuid::Uuid,\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_not_confused_by_an_unrelated_field_literally_named_id() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n pub pk: i64,\n pub id: uuid::Uuid,\n}\n",
)
.unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings.is_empty(),
"an unrelated field named 'id' must not trigger a false UUID-PK error: {:?}",
plan.warnings
);
}
#[test]
fn references_field_detects_unqualified_uuid_type() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"use uuid::Uuid;\n\n#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: Uuid,\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_detects_uuid_pk_through_intervening_attribute() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n #[serde(rename = \"id\")]\n pub id: uuid::Uuid,\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_detects_uuid_pk_through_intervening_doc_comment() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n /// The primary key.\n pub id: uuid::Uuid,\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_detects_uuid_pk_with_trailing_inline_comment() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: uuid::Uuid, // primary key\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_detects_uuid_pk_with_trailing_block_comment() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[id]\n pub id: uuid::Uuid, /* primary key */\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_detects_uuid_pk_regardless_of_attribute_order() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(
models_dir.join("post.rs"),
"#[autumn_web::model]\npub struct Post {\n #[serde(rename = \"id\")]\n #[id]\n pub id: uuid::Uuid,\n}\n",
)
.unwrap();
let err = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
}
#[test]
fn references_field_handles_unparseable_model_file_gracefully() {
let tmp = project();
let models_dir = tmp.path().join("src/models");
fs::create_dir_all(&models_dir).unwrap();
fs::write(models_dir.join("post.rs"), "this is not valid rust {{{").unwrap();
let plan = plan_model(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert!(plan.warnings.is_empty());
}
#[test]
fn self_referential_references_errors_when_own_id_is_uuid() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Category",
&["name:String".into(), "category:references".into()],
"20260427000000",
&ModelOptions {
id_type: IdType::Uuid,
..Default::default()
},
)
.unwrap_err();
assert!(err.to_string().contains("UUID"));
assert!(err.to_string().contains("self-referential"));
}
#[test]
fn self_referential_references_fine_with_default_bigserial_id() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Category",
&["name:String".into(), "category:references".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings.is_empty(),
"a self-reference to the table being created now must not warn \
'model not found': {:?}",
plan.warnings
);
}
#[test]
fn self_referential_references_emits_correct_fk_sql() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Category",
&["name:String".into(), "category:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_categories/up.sql"),
)
.unwrap();
assert!(up.contains("category_id BIGINT NOT NULL REFERENCES categories(id)"));
}
#[test]
fn lock_version_field_emits_lock_version_attribute() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("#[lock_version]\n pub lock_version: i32,"),
"a `lock_version` column must carry the framework's `#[lock_version]` \
attribute so `#[repository]` update raises RepositoryError::Conflict: {model}"
);
}
#[test]
fn lock_version_column_gets_sql_default_zero() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("lock_version INTEGER NOT NULL DEFAULT 0"),
"got:\n{up}"
);
}
#[test]
fn position_field_emits_position_attribute() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Task",
&["title:String".into(), "rank:position".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/task.rs")).unwrap();
assert!(
model.contains("#[position]\n pub rank: i64,"),
"a `position` column must carry the framework's `#[position]` attribute so it is \
excluded from New/UpdateTask: {model}"
);
}
#[test]
fn position_column_gets_sql_default_zero() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Task",
&["title:String".into(), "rank:position".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_tasks/up.sql"),
)
.unwrap();
assert!(up.contains("rank BIGINT NOT NULL DEFAULT 0"), "got:\n{up}");
}
#[test]
fn lock_version_bigint_is_also_supported() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i64".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("#[lock_version]\n pub lock_version: i64,"),
"got:\n{model}"
);
}
#[test]
fn model_without_lock_version_emits_no_lock_version_attribute() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(!model.contains("lock_version"), "got:\n{model}");
}
#[test]
fn lock_version_with_non_integer_type_is_rejected() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:String".into()],
"20260427000000",
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("lock_version") && msg.contains("i32"),
"the error must name the field and the supported types: {msg}"
);
}
#[test]
fn a_model_with_no_insertable_columns_is_rejected() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&["lock_version:i32".into()],
"20260427000000",
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no insertable columns") && msg.contains("NewPost"),
"got: {msg}"
);
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
&ModelOptions {
defaults: vec!["title=x".into()],
..Default::default()
},
)
.unwrap_err();
assert!(
format!("{err}").contains("no insertable columns"),
"got: {err}"
);
let tmp = project();
plan_model(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
)
.expect("a lock column plus an ordinary column must generate");
}
#[test]
fn destroying_a_legacy_lock_version_model_is_never_blocked_by_the_new_checks() {
for cols in [
vec!["title:String".to_owned(), "lock_version:String".to_owned()],
vec![
"title:String".to_owned(),
"lock_version:Option<i32>".to_owned(),
],
vec!["lock_version:i32".to_owned()],
] {
let tmp = project();
let plan = plan_model_with_options_for_revert(
tmp.path(),
"Post",
&cols,
"20260427000000",
&ModelOptions::default(),
);
assert!(
plan.is_ok(),
"destroying a legacy model with {cols:?} must plan: {:?}",
plan.err()
);
}
let tmp = project();
assert!(
plan_model_with_options_for_revert(
tmp.path(),
"Post",
&["title:NotAType".to_owned()],
"20260427000000",
&ModelOptions::default(),
)
.is_err(),
"a malformed field list must still fail on the revert path"
);
}
#[test]
fn a_lock_version_seeded_at_its_ceiling_is_rejected() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
&ModelOptions {
defaults: vec!["lock_version=2147483647".into()],
..Default::default()
},
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("2147483647") && msg.contains("i64"),
"the refusal must name the ceiling and the way out: {msg}"
);
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i64".into()],
"20260427000000",
&ModelOptions {
defaults: vec!["lock_version=9223372036854775807".into()],
..Default::default()
},
)
.unwrap_err();
assert!(
format!("{err}").contains("9223372036854775807"),
"got: {err}"
);
for (ty, seed) in [
("lock_version:i64", "lock_version=2147483647"),
("lock_version:i32", "lock_version=2147483646"),
("lock_version:i32", "lock_version=5"),
] {
let tmp = project();
plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), ty.into()],
"20260427000000",
&ModelOptions {
defaults: vec![seed.into()],
..Default::default()
},
)
.unwrap_or_else(|e| panic!("{ty} seeded {seed} must be accepted: {e}"));
}
}
#[test]
fn unique_lock_version_is_rejected() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
&ModelOptions {
uniques: vec!["lock_version".into()],
..Default::default()
},
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("lock_version") && msg.contains("unique"),
"got: {msg}"
);
}
#[test]
fn lock_version_emits_a_plan_warning_so_the_opt_in_is_never_silent() {
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("optimistic locking") && w.contains("Rename")),
"expected an opt-in warning naming the escape hatch: {:?}",
plan.warnings
);
let tmp = project();
let plan = plan_model(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
assert!(
!plan.warnings.iter().any(|w| w.contains("lock")),
"a model without the column must not warn: {:?}",
plan.warnings
);
}
#[test]
fn nullable_lock_version_is_rejected() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:Option<i32>".into()],
"20260427000000",
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("lock_version") && msg.contains("non-nullable"),
"the nullability guard must be what rejects it: {msg}"
);
}
#[test]
fn index_flag_on_randomized_encrypted_field_is_rejected() {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
&ModelOptions {
indexes: vec!["api_token".into()],
..ModelOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("api_token"), "must name the field: {msg}");
assert!(
msg.contains("deterministic"),
"must point at the fix: {msg}"
);
}
#[test]
fn index_flag_on_deterministic_encrypted_field_is_allowed() {
let tmp = project();
let plan = plan_model_with_options(
tmp.path(),
"Account",
&["email:String{encrypted:deterministic}".into()],
"20260427000000",
&ModelOptions {
indexes: vec!["email".into()],
..ModelOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_accounts/up.sql"),
)
.unwrap();
assert!(
up.contains("CREATE INDEX idx_accounts_email ON accounts (email);"),
"up.sql: {up}"
);
}
#[test]
fn shard_key_on_an_encrypted_field_is_rejected() {
for mode in ["encrypted", "encrypted:deterministic"] {
let tmp = project();
let err = plan_model_with_options(
tmp.path(),
"Account",
&[format!("tenant:String{{{mode}}}")],
"20260427000000",
&ModelOptions {
sharded: true,
shard_key: Some("tenant".into()),
..ModelOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("tenant"), "must name the field: {msg}");
assert!(msg.contains("shard-key"), "must name the flag: {msg}");
}
}
#[test]
fn destroy_recompute_skips_the_generation_only_encryption_refusals() {
let tmp = project();
let options = ModelOptions {
uniques: vec!["api_token".into()],
..ModelOptions::default()
};
assert!(
plan_model_with_options(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
&options,
)
.is_err()
);
assert!(
plan_model_with_options_for_revert(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
&options,
)
.is_ok(),
"destroy must be able to recompute a plan it is about to revert"
);
}
fn project_with_the_shared_comments_table() -> TempDir {
let tmp = project();
let dir = tmp
.path()
.join("migrations/202604270000002_create_comments");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("up.sql"),
super::super::commentable::up_sql(autumn_web::config::DatabaseBackend::Postgres),
)
.unwrap();
tmp
}
#[test]
fn a_comment_model_over_the_shared_table_is_refused() {
let tmp = project_with_the_shared_comments_table();
let err = plan_model(
tmp.path(),
"Comment",
&["body:Text".into()],
"20260427000000",
)
.expect_err("the shared table already uses the name `comments`");
let message = err.to_string();
assert!(message.contains("`comments`"), "{message}");
assert!(message.contains("shared"), "{message}");
}
#[test]
fn a_commentable_comment_model_is_refused() {
let tmp = project();
let err = plan_model(
tmp.path(),
"Comment",
&["body:Text".into(), "comments:commentable".into()],
"20260427000000",
)
.expect_err("two `CREATE TABLE comments` in one plan");
assert!(err.to_string().contains("shared"), "{err}");
}
#[test]
fn a_comment_model_revert_is_not_refused() {
let tmp = project_with_the_shared_comments_table();
assert!(
plan_model_with_options_for_revert(
tmp.path(),
"Comment",
&["body:Text".into()],
"20260427000000",
&ModelOptions::default(),
)
.is_ok()
);
}
#[test]
fn a_comment_model_revert_is_allowed_when_the_shared_migration_stands_alone() {
let tmp = project_with_the_shared_comments_table();
let plain = tmp.path().join("migrations/20260101000000_create_comments");
fs::create_dir_all(&plain).unwrap();
fs::write(
plain.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL);\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src/models")).unwrap();
fs::write(
tmp.path().join("src/models/post.rs"),
"#[commentable]\npub struct Post {}\n",
)
.unwrap();
assert!(
plan_model_with_options_for_revert(
tmp.path(),
"Comment",
&["body:Text".into()],
"20260101000000",
&ModelOptions::default(),
)
.is_ok()
);
}
}