use proc_macro2::TokenStream;
use quote::{ToTokens as _, format_ident, quote};
use syn::parse::Parser as _;
use syn::{DeriveInput, Field, LitStr};
use crate::commentable::{emit_commentable_items, is_commentable_attr, resolve_commentable};
#[derive(Default, Debug)]
struct ModelArgs {
table: Option<String>,
#[allow(dead_code)]
managed: bool,
}
fn parse_attr_args(attr: TokenStream) -> syn::Result<ModelArgs> {
let mut args = ModelArgs::default();
if attr.is_empty() {
return Ok(args);
}
syn::meta::parser(|meta| {
if meta.path.is_ident("table") {
let value: LitStr = meta.value()?.parse()?;
args.table = Some(value.value());
Ok(())
} else if meta.path.is_ident("managed") {
if meta.input.peek(syn::Token![=]) || meta.input.peek(syn::token::Paren) {
return Err(
meta.error("`managed` takes no value; write a bare `#[model(managed)]`")
);
}
args.managed = true;
Ok(())
} else {
Err(meta.error(
"unsupported `#[model(...)]` argument; expected `table = \"...\"` or `managed`",
))
}
})
.parse2(attr)?;
Ok(args)
}
fn has_attr(field: &Field, name: &str) -> bool {
field.attrs.iter().any(|a| a.path().is_ident(name))
}
fn validate_field_schema_markers(field: &Field) -> syn::Result<()> {
for attr in &field.attrs {
if attr.path().is_ident("unique") {
if !matches!(attr.meta, syn::Meta::Path(_)) {
return Err(syn::Error::new_spanned(
attr,
"`#[unique]` takes no arguments; write a bare `#[unique]`",
));
}
} else if attr.path().is_ident("position") {
if !matches!(attr.meta, syn::Meta::Path(_)) {
return Err(syn::Error::new_spanned(
attr,
"`#[position]` takes no arguments; write a bare `#[position]`",
));
}
} else if attr.path().is_ident("references") {
if matches!(attr.meta, syn::Meta::Path(_)) {
continue;
}
if !matches!(attr.meta, syn::Meta::List(_)) {
return Err(syn::Error::new_spanned(
attr,
"`#[references]` must be a bare attribute or have the form `#[references(table = \"...\")]`",
));
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("table") {
let _value: LitStr = meta.value()?.parse()?;
Ok(())
} else {
Err(meta.error(
"unsupported `#[references(...)]` argument; expected `table = \"...\"`",
))
}
})?;
}
}
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum AssocKind {
BelongsTo,
HasMany,
HasOne,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum DependentAction {
Destroy,
DeleteAll,
Nullify,
Restrict,
}
impl DependentAction {
fn parse(action: &str) -> Option<Self> {
match action {
"destroy" => Some(Self::Destroy),
"delete_all" => Some(Self::DeleteAll),
"nullify" => Some(Self::Nullify),
"restrict" => Some(Self::Restrict),
_ => None,
}
}
fn variant_ident(self) -> proc_macro2::Ident {
let name = match self {
Self::Destroy => "Destroy",
Self::DeleteAll => "DeleteAll",
Self::Nullify => "Nullify",
Self::Restrict => "Restrict",
};
format_ident!("{name}")
}
}
#[derive(Clone, Debug)]
struct CounterCacheDecl {
column: Option<String>,
tenant_column: Option<String>,
span: proc_macro2::Span,
}
struct Association {
kind: AssocKind,
target: syn::Ident,
fk: String,
name: String,
through: Option<ThroughSpec>,
dependent: Option<DependentAction>,
helper: Option<String>,
counter_cache: Option<CounterCacheDecl>,
}
struct ThroughSpec {
table: String,
target_fk: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VoteAggregate {
Sum,
Count,
}
struct VotableSpec {
reactor: syn::Ident,
aggregate: VoteAggregate,
name: String,
table: String,
reactor_fk: String,
target_fk: String,
value_column: Option<String>,
column: String,
}
fn check_votable_ident_value(
key: &syn::Ident,
value: &str,
span: proc_macro2::Span,
) -> syn::Result<()> {
if !value.starts_with("r#") && syn::parse_str::<syn::Ident>(value).is_ok() {
return Ok(());
}
Err(syn::Error::new(
span,
format!(
"`{value}` is not a valid identifier for `{key} = ...` in \
`#[votable]`: the value is spliced verbatim into generated table, \
column and module names, so it must be a plain Rust identifier — \
no spaces or punctuation, no leading digit, no keyword, and no \
`r#` prefix"
),
))
}
#[allow(clippy::too_many_lines)]
fn parse_votable_attr(attr: &syn::Attribute, model_ident: &syn::Ident) -> syn::Result<VotableSpec> {
use syn::parse::ParseStream;
if matches!(attr.meta, syn::Meta::Path(_)) {
return Err(syn::Error::new_spanned(
attr,
"`#[votable]` requires `by = <ReactorModel>` \
(e.g. `#[votable(by = User)]`)",
));
}
let mut reactor: Option<syn::Ident> = None;
let mut aggregate: Option<VoteAggregate> = None;
let mut name: Option<String> = None;
let mut table: Option<String> = None;
let mut reactor_fk: Option<String> = None;
let mut target_fk: Option<String> = None;
let mut value_column: Option<syn::Ident> = None;
let mut value_column_value: Option<String> = None;
let mut column: Option<String> = None;
let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
attr.parse_args_with(|input: ParseStream| {
while !input.is_empty() {
let key: syn::Ident = input.parse()?;
input.parse::<syn::Token![=]>()?;
let (value_ident, value, value_span) = if input.peek(LitStr) {
let lit: LitStr = input.parse()?;
let span = lit.span();
(None, lit.value(), span)
} else {
let ident: syn::Ident = input.parse()?;
let span = ident.span();
(Some(ident.clone()), ident.to_string(), span)
};
if let Some(previous) = seen.insert(key.to_string(), value.clone()) {
return Err(syn::Error::new_spanned(
&key,
format!(
"duplicate `{key} = ...` in `#[votable]` (was \
`{previous}`, now `{value}`): each key may be given \
at most once — the later value would silently win"
),
));
}
if key == "by" {
check_votable_ident_value(&key, &value, value_span)?;
reactor = Some(match value_ident {
Some(ident) => ident,
None => syn::parse_str::<syn::Ident>(&value).map_err(|_| {
syn::Error::new_spanned(
&key,
format!("`by = \"{value}\"` is not a valid model type name"),
)
})?,
});
} else if key == "aggregate" {
aggregate = Some(match value.as_str() {
"sum" => VoteAggregate::Sum,
"count" => VoteAggregate::Count,
other => {
return Err(syn::Error::new_spanned(
&key,
format!(
"unknown aggregate `{other}`; expected `sum` \
(signed up/down votes) or `count` (unary likes)"
),
));
}
});
} else if key == "name" {
check_votable_ident_value(&key, &value, value_span)?;
name = Some(value);
} else if key == "table" {
check_votable_ident_value(&key, &value, value_span)?;
table = Some(value);
} else if key == "reactor_fk" {
check_votable_ident_value(&key, &value, value_span)?;
reactor_fk = Some(value);
} else if key == "target_fk" {
check_votable_ident_value(&key, &value, value_span)?;
target_fk = Some(value);
} else if key == "value_column" {
check_votable_ident_value(&key, &value, value_span)?;
value_column = Some(key.clone());
value_column_value = Some(value);
} else if key == "column" {
check_votable_ident_value(&key, &value, value_span)?;
column = Some(value);
} else {
return Err(syn::Error::new_spanned(
&key,
"expected `by = <Model>`, `aggregate = sum|count`, \
`name = <ident>`, `table = <table>`, \
`reactor_fk = <column>`, `target_fk = <column>`, \
`value_column = <column>`, or \
`column = <aggregate_column>` in `#[votable]`",
));
}
if input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
} else {
break;
}
}
Ok(())
})?;
let Some(reactor) = reactor else {
return Err(syn::Error::new_spanned(
attr,
"`#[votable]` requires `by = <ReactorModel>` \
(e.g. `#[votable(by = User)]`)",
));
};
let aggregate = aggregate.unwrap_or(VoteAggregate::Sum);
if aggregate == VoteAggregate::Count
&& let Some(key) = value_column
{
return Err(syn::Error::new_spanned(
key,
"`value_column = ...` has no meaning with `aggregate = count`: a \
count reaction's edge table stores no value column, its rows are \
pure membership — use `aggregate = sum` (signed values) or drop \
the key",
));
}
let name = name.unwrap_or_else(|| "vote".to_owned());
let table = table.unwrap_or_else(|| pluralize_word(&name));
let reactor_fk =
reactor_fk.unwrap_or_else(|| format!("{}_id", pascal_to_snake(&reactor.to_string())));
let target_fk =
target_fk.unwrap_or_else(|| format!("{}_id", pascal_to_snake(&model_ident.to_string())));
let value_column = match aggregate {
VoteAggregate::Sum => Some(value_column_value.unwrap_or_else(|| "value".to_owned())),
VoteAggregate::Count => None,
};
let column = column.unwrap_or_else(|| match aggregate {
VoteAggregate::Sum => "score".to_owned(),
VoteAggregate::Count => format!("{name}_count"),
});
Ok(VotableSpec {
reactor,
aggregate,
name,
table,
reactor_fk,
target_fk,
value_column,
column,
})
}
fn resolve_votable(
model_ident: &syn::Ident,
attrs: &[syn::Attribute],
) -> syn::Result<Option<VotableSpec>> {
let mut found: Option<&syn::Attribute> = None;
for attr in attrs {
if !is_votable_attr(attr) {
continue;
}
if found.is_some() {
return Err(syn::Error::new_spanned(
attr,
"at most one `#[votable]` per model: the generated \
`{Model}Reactions` trait's `react`/`reaction_of` methods would \
otherwise collide (multiple reaction kinds per model are not \
yet supported — see \
https://github.com/autumn-foundation/autumn/issues/1362)",
));
}
found = Some(attr);
}
found
.map(|attr| parse_votable_attr(attr, model_ident))
.transpose()
}
fn is_votable_attr(attr: &syn::Attribute) -> bool {
attr.path().is_ident("votable")
}
fn resolve_fk_and_name(
kind: AssocKind,
model_ident: &syn::Ident,
target_ident: &syn::Ident,
explicit_fk: Option<&str>,
) -> (String, String) {
let snake_target = pascal_to_snake(&target_ident.to_string());
let snake_source = pascal_to_snake(&model_ident.to_string());
match kind {
AssocKind::BelongsTo => {
let fk = explicit_fk.map_or_else(|| format!("{snake_target}_id"), ToOwned::to_owned);
let name = fk.strip_suffix("_id").unwrap_or(&fk).to_owned();
(fk, name)
}
AssocKind::HasMany => {
let fk = explicit_fk.map_or_else(|| format!("{snake_source}_id"), ToOwned::to_owned);
let name = pluralize_word(&snake_target);
(fk, name)
}
AssocKind::HasOne => {
let fk = explicit_fk.map_or_else(|| format!("{snake_source}_id"), ToOwned::to_owned);
(fk, snake_target)
}
}
}
fn parse_dependent_action(
kind: AssocKind,
key: &syn::Ident,
action: &str,
) -> syn::Result<DependentAction> {
if kind == AssocKind::BelongsTo {
return Err(syn::Error::new_spanned(
key,
"`dependent`/`on_delete` is not valid on `#[belongs_to]`: the child \
foreign key lives on this (belongs_to) side, so there is no \
dependent to cascade — declare the cascade on the parent's \
`#[has_many]`/`#[has_one]` instead",
));
}
DependentAction::parse(action).ok_or_else(|| {
syn::Error::new_spanned(
key,
format!(
"unknown dependent action `{action}`; expected one of \
`destroy`, `delete_all`, `nullify`, `restrict`"
),
)
})
}
#[allow(clippy::too_many_lines)]
fn parse_assoc_attr(
attr: &syn::Attribute,
kind: AssocKind,
model_ident: &syn::Ident,
) -> syn::Result<Association> {
use syn::parse::ParseStream;
let (
target,
explicit_fk,
explicit_name,
explicit_through,
explicit_target_fk,
dependent,
explicit_helper,
counter_cache,
counter_cache_tenant,
) = attr.parse_args_with(|input: ParseStream| {
let target: syn::Ident = input.parse()?;
let mut explicit_fk: Option<String> = None;
let mut explicit_name: Option<String> = None;
let mut explicit_through: Option<String> = None;
let mut explicit_target_fk: Option<String> = None;
let mut dependent: Option<DependentAction> = None;
let mut explicit_helper: Option<String> = None;
let mut counter_cache: Option<CounterCacheDecl> = None;
let mut counter_cache_tenant: Option<(String, proc_macro2::Span)> = None;
while input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
let key: syn::Ident = input.parse()?;
if key == "counter_cache" && !input.peek(syn::Token![=]) {
counter_cache = Some(CounterCacheDecl {
column: None,
tenant_column: None,
span: key.span(),
});
continue;
}
input.parse::<syn::Token![=]>()?;
let value = if input.peek(LitStr) {
input.parse::<LitStr>()?.value()
} else {
input.parse::<syn::Ident>()?.to_string()
};
if key == "counter_cache" {
check_counter_cache_column(&key, &value)?;
counter_cache = Some(CounterCacheDecl {
column: Some(value),
tenant_column: None,
span: key.span(),
});
} else if key == "counter_cache_tenant" {
check_counter_cache_column(&key, &value)?;
counter_cache_tenant = Some((value, key.span()));
} else if key == "fk" {
explicit_fk = Some(value);
} else if key == "name" {
explicit_name = Some(value);
} else if key == "through" {
explicit_through = Some(value);
} else if key == "target_fk" {
explicit_target_fk = Some(value);
} else if key == "helper" {
explicit_helper = Some(value);
} else if key == "dependent" || key == "on_delete" {
dependent = Some(parse_dependent_action(kind, &key, &value)?);
} else {
return Err(syn::Error::new_spanned(
&key,
"expected `fk = <column>`, `name = <accessor>`, \
`through = <join_table>`, `target_fk = <column>`, \
`helper = <singular>`, `counter_cache` / \
`counter_cache = <column>`, or \
`counter_cache_tenant = <column>` in association \
attribute",
));
}
}
Ok((
target,
explicit_fk,
explicit_name,
explicit_through,
explicit_target_fk,
dependent,
explicit_helper,
counter_cache,
counter_cache_tenant,
))
})?;
let counter_cache = match (counter_cache, counter_cache_tenant) {
(Some(decl), Some((tenant_column, _))) => Some(CounterCacheDecl {
tenant_column: Some(tenant_column),
..decl
}),
(decl @ Some(_), None) => decl,
(None, Some((_, span))) => {
return Err(syn::Error::new(
span,
"`counter_cache_tenant = \"<column>\"` scopes a counter cache to \
the caller's tenant, so it requires `counter_cache` on the same \
association",
));
}
(None, None) => None,
};
if let Some(decl) = counter_cache.as_ref()
&& kind != AssocKind::BelongsTo
{
return Err(syn::Error::new(
decl.span,
format!(
"`counter_cache` is a `#[belongs_to]` option: the counter is \
maintained by the child's repository — the side that owns the \
foreign key and runs the insert/delete — so there is nothing on \
this side to hang the maintenance off. Move it to the child \
model's `#[belongs_to(...)]`, i.e. `#[belongs_to({model_ident}, \
counter_cache)]` on `{target}`"
),
));
}
if let Some(decl) = counter_cache.as_ref()
&& explicit_through.is_some()
{
return Err(syn::Error::new(
decl.span,
"`counter_cache` is not supported on a `through = <join_table>` \
(many-to-many) association: its foreign key names a column on the \
join table, not on this model, so the generated increment would \
read a column that does not exist. Counters over join tables are \
out of scope — map the join table as its own model and put \
`counter_cache` on its `#[belongs_to]`",
));
}
if explicit_through.is_some() && kind != AssocKind::HasMany {
return Err(syn::Error::new_spanned(
&target,
"`through = <join_table>` (many-to-many) is only supported on \
`has_many`, not `belongs_to`/`has_one`",
));
}
if explicit_target_fk.is_some() && explicit_through.is_none() {
return Err(syn::Error::new_spanned(
&target,
"`target_fk = <column>` requires `through = <join_table>`",
));
}
if explicit_through.is_some() && dependent.is_some() {
return Err(syn::Error::new_spanned(
&target,
"`dependent`/`on_delete` cascade is not supported on a `through = \
<join_table>` (many-to-many) association: its foreign key names a \
column on the join table, not on the target model, so the cascade \
would delete/nullify the wrong rows — remove `dependent`/\
`on_delete`, or declare the cascade on a model that maps the join \
table directly",
));
}
if explicit_helper.is_some() && explicit_through.is_none() {
return Err(syn::Error::new_spanned(
&target,
"`helper = <singular>` overrides the many-to-many `add_`/`remove_` \
mutation-helper names and therefore requires `through = \
<join_table>` (it has no effect on plain `belongs_to`/`has_one`/\
non-`through` `has_many` associations)",
));
}
let (fk, derived_name) =
resolve_fk_and_name(kind, model_ident, &target, explicit_fk.as_deref());
let name = explicit_name.unwrap_or(derived_name);
let through = explicit_through.map(|table| {
let snake_target = pascal_to_snake(&target.to_string());
let target_fk = explicit_target_fk.unwrap_or_else(|| format!("{snake_target}_id"));
ThroughSpec { table, target_fk }
});
Ok(Association {
kind,
target,
fk,
name,
through,
dependent,
helper: explicit_helper,
counter_cache,
})
}
fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
let syn::Type::Path(type_path) = ty else {
return None;
};
let segment = type_path.path.segments.last()?;
if segment.ident != "Option" {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
return None;
};
args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(inner) => Some(inner),
_ => None,
})
}
fn check_counter_cache_column(key: &syn::Ident, value: &str) -> syn::Result<()> {
let plain = !value.is_empty()
&& !value.starts_with(|c: char| c.is_ascii_digit())
&& value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if plain {
return Ok(());
}
Err(syn::Error::new_spanned(
key,
format!(
"`{value}` is not a valid column name for `counter_cache = ...`: \
the value is spliced verbatim into the generated `UPDATE <parent> \
SET <column> = <column> + $1` statement, so it must be a plain \
identifier — ASCII letters, digits and underscores only, and no \
leading digit"
),
))
}
fn resolve_associations(
model_ident: &syn::Ident,
attrs: &[syn::Attribute],
) -> syn::Result<Vec<Association>> {
let mut out = Vec::new();
for attr in attrs {
let kind = if attr.path().is_ident("belongs_to") {
AssocKind::BelongsTo
} else if attr.path().is_ident("has_many") {
AssocKind::HasMany
} else if attr.path().is_ident("has_one") {
AssocKind::HasOne
} else {
continue;
};
out.push(parse_assoc_attr(attr, kind, model_ident)?);
}
check_m2m_mutation_name_collisions(&out)?;
check_counter_cache_collisions(model_ident, &out)?;
Ok(out)
}
fn counter_cache_column(model_ident: &syn::Ident, assoc: &Association) -> Option<String> {
let decl = assoc.counter_cache.as_ref()?;
Some(
decl.column
.clone()
.unwrap_or_else(|| format!("{}_count", pascal_to_snake(&model_ident.to_string()))),
)
}
fn check_counter_cache_collisions(
model_ident: &syn::Ident,
assocs: &[Association],
) -> syn::Result<()> {
let mut seen: Vec<(String, String)> = Vec::new();
for assoc in assocs {
let Some(column) = counter_cache_column(model_ident, assoc) else {
continue;
};
let parent_table = infer_table_name(&assoc.target);
let key = (parent_table.clone(), column.clone());
if seen.contains(&key) {
let span = assoc
.counter_cache
.as_ref()
.map_or_else(proc_macro2::Span::call_site, |d| d.span);
return Err(syn::Error::new(
span,
format!(
"two counter-cached associations on `{model_ident}` both \
maintain `{parent_table}.{column}`, so every insert would \
count twice. The column name defaults to \
`{{snake(model)}}_count`, which collides whenever two \
`belongs_to` legs point at the same parent — give at least \
one of them an explicit `counter_cache = \"<column>\"` (and \
a matching column on `{parent_table}`)"
),
));
}
seen.push(key);
}
Ok(())
}
fn m2m_mutation_singular(target: &syn::Ident) -> String {
pascal_to_snake(&target.to_string())
}
fn resolved_m2m_singular(assoc: &Association) -> String {
assoc
.helper
.clone()
.unwrap_or_else(|| m2m_mutation_singular(&assoc.target))
}
fn check_m2m_mutation_name_collisions(assocs: &[Association]) -> syn::Result<()> {
let mut seen: std::collections::HashMap<String, &syn::Ident> = std::collections::HashMap::new();
for assoc in assocs {
if assoc.through.is_none() {
continue;
}
let singular = resolved_m2m_singular(assoc);
if seen.insert(singular.clone(), &assoc.target).is_some() {
return Err(syn::Error::new_spanned(
&assoc.target,
format!(
"many-to-many association `{}` (target `{}`) resolves to the \
same mutation helpers `add_{singular}`/`remove_{singular}` \
as another `through =` association on this model. Two m2m \
associations to the same target type would otherwise \
generate colliding helpers. Give at least one an explicit \
per-association override, e.g. `#[has_many({}, through = \
..., helper = \"...\")]`, so each gets a distinct \
`add_`/`remove_` name (the followers/following-through-a-\
join pattern) — see \
https://github.com/autumn-foundation/autumn/issues/1785",
assoc.name, assoc.target, assoc.target,
),
));
}
}
Ok(())
}
fn is_association_attr(attr: &syn::Attribute) -> bool {
attr.path().is_ident("belongs_to")
|| attr.path().is_ident("has_many")
|| attr.path().is_ident("has_one")
}
fn emit_dependents_impl(model_ident: &syn::Ident, assocs: &[Association]) -> TokenStream {
let deps: Vec<&Association> = assocs.iter().filter(|a| a.dependent.is_some()).collect();
if deps.is_empty() {
return quote! {};
}
let mut thunk_fns: Vec<TokenStream> = Vec::new();
let mut spec_entries: Vec<TokenStream> = Vec::new();
for (i, assoc) in deps.iter().enumerate() {
let action = assoc.dependent.expect("filtered to Some above");
let action_variant = action.variant_ident();
let fk = &assoc.fk;
let child_repo = format_ident!("Pg{}Repository", assoc.target);
let thunk_ident = format_ident!("__autumn_dependent_cascade_{}", i);
thunk_fns.push(quote! {
fn #thunk_ident<'__a>(
__pool: &'__a ::autumn_web::reexports::diesel_async::pooled_connection::deadpool::Pool<
::autumn_web::RuntimeConnection,
>,
__conn: &'__a mut ::autumn_web::RuntimeConnection,
__parent_id: i64,
__parent_soft: bool,
__path: &'__a mut ::std::collections::HashSet<(&'static str, i64)>,
__deleted: &'__a mut ::std::collections::HashSet<(&'static str, i64)>,
__physical: &'__a mut ::std::collections::HashSet<(&'static str, i64)>,
) -> ::autumn_web::repository::RuntimeDependentCascadeFuture<'__a> {
::std::boxed::Box::pin(async move {
let __autumn_child_repo = #child_repo::with_pool_untracked(
::core::clone::Clone::clone(__pool),
);
__autumn_child_repo
.__autumn_apply_dependent_on_conn(
__conn,
#fk,
__parent_id,
::autumn_web::repository::DependentAction::#action_variant,
__parent_soft,
__path,
__deleted,
__physical,
)
.await
})
}
});
spec_entries.push(quote! {
::autumn_web::repository::RuntimeDependentSpec {
fk: #fk,
action: ::autumn_web::repository::DependentAction::#action_variant,
cascade: #thunk_ident,
}
});
}
quote! {
impl #model_ident {
#[doc(hidden)]
#[must_use]
pub fn dependents() -> &'static [::autumn_web::repository::RuntimeDependentSpec] {
#(#thunk_fns)*
const __AUTUMN_DEPENDENTS: &[::autumn_web::repository::RuntimeDependentSpec] = &[
#(#spec_entries),*
];
__AUTUMN_DEPENDENTS
}
}
}
}
#[allow(clippy::too_many_lines)]
fn emit_counter_caches_impl(
model_ident: &syn::Ident,
table_name: &str,
pk_ident: Option<&syn::Ident>,
has_deleted_at: bool,
assocs: &[Association],
all_fields: &[&syn::Field],
) -> syn::Result<TokenStream> {
let cached: Vec<&Association> = assocs
.iter()
.filter(|a| a.counter_cache.is_some())
.collect();
if cached.is_empty() {
return Ok(TokenStream::new());
}
let id_fields: Vec<&syn::Ident> = all_fields
.iter()
.filter(|f| has_attr(f, "id"))
.filter_map(|f| f.ident.as_ref())
.collect();
if id_fields.len() > 1 {
let listed = id_fields
.iter()
.map(|i| format!("`{i}`"))
.collect::<Vec<_>>()
.join(", ");
let span = cached[0]
.counter_cache
.as_ref()
.map_or_else(proc_macro2::Span::call_site, |d| d.span);
return Err(syn::Error::new(
span,
format!(
"`counter_cache` requires a single primary key: model \
`{model_ident}` declares a composite key ({listed}), and the \
counter maintenance identifies its child row by one id — the \
decrement would key on every row sharing the first component"
),
));
}
let Some(pk_ident) = pk_ident else {
return Err(syn::Error::new_spanned(
model_ident,
"`counter_cache` requires the child model to have a primary-key \
field: the maintenance resolves the parent from the child row by \
primary key. Mark one with `#[id]`",
));
};
let pk_column = pk_ident.to_string();
let live_body = if has_deleted_at {
quote! { __autumn_cc_record.deleted_at.is_none() }
} else {
quote! { { let _ = __autumn_cc_record; true } }
};
let mut fk_fns: Vec<TokenStream> = vec![
quote! {
fn __autumn_counter_cache_pk(__autumn_cc_record: &#model_ident) -> i64 {
__autumn_cc_record.#pk_ident
}
},
quote! {
fn __autumn_counter_cache_live(__autumn_cc_record: &#model_ident) -> bool {
#live_body
}
},
];
let mut spec_entries: Vec<TokenStream> = Vec::new();
for (index, assoc) in cached.iter().enumerate() {
let decl = assoc
.counter_cache
.as_ref()
.expect("filtered to Some above");
let column = counter_cache_column(model_ident, assoc)
.expect("filtered to a counter-cached association above");
let tenant_column = decl.tenant_column.as_deref().map_or_else(
|| quote! { ::core::option::Option::None },
|tenant| quote! { ::core::option::Option::Some(#tenant) },
);
let parent_table = infer_table_name(&assoc.target);
let fk = &assoc.fk;
let fk_ident = format_ident!("{fk}");
let fk_field = all_fields
.iter()
.find(|f| f.ident.as_ref().is_some_and(|i| i == fk));
let Some(fk_field) = fk_field else {
return Err(syn::Error::new(
decl.span,
format!(
"`counter_cache` foreign key `{fk}` is not a field of model \
`{model_ident}`; add it, or name the right column with \
`#[belongs_to({}, fk = <column>, counter_cache)]`",
assoc.target
),
));
};
let fk_expr = if option_inner(&fk_field.ty).is_some() {
quote! { __autumn_cc_record.#fk_ident }
} else {
quote! { ::core::option::Option::Some(__autumn_cc_record.#fk_ident) }
};
let fk_fn = format_ident!("__autumn_counter_cache_fk_{index}");
fk_fns.push(quote! {
fn #fk_fn(__autumn_cc_record: &#model_ident) -> ::core::option::Option<i64> {
#fk_expr
}
});
spec_entries.push(quote! {
::autumn_web::repository::CounterCacheSpec {
child_table: #table_name,
child_pk: #pk_column,
child_soft_delete: #has_deleted_at,
fk_column: #fk,
parent_table: #parent_table,
parent_pk: "id",
counter_column: #column,
fk_of: #fk_fn,
pk_of: __autumn_counter_cache_pk,
live_of: __autumn_counter_cache_live,
tenant_column: #tenant_column,
}
});
}
Ok(quote! {
impl #model_ident {
#[doc(hidden)]
pub const HAS_COUNTER_CACHES: bool = true;
#[doc(hidden)]
#[must_use]
pub fn counter_caches()
-> &'static [::autumn_web::repository::CounterCacheSpec<#model_ident>]
{
#(#fk_fns)*
const __AUTUMN_COUNTER_CACHES:
&[::autumn_web::repository::CounterCacheSpec<#model_ident>] = &[
#(#spec_entries),*
];
__AUTUMN_COUNTER_CACHES
}
}
})
}
#[allow(clippy::too_many_lines)]
fn emit_association_items(
model_ident: &syn::Ident,
table_ident: &syn::Ident,
vis: &syn::Visibility,
assocs: &[Association],
) -> TokenStream {
let preload_spec_ident = format_ident!("{model_ident}Preload");
let assoc_trait_ident = format_ident!("{model_ident}Associations");
let model_str = model_ident.to_string();
let mut spec_fields: Vec<TokenStream> = Vec::new();
let mut spec_builders: Vec<TokenStream> = Vec::new();
let mut accessor_sigs: Vec<TokenStream> = Vec::new();
let mut accessor_impls: Vec<TokenStream> = Vec::new();
let mut loader_blocks: Vec<TokenStream> = Vec::new();
let mut m2m_items: Vec<TokenStream> = Vec::new();
for assoc in assocs {
let name_ident = format_ident!("{}", assoc.name);
let with_ident = format_ident!("{}_with", assoc.name);
let target = &assoc.target;
let target_table = format_ident!("{}", infer_table_name(target));
let fk_ident = format_ident!("{}", assoc.fk);
let key = &assoc.name;
let spec_ty = quote! {
::core::option::Option<
::std::boxed::Box<<#target as ::autumn_web::preload::Preloadable>::Spec>
>
};
spec_fields.push(quote! { #name_ident: #spec_ty });
spec_builders.push(quote! {
#[must_use]
#vis fn #name_ident(mut self) -> Self {
self.#name_ident = ::core::option::Option::Some(
::std::boxed::Box::new(::core::default::Default::default())
);
self
}
#[must_use]
#vis fn #with_ident(
mut self,
spec: <#target as ::autumn_web::preload::Preloadable>::Spec,
) -> Self {
self.#name_ident = ::core::option::Option::Some(::std::boxed::Box::new(spec));
self
}
});
match assoc.kind {
AssocKind::BelongsTo | AssocKind::HasOne => {
let stored_ty = quote! {
::core::option::Option<::std::sync::Arc<::autumn_web::preload::Preloaded<#target>>>
};
accessor_sigs.push(quote! {
fn #name_ident(&self) -> ::core::result::Result<
::core::option::Option<&::autumn_web::preload::Preloaded<#target>>,
::autumn_web::preload::NotLoaded,
>;
});
accessor_impls.push(quote! {
fn #name_ident(&self) -> ::core::result::Result<
::core::option::Option<&::autumn_web::preload::Preloaded<#target>>,
::autumn_web::preload::NotLoaded,
> {
match self.associations().get::<#stored_ty>(#key) {
::core::option::Option::Some(v) => ::core::result::Result::Ok(v.as_deref()),
::core::option::Option::None => ::core::result::Result::Err(
::autumn_web::preload::NotLoaded::new(#model_str, #key),
),
}
}
});
let (key_expr, filter_col) = match assoc.kind {
AssocKind::BelongsTo => {
(quote! { __r.#fk_ident }, quote! { #target_table::id })
}
_ => (quote! { __r.id }, quote! { #target_table::#fk_ident }),
};
let map_key_expr = if assoc.kind == AssocKind::BelongsTo {
quote! { __child.id }
} else {
quote! { __child.#fk_ident }
};
loader_blocks.push(quote! {
if let ::core::option::Option::Some(__child_spec) = &spec.#name_ident {
let mut __keys: ::std::vec::Vec<i64> =
records.iter().map(|__r| #key_expr).collect();
__keys.sort_unstable();
__keys.dedup();
let __rows: ::std::vec::Vec<#target> = #target_table::table
.filter(#filter_col.eq_any(__keys))
.select(<#target as ::autumn_web::reexports::diesel::SelectableHelper<::autumn_web::reexports::diesel::pg::Pg>>::as_select())
.load::<#target>(&mut *conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
let __rows = #target::__autumn_preload_retain(__rows)?;
let mut __children: ::std::vec::Vec<
::autumn_web::preload::Preloaded<#target>
> = __rows.into_iter().map(::autumn_web::preload::Preloaded::new).collect();
<#target as ::autumn_web::preload::Preloadable>::load_associations(
&mut __children, &**__child_spec, &mut *conn,
).await?;
let mut __map: ::std::collections::HashMap<
i64, ::std::sync::Arc<::autumn_web::preload::Preloaded<#target>>
> = __children
.into_iter()
.map(|__child| (#map_key_expr, ::std::sync::Arc::new(__child)))
.collect();
for __r in records.iter_mut() {
let __v: #stored_ty = __map.get(&(#key_expr)).map(::std::sync::Arc::clone);
__r.associations_mut().insert::<#stored_ty>(#key, __v);
}
}
});
}
AssocKind::HasMany if assoc.through.is_none() => {
let stored_ty = quote! {
::std::vec::Vec<::autumn_web::preload::Preloaded<#target>>
};
accessor_sigs.push(quote! {
fn #name_ident(&self) -> ::core::result::Result<
&[::autumn_web::preload::Preloaded<#target>],
::autumn_web::preload::NotLoaded,
>;
});
accessor_impls.push(quote! {
fn #name_ident(&self) -> ::core::result::Result<
&[::autumn_web::preload::Preloaded<#target>],
::autumn_web::preload::NotLoaded,
> {
match self.associations().get::<#stored_ty>(#key) {
::core::option::Option::Some(v) => ::core::result::Result::Ok(v.as_slice()),
::core::option::Option::None => ::core::result::Result::Err(
::autumn_web::preload::NotLoaded::new(#model_str, #key),
),
}
}
});
loader_blocks.push(quote! {
if let ::core::option::Option::Some(__child_spec) = &spec.#name_ident {
let mut __keys: ::std::vec::Vec<i64> =
records.iter().map(|__r| __r.id).collect();
__keys.sort_unstable();
__keys.dedup();
let __rows: ::std::vec::Vec<#target> = #target_table::table
.filter(#target_table::#fk_ident.eq_any(__keys))
.select(<#target as ::autumn_web::reexports::diesel::SelectableHelper<::autumn_web::reexports::diesel::pg::Pg>>::as_select())
.load::<#target>(&mut *conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
let __rows = #target::__autumn_preload_retain(__rows)?;
let mut __children: ::std::vec::Vec<
::autumn_web::preload::Preloaded<#target>
> = __rows.into_iter().map(::autumn_web::preload::Preloaded::new).collect();
<#target as ::autumn_web::preload::Preloadable>::load_associations(
&mut __children, &**__child_spec, &mut *conn,
).await?;
let mut __groups: ::std::collections::HashMap<i64, #stored_ty> =
::std::collections::HashMap::new();
for __child in __children {
if let ::core::option::Option::Some(__k) =
::autumn_web::preload::FkKey::autumn_fk_key(&__child.#fk_ident)
{
__groups.entry(__k).or_default().push(__child);
}
}
for __r in records.iter_mut() {
let __v: #stored_ty = __groups.remove(&__r.id).unwrap_or_default();
__r.associations_mut().insert::<#stored_ty>(#key, __v);
}
}
});
}
AssocKind::HasMany => {
let through = assoc
.through
.as_ref()
.expect("through checked by guard above");
let stored_ty = quote! {
::std::vec::Vec<::std::sync::Arc<::autumn_web::preload::Preloaded<#target>>>
};
accessor_sigs.push(quote! {
fn #name_ident(&self) -> ::core::result::Result<
::std::vec::Vec<&::autumn_web::preload::Preloaded<#target>>,
::autumn_web::preload::NotLoaded,
>;
});
accessor_impls.push(quote! {
fn #name_ident(&self) -> ::core::result::Result<
::std::vec::Vec<&::autumn_web::preload::Preloaded<#target>>,
::autumn_web::preload::NotLoaded,
> {
match self.associations().get::<#stored_ty>(#key) {
::core::option::Option::Some(v) => ::core::result::Result::Ok(
v.iter().map(|__a| &**__a).collect()
),
::core::option::Option::None => ::core::result::Result::Err(
::autumn_web::preload::NotLoaded::new(#model_str, #key),
),
}
}
});
{
let model_snake = pascal_to_snake(&model_ident.to_string());
let join_mod_ident = format_ident!(
"__autumn_m2m_{}_{model_snake}_{}",
model_snake.len(),
assoc.name
);
let join_table_ident = format_ident!("{}", through.table);
let target_fk_ident = format_ident!("{}", through.target_fk);
m2m_items.push(quote! {
#[allow(
missing_docs,
unreachable_pub,
clippy::all,
clippy::pedantic,
clippy::nursery
)]
mod #join_mod_ident {
#[allow(unused_imports)]
use super::*;
::autumn_web::reexports::diesel::table! {
#join_table_ident (#fk_ident, #target_fk_ident) {
#fk_ident -> Int8,
#target_fk_ident -> Int8,
}
}
::autumn_web::reexports::diesel::allow_tables_to_appear_in_same_query!(
#join_table_ident, #target_table
);
}
});
loader_blocks.push(quote! {
if let ::core::option::Option::Some(__child_spec) = &spec.#name_ident {
#[allow(unused_imports)]
use ::autumn_web::reexports::diesel::query_dsl::JoinOnDsl as _;
let mut __keys: ::std::vec::Vec<i64> =
records.iter().map(|__r| __r.id).collect();
__keys.sort_unstable();
__keys.dedup();
let __pairs: ::std::vec::Vec<(i64, #target)> =
#join_mod_ident::#join_table_ident::table
.inner_join(
#target_table::table.on(
#target_table::id.eq(
#join_mod_ident::#join_table_ident::#target_fk_ident
)
)
)
.filter(
#join_mod_ident::#join_table_ident::#fk_ident.eq_any(__keys)
)
.select((
#join_mod_ident::#join_table_ident::#fk_ident,
<#target as ::autumn_web::reexports::diesel::SelectableHelper<::autumn_web::reexports::diesel::pg::Pg>>::as_select(),
))
.load::<(i64, #target)>(&mut *conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
let _ = #target::__autumn_preload_retain(::std::vec::Vec::new())?;
let mut __unique_by_id: ::std::collections::HashMap<i64, #target> =
::std::collections::HashMap::new();
let mut __links: ::std::vec::Vec<(i64, i64)> = ::std::vec::Vec::new();
for (__fk, __row) in __pairs {
if let ::core::option::Option::Some(__row) =
#target::__autumn_preload_keep(__row)?
{
let __id = __row.id;
__links.push((__fk, __id));
__unique_by_id.entry(__id).or_insert(__row);
}
}
let mut __unique_children: ::std::vec::Vec<
::autumn_web::preload::Preloaded<#target>
> = __unique_by_id
.into_values()
.map(::autumn_web::preload::Preloaded::new)
.collect();
<#target as ::autumn_web::preload::Preloadable>::load_associations(
&mut __unique_children, &**__child_spec, &mut *conn,
).await?;
let __arc_by_id: ::std::collections::HashMap<
i64, ::std::sync::Arc<::autumn_web::preload::Preloaded<#target>>
> = __unique_children
.into_iter()
.map(|__c| (__c.id, ::std::sync::Arc::new(__c)))
.collect();
let mut __groups: ::std::collections::HashMap<i64, #stored_ty> =
::std::collections::HashMap::new();
for (__fk, __id) in &__links {
if let ::core::option::Option::Some(__arc) = __arc_by_id.get(__id) {
__groups.entry(*__fk).or_default().push(::std::sync::Arc::clone(__arc));
}
}
for __r in records.iter_mut() {
let __v: #stored_ty = __groups.get(&__r.id).cloned().unwrap_or_default();
__r.associations_mut().insert::<#stored_ty>(#key, __v);
}
}
});
let mutation_trait_ident =
format_ident!("{model_ident}{}Mutations", pascal_case(&assoc.name));
let singular = resolved_m2m_singular(assoc);
let add_ident = format_ident!("add_{singular}");
let remove_ident = format_ident!("remove_{singular}");
let set_ident = format_ident!("set_{}", assoc.name);
let mutation_trait_doc = format!(
"Mutation helpers for the `{}` many-to-many association \
(`#[has_many({}, through = {})]`). Each method acquires \
its own primary-pool connection and is idempotent; \
`{set_ident}` wraps its delete-then-insert in a single \
transaction.",
assoc.name, target, through.table,
);
m2m_items.push(quote! {
#[doc = #mutation_trait_doc]
#vis trait #mutation_trait_ident {
fn #add_ident(
&self,
parent_id: i64,
child_id: i64,
) -> impl ::std::future::Future<Output = ::autumn_web::AutumnResult<()>> + Send;
fn #remove_ident(
&self,
parent_id: i64,
child_id: i64,
) -> impl ::std::future::Future<Output = ::autumn_web::AutumnResult<()>> + Send;
fn #set_ident(
&self,
parent_id: i64,
child_ids: &[i64],
) -> impl ::std::future::Future<Output = ::autumn_web::AutumnResult<()>> + Send;
}
impl<__R> #mutation_trait_ident for __R
where
__R: ::autumn_web::repository::M2mConnSource<Model = #model_ident>
+ ::core::marker::Sync,
{
async fn #add_ident(
&self,
parent_id: i64,
child_id: i64,
) -> ::autumn_web::AutumnResult<()> {
use ::autumn_web::reexports::diesel::{ExpressionMethods as _, QueryDsl as _};
use ::autumn_web::reexports::diesel_async::RunQueryDsl as _;
let mut conn = self.__autumn_m2m_write_conn().await?;
::autumn_web::reexports::diesel::insert_into(
#join_mod_ident::#join_table_ident::table
)
.values((
#join_mod_ident::#join_table_ident::#fk_ident.eq(parent_id),
#join_mod_ident::#join_table_ident::#target_fk_ident.eq(child_id),
))
.on_conflict((
#join_mod_ident::#join_table_ident::#fk_ident,
#join_mod_ident::#join_table_ident::#target_fk_ident,
))
.do_nothing()
.execute(&mut conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
::core::result::Result::Ok(())
}
async fn #remove_ident(
&self,
parent_id: i64,
child_id: i64,
) -> ::autumn_web::AutumnResult<()> {
use ::autumn_web::reexports::diesel::{ExpressionMethods as _, QueryDsl as _};
use ::autumn_web::reexports::diesel_async::RunQueryDsl as _;
let mut conn = self.__autumn_m2m_write_conn().await?;
::autumn_web::reexports::diesel::delete(
#join_mod_ident::#join_table_ident::table
.filter(
#join_mod_ident::#join_table_ident::#fk_ident.eq(parent_id)
)
.filter(
#join_mod_ident::#join_table_ident::#target_fk_ident.eq(child_id)
),
)
.execute(&mut conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
::core::result::Result::Ok(())
}
async fn #set_ident(
&self,
parent_id: i64,
child_ids: &[i64],
) -> ::autumn_web::AutumnResult<()> {
use ::autumn_web::reexports::diesel::{ExpressionMethods as _, QueryDsl as _};
use ::autumn_web::reexports::diesel_async::RunQueryDsl as _;
use ::autumn_web::reexports::diesel_async::AsyncConnection as _;
use ::autumn_web::reexports::scoped_futures::ScopedFutureExt as _;
let mut __ids: ::std::vec::Vec<i64> = child_ids.to_vec();
__ids.sort_unstable();
__ids.dedup();
let mut conn = self.__autumn_m2m_write_conn().await?;
::autumn_web::__private::scoped_transaction::<(), ::autumn_web::AutumnError, _, _>(&mut *conn, |conn| {
async move {
::autumn_web::reexports::diesel::delete(
#join_mod_ident::#join_table_ident::table.filter(
#join_mod_ident::#join_table_ident::#fk_ident
.eq(parent_id)
),
)
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
if !__ids.is_empty() {
let __values: ::std::vec::Vec<_> = __ids
.iter()
.map(|__child_id| (
#join_mod_ident::#join_table_ident::#fk_ident
.eq(parent_id),
#join_mod_ident::#join_table_ident::#target_fk_ident
.eq(*__child_id),
))
.collect();
::autumn_web::reexports::diesel::insert_into(
#join_mod_ident::#join_table_ident::table
)
.values(__values)
.on_conflict((
#join_mod_ident::#join_table_ident::#fk_ident,
#join_mod_ident::#join_table_ident::#target_fk_ident,
))
.do_nothing()
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
}
::core::result::Result::Ok(())
}
.scope_boxed()
})
.await
}
}
});
}
}
}
}
quote! {
#[derive(::core::default::Default)]
#vis struct #preload_spec_ident {
#(#spec_fields,)*
}
impl #preload_spec_ident {
#[must_use]
#vis fn new() -> Self {
::core::default::Default::default()
}
#(#spec_builders)*
}
impl #model_ident {
#[must_use]
#vis fn preload() -> #preload_spec_ident {
#preload_spec_ident::new()
}
}
#vis trait #assoc_trait_ident {
#(#accessor_sigs)*
}
impl #assoc_trait_ident for ::autumn_web::preload::Preloaded<#model_ident> {
#(#accessor_impls)*
}
impl ::autumn_web::preload::Preloadable for #model_ident {
type Spec = #preload_spec_ident;
fn load_associations<'__a>(
records: &'__a mut [::autumn_web::preload::Preloaded<Self>],
spec: &'__a Self::Spec,
conn: &'__a mut ::autumn_web::RuntimeConnection,
) -> ::autumn_web::preload::PreloadFuture<'__a> {
::std::boxed::Box::pin(async move {
#[allow(unused_imports)]
use ::autumn_web::reexports::diesel::{
QueryDsl as _, ExpressionMethods as _,
};
#[allow(unused_imports)]
use ::autumn_web::reexports::diesel_async::RunQueryDsl as _;
if records.is_empty() {
return ::core::result::Result::Ok(());
}
let _ = (&records, &spec, &conn, #table_ident::table);
#(#loader_blocks)*
::core::result::Result::Ok(())
})
}
}
#(#m2m_items)*
}
}
#[allow(clippy::too_many_lines)]
fn emit_votable_items(
model_ident: &syn::Ident,
table_ident: &syn::Ident,
vis: &syn::Visibility,
spec: &VotableSpec,
has_deleted_at: bool,
has_tenant_id: bool,
pk_ident: Option<&syn::Ident>,
) -> TokenStream {
let model_snake = pascal_to_snake(&model_ident.to_string());
let edge_mod = format_ident!(
"__autumn_votable_{}_{model_snake}_{}",
model_snake.len(),
spec.name
);
let edge_table = format_ident!("{}", spec.table);
let reactor_fk = format_ident!("{}", spec.reactor_fk);
let target_fk = format_ident!("{}", spec.target_fk);
let agg_column = format_ident!("{}", spec.column);
let pk_column = pk_ident.map_or_else(|| format_ident!("id"), ::std::clone::Clone::clone);
let trait_ident = format_ident!("{model_ident}Reactions");
let is_sum = spec.aggregate == VoteAggregate::Sum;
let pk_guard = pk_ident.map(|pk| {
quote! {
const _: fn(&#model_ident) -> i64 = |__autumn_votable_model| {
__autumn_votable_model.#pk
};
}
});
let agg_ty_guard = quote! {
const _: fn(&#model_ident) -> i64 = |__autumn_votable_model| {
__autumn_votable_model.#agg_column
};
};
let reactor_ident = &spec.reactor;
let reactor_guard = quote! {
const _: ::core::marker::PhantomData<#reactor_ident> =
::core::marker::PhantomData;
};
let value_column_decl = spec.value_column.as_ref().map(|value_column| {
let value_ident = format_ident!("{value_column}");
quote! { #value_ident -> Int2, }
});
let deleted_at_decl = has_deleted_at.then(|| {
quote! { deleted_at -> Nullable<Timestamp>, }
});
let tenant_id_decl = has_tenant_id.then(|| {
quote! { tenant_id -> Text, }
});
let hidden_module = quote! {
#[allow(
missing_docs,
unreachable_pub,
clippy::all,
clippy::pedantic,
clippy::nursery
)]
mod #edge_mod {
::autumn_web::reexports::diesel::table! {
#edge_table (#reactor_fk, #target_fk) {
#reactor_fk -> Int8,
#target_fk -> Int8,
#value_column_decl
}
}
::autumn_web::reexports::diesel::table! {
#table_ident (#pk_column) {
#pk_column -> Int8,
#agg_column -> Int8,
#tenant_id_decl
#deleted_at_decl
}
}
::autumn_web::reexports::diesel::allow_tables_to_appear_in_same_query!(
#edge_table,
#table_ident,
);
}
};
let live_filter = has_deleted_at.then(|| {
quote! { .filter(#edge_mod::#table_ident::deleted_at.is_null()) }
});
let edge_of_reactor = quote! {
#edge_mod::#edge_table::table
.filter(#edge_mod::#edge_table::#reactor_fk.eq(reactor_id))
.filter(#edge_mod::#edge_table::#target_fk.eq(target_id))
};
let not_found_msg = format!("{model_ident} not found");
let resolve_tenant = if has_tenant_id {
quote! {
let __tenant: ::core::option::Option<::std::string::String> =
self.__autumn_m2m_tenant_scope()?;
}
} else {
quote! {
let _: ::core::option::Option<::std::string::String> =
self.__autumn_m2m_tenant_scope()?;
}
};
let tenant_filter = quote! {
.filter(#edge_mod::#table_ident::tenant_id.eq(__t))
};
let target_probe = |scoped: bool, lock: bool| {
let tenant_predicate = scoped.then(|| tenant_filter.clone());
let lock_clause = lock.then(|| quote! { .for_no_key_update() });
quote! {
#edge_mod::#table_ident::table
.filter(#edge_mod::#table_ident::#pk_column.eq(target_id))
#tenant_predicate
#live_filter
.select(#edge_mod::#table_ident::#pk_column)
#lock_clause
.first::<i64>(conn)
.await
.optional()
.map_err(::autumn_web::AutumnError::from)?
}
};
let s1_arm = |lock: bool| {
let unscoped = target_probe(false, lock);
if has_tenant_id {
let scoped = target_probe(true, lock);
quote! {
match __tenant {
::core::option::Option::Some(ref __t) => { #scoped }
::core::option::Option::None => { #unscoped }
}
}
} else {
unscoped
}
};
let s1_pg = s1_arm(true);
let s1_sqlite = s1_arm(false);
let aggregate_update = |scoped: bool| {
let tenant_predicate = scoped.then(|| tenant_filter.clone());
quote! {
::autumn_web::reexports::diesel::update(
#edge_mod::#table_ident::table
.filter(#edge_mod::#table_ident::#pk_column.eq(target_id))
#tenant_predicate
#live_filter
)
.set(#edge_mod::#table_ident::#agg_column.eq(__aggregate))
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?
}
};
let s5_persist = if has_tenant_id {
let scoped = aggregate_update(true);
let unscoped = aggregate_update(false);
quote! {
let __persisted: usize = match __tenant {
::core::option::Option::Some(ref __t) => { #scoped }
::core::option::Option::None => { #unscoped }
};
}
} else {
let unscoped = aggregate_update(false);
quote! { let __persisted: usize = #unscoped; }
};
let reaction_of_tenant_exists = quote! {
.filter(::autumn_web::reexports::diesel::dsl::exists(
#edge_mod::#table_ident::table
.filter(#edge_mod::#table_ident::#pk_column.eq(target_id))
.filter(#edge_mod::#table_ident::tenant_id.eq(__t))
))
};
let (react_value_param, read_current, branch, aggregate_query) = if is_sum {
let value_ident = format_ident!(
"{}",
spec.value_column
.as_ref()
.expect("sum mode always resolves a value column")
);
(
Some(quote! { value: i16, }),
quote! {
let __current: ::core::option::Option<i16> = #edge_of_reactor
.select(#edge_mod::#edge_table::#value_ident)
.first::<i16>(conn)
.await
.optional()
.map_err(::autumn_web::AutumnError::from)?;
},
quote! {
let (__new_value, __outcome) = match __current {
::core::option::Option::Some(__existing) if __existing == value => {
::autumn_web::reexports::diesel::delete(#edge_of_reactor)
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
(
::core::option::Option::None,
::autumn_web::repository::ReactionOutcome::Removed,
)
}
::core::option::Option::Some(_) => {
::autumn_web::reexports::diesel::update(#edge_of_reactor)
.set(#edge_mod::#edge_table::#value_ident.eq(value))
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
(
::core::option::Option::Some(value),
::autumn_web::repository::ReactionOutcome::Flipped,
)
}
::core::option::Option::None => {
::autumn_web::reexports::diesel::insert_into(
#edge_mod::#edge_table::table
)
.values((
#edge_mod::#edge_table::#reactor_fk.eq(reactor_id),
#edge_mod::#edge_table::#target_fk.eq(target_id),
#edge_mod::#edge_table::#value_ident.eq(value),
))
.on_conflict((
#edge_mod::#edge_table::#reactor_fk,
#edge_mod::#edge_table::#target_fk,
))
.do_update()
.set(
#edge_mod::#edge_table::#value_ident.eq(
::autumn_web::reexports::diesel::upsert::excluded(
#edge_mod::#edge_table::#value_ident
)
)
)
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
(
::core::option::Option::Some(value),
::autumn_web::repository::ReactionOutcome::Inserted,
)
}
};
},
quote! {
let __total: ::core::option::Option<i64> = #edge_mod::#edge_table::table
.filter(#edge_mod::#edge_table::#target_fk.eq(target_id))
.select(::autumn_web::reexports::diesel::dsl::sum(
#edge_mod::#edge_table::#value_ident
))
.get_result::<::core::option::Option<i64>>(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
let __aggregate: i64 = __total.unwrap_or(0);
},
)
} else {
(
None,
quote! {
let __current: ::core::option::Option<i64> = #edge_of_reactor
.select(#edge_mod::#edge_table::#target_fk)
.first::<i64>(conn)
.await
.optional()
.map_err(::autumn_web::AutumnError::from)?;
},
quote! {
let (__new_value, __outcome) = match __current {
::core::option::Option::Some(_) => {
::autumn_web::reexports::diesel::delete(#edge_of_reactor)
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
(
::core::option::Option::None,
::autumn_web::repository::ReactionOutcome::Removed,
)
}
::core::option::Option::None => {
::autumn_web::reexports::diesel::insert_into(
#edge_mod::#edge_table::table
)
.values((
#edge_mod::#edge_table::#reactor_fk.eq(reactor_id),
#edge_mod::#edge_table::#target_fk.eq(target_id),
))
.on_conflict((
#edge_mod::#edge_table::#reactor_fk,
#edge_mod::#edge_table::#target_fk,
))
.do_nothing()
.execute(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
(
::core::option::Option::Some(1i16),
::autumn_web::repository::ReactionOutcome::Inserted,
)
}
};
},
quote! {
let __aggregate: i64 = #edge_mod::#edge_table::table
.filter(#edge_mod::#edge_table::#target_fk.eq(target_id))
.count()
.get_result::<i64>(conn)
.await
.map_err(::autumn_web::AutumnError::from)?;
},
)
};
let reaction_of_lookup = |scoped: bool| {
let tenant_predicate = scoped.then(|| reaction_of_tenant_exists.clone());
if is_sum {
let value_ident = format_ident!(
"{}",
spec.value_column
.as_ref()
.expect("sum mode always resolves a value column")
);
quote! {
#edge_of_reactor
#tenant_predicate
.select(#edge_mod::#edge_table::#value_ident)
.first::<i16>(&mut conn)
.await
.optional()
.map_err(::autumn_web::AutumnError::from)
}
} else {
quote! {
{
let __row: ::core::option::Option<i64> = #edge_of_reactor
#tenant_predicate
.select(#edge_mod::#edge_table::#target_fk)
.first::<i64>(&mut conn)
.await
.optional()
.map_err(::autumn_web::AutumnError::from)?;
::core::result::Result::Ok(__row.map(|_| 1i16))
}
}
}
};
let reaction_of_body = if has_tenant_id {
let scoped = reaction_of_lookup(true);
let unscoped = reaction_of_lookup(false);
quote! {
match __tenant {
::core::option::Option::Some(ref __t) => #scoped,
::core::option::Option::None => #unscoped,
}
}
} else {
reaction_of_lookup(false)
};
let aggregate_word = if is_sum { "SUM(value)" } else { "COUNT(*)" };
let (react_tenant_doc, react_tenant_not_found, react_tenant_error) = if has_tenant_id {
(
"This model has a `tenant_id` column, so a `tenant_scoped` \
repository matches the target on it too: another tenant's \
`target_id` is `NotFound` before any write. `across_tenants()` \
opts out; a `tenant_scoped` repository with no tenant context is \
an error.\n\n",
", or belongs to another tenant",
"- An error when this repository is `tenant_scoped` and no tenant \
context was established.\n",
)
} else {
("", "", "")
};
let cross_shard_error = "- `AutumnError::bad_request` when this repository is sharded and in \
`across_tenants()` mode: there is no single right shard for a \
reaction, so cross-shard reactions are rejected.\n";
let (reaction_of_tenant_doc, reaction_of_tenant_error) = if has_tenant_id {
(
"Tenant-isolated on the same terms as `react()`: through a \
`tenant_scoped` repository, a target belonging to another tenant \
reports `None` rather than that tenant's reaction.\n\n",
"- An error when this repository is `tenant_scoped` and no tenant \
context was established.\n",
)
} else {
("", "")
};
let trait_doc = format!(
"Reaction helpers for `{model_ident}`'s `#[votable(by = {}, aggregate \
= {})]` declaration: the `{}` edge table keyed on `({}, {})`, \
aggregated into `{}.{}`.",
spec.reactor,
if is_sum { "sum" } else { "count" },
spec.table,
spec.reactor_fk,
spec.target_fk,
table_ident,
spec.column,
);
let react_doc = format!(
"Toggle / flip / insert this reactor's reaction on `target_id`, and \
recompute `{}.{}` from ground truth in the **same** transaction, so a \
reader never observes edge/aggregate disagreement.\n\
\n\
Race-safe: the target row is locked for the whole \
read-decide-write-recompute window, so N concurrent calls converge to \
at most one edge per `({}, {})` and the persisted aggregate always \
equals `{aggregate_word}`.\n\
\n\
**Not idempotent — it is a toggle.** Calling it twice with the same \
arguments reacts and then un-reacts. In particular, blindly retrying \
a call that timed out (or whose response was lost) can *invert* the \
outcome: the first attempt may well have committed. Callers that need \
retry safety must dedupe above this layer — an idempotency key on the \
HTTP request, or re-reading `reaction_of()` before retrying.\n\
\n\
Runs on its **own** pooled connection — it does not join an enclosing \
`Db::tx`. Do not hold a `Db` extractor across this call on a small \
pool.\n\
\n\
`value` is **not** validated: it is written to the edge table as \
given, so the edge table's `CHECK` constraint is what keeps the sum \
meaningful. Never bind it straight from a request body.\n\
\n\
{react_tenant_doc}\
# Errors\n\
\n\
- `AutumnError::not_found` when `target_id` does not exist (or is \
soft-deleted{react_tenant_not_found}).\n\
{react_tenant_error}\
{cross_shard_error}\
- Any database error from the enclosing transaction.",
table_ident, spec.column, spec.reactor_fk, spec.target_fk,
);
let reaction_of_doc = format!(
"This reactor's current reaction on `target_id`, or `None`.\n\
\n\
A single indexed lookup on `{}`; safe to call per-row on a detail \
page. For feed pages prefer rendering un-highlighted controls over an \
N+1 — a batch accessor is tracked as a follow-up.\n\
\n\
A **read**: it routes per this repository's read route, so a \
configured replica serves it, and it does not pin read-your-writes. \
It can therefore lag a `react()` this request just committed — render \
from the `Reaction` that `react()` returned instead of re-reading, or \
use a `primary_reads` / `on_primary()` repository.\n\
\n\
{reaction_of_tenant_doc}\
# Errors\n\
\n\
{reaction_of_tenant_error}\
{cross_shard_error}\
- Any database error.",
spec.table,
);
quote! {
#hidden_module
#pk_guard
#agg_ty_guard
#reactor_guard
#[doc = #trait_doc]
#vis trait #trait_ident {
#[doc = #react_doc]
fn react(
&self,
reactor_id: i64,
target_id: i64,
#react_value_param
) -> impl ::std::future::Future<
Output = ::autumn_web::AutumnResult<::autumn_web::repository::Reaction>
> + Send;
#[doc = #reaction_of_doc]
fn reaction_of(
&self,
reactor_id: i64,
target_id: i64,
) -> impl ::std::future::Future<
Output = ::autumn_web::AutumnResult<::core::option::Option<i16>>
> + Send;
}
impl<__R> #trait_ident for __R
where
__R: ::autumn_web::repository::M2mConnSource<Model = #model_ident>
+ ::core::marker::Sync,
{
async fn react(
&self,
reactor_id: i64,
target_id: i64,
#react_value_param
) -> ::autumn_web::AutumnResult<::autumn_web::repository::Reaction> {
use ::autumn_web::reexports::diesel::result::OptionalExtension as _;
use ::autumn_web::reexports::diesel::{ExpressionMethods as _, QueryDsl as _};
use ::autumn_web::reexports::diesel_async::RunQueryDsl as _;
use ::autumn_web::reexports::scoped_futures::ScopedFutureExt as _;
#resolve_tenant
let mut conn = self.__autumn_m2m_write_conn().await?;
::autumn_web::__private::scoped_immediate_transaction::<
::autumn_web::repository::Reaction,
::autumn_web::AutumnError,
_,
>(&mut *conn, |conn| {
async move {
let __target: ::core::option::Option<i64> =
::autumn_web::backend_select! {
pg => { #s1_pg },
sqlite => { #s1_sqlite },
};
if __target.is_none() {
return ::core::result::Result::Err(
::autumn_web::AutumnError::not_found_msg(#not_found_msg)
);
}
#read_current
#branch
#aggregate_query
#s5_persist
if __persisted == 0 {
return ::core::result::Result::Err(
::autumn_web::AutumnError::not_found_msg(#not_found_msg)
);
}
::core::result::Result::Ok(
::autumn_web::repository::Reaction::__new(
__new_value,
__aggregate,
__outcome,
)
)
}
.scope_boxed()
})
.await
}
async fn reaction_of(
&self,
reactor_id: i64,
target_id: i64,
) -> ::autumn_web::AutumnResult<::core::option::Option<i16>> {
use ::autumn_web::reexports::diesel::result::OptionalExtension as _;
use ::autumn_web::reexports::diesel::{ExpressionMethods as _, QueryDsl as _};
use ::autumn_web::reexports::diesel_async::RunQueryDsl as _;
#resolve_tenant
let mut conn = self.__autumn_m2m_read_conn().await?;
#reaction_of_body
}
}
}
}
fn validate_attrs(field: &Field) -> Vec<&syn::Attribute> {
field
.attrs
.iter()
.filter(|a| a.path().is_ident("validate"))
.collect()
}
const NON_PATCH_VALIDATORS: &[&str] = &[
"custom",
"must_match",
"nested",
"credit_card",
"non_control_character",
"does_not_contain",
];
const OPTION_INCOMPATIBLE_VALIDATORS: &[&str] = &["ip"];
fn validate_attrs_for_patch(field: &Field) -> Vec<syn::Attribute> {
let field_is_option = is_option_type(&field.ty);
let mut out = Vec::new();
for attr in field.attrs.iter().filter(|a| a.path().is_ident("validate")) {
let syn::Meta::List(list) = &attr.meta else {
out.push(attr.clone());
continue;
};
let parser = syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated;
let Ok(nested) = parser.parse2(list.tokens.clone()) else {
out.push(attr.clone());
continue;
};
let kept: Vec<syn::Meta> = nested
.into_iter()
.filter(|m| {
let Some(id) = m.path().get_ident() else {
return true;
};
if NON_PATCH_VALIDATORS.iter().any(|v| id == *v) {
return false;
}
if field_is_option && OPTION_INCOMPATIBLE_VALIDATORS.iter().any(|v| id == *v) {
return false;
}
true
})
.collect();
if kept.is_empty() {
continue;
}
out.push(syn::parse_quote!(#[validate(#(#kept),*)]));
}
out
}
fn user_attrs(field: &Field) -> Vec<&syn::Attribute> {
field
.attrs
.iter()
.filter(|a| {
!a.path().is_ident("id")
&& !a.path().is_ident("indexed")
&& !a.path().is_ident("validate")
&& !a.path().is_ident("default")
&& !a.path().is_ident("factory_assoc")
&& !a.path().is_ident("lock_version")
&& !a.path().is_ident("searchable")
&& !a.path().is_ident("encrypted")
&& !a.path().is_ident("private")
&& !a.path().is_ident("normalize")
&& !a.path().is_ident("state_machine")
&& !a.path().is_ident("unique")
&& !a.path().is_ident("references")
&& !a.path().is_ident("position")
&& !a.path().is_ident("translatable")
})
.collect()
}
fn field_is_private(field: &Field) -> bool {
has_attr(field, "private")
}
fn field_hidden_from_json(field: &Field) -> bool {
if field_is_private(field) {
return true;
}
let enc = parse_field_encrypted(field).unwrap_or(EncryptedSpec::NONE);
enc.is_encrypted() && !enc.admin_visible
}
fn field_already_skips_serialization(field: &Field) -> bool {
let mut skips = false;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("serde")) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("skip") || meta.path.is_ident("skip_serializing") {
skips = true;
}
if let Ok(value) = meta.value() {
let _: syn::Result<syn::Lit> = value.parse();
} else if meta.input.peek(syn::token::Paren) {
let _ = meta.parse_nested_meta(|_| Ok(()));
}
Ok(())
});
}
skips
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EncryptedMode {
None,
Randomized,
Deterministic,
}
#[derive(Clone, Copy)]
struct EncryptedSpec {
mode: EncryptedMode,
admin_visible: bool,
versioned_ciphertext: bool,
}
impl EncryptedSpec {
const NONE: Self = Self {
mode: EncryptedMode::None,
admin_visible: false,
versioned_ciphertext: false,
};
fn is_encrypted(self) -> bool {
self.mode != EncryptedMode::None
}
}
fn parse_field_encrypted(field: &syn::Field) -> syn::Result<EncryptedSpec> {
for attr in &field.attrs {
if !attr.path().is_ident("encrypted") {
continue;
}
if matches!(attr.meta, syn::Meta::Path(_)) {
return Ok(EncryptedSpec {
mode: EncryptedMode::Randomized,
..EncryptedSpec::NONE
});
}
let mut spec = EncryptedSpec {
mode: EncryptedMode::Randomized,
..EncryptedSpec::NONE
};
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("deterministic") {
spec.mode = EncryptedMode::Deterministic;
Ok(())
} else if meta.path.is_ident("randomized") {
spec.mode = EncryptedMode::Randomized;
Ok(())
} else if meta.path.is_ident("admin_visible") {
spec.admin_visible = true;
Ok(())
} else if meta.path.is_ident("versioned_ciphertext") {
spec.versioned_ciphertext = true;
Ok(())
} else {
Err(meta.error(
"unsupported `#[encrypted]` option; expected one of \
`deterministic`, `randomized`, `admin_visible`, `versioned_ciphertext`",
))
}
})?;
return Ok(spec);
}
Ok(EncryptedSpec::NONE)
}
fn parse_field_encrypted_mode(field: &syn::Field) -> syn::Result<EncryptedMode> {
Ok(parse_field_encrypted(field)?.mode)
}
fn redacting_debug_impl(
struct_name: &syn::Ident,
field_idents: &[&syn::Ident],
encrypted_names: &[&str],
) -> TokenStream {
let stmts = field_idents.iter().map(|ident| {
let nm = ident.to_string();
if encrypted_names.contains(&nm.as_str()) {
quote! {
if ::autumn_web::encryption::debug_plaintext_enabled() {
s.field(#nm, &self.#ident);
} else {
s.field(#nm, &::core::format_args!("<encrypted>"));
}
}
} else {
quote! { s.field(#nm, &self.#ident); }
}
});
quote! {
impl ::core::fmt::Debug for #struct_name {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
let mut s = f.debug_struct(stringify!(#struct_name));
#(#stmts)*
s.finish()
}
}
}
}
fn encrypted_wrapper_path(mode: EncryptedMode) -> Option<TokenStream> {
match mode {
EncryptedMode::None => None,
EncryptedMode::Randomized => Some(quote! { ::autumn_web::encryption::RandomizedText }),
EncryptedMode::Deterministic => {
Some(quote! { ::autumn_web::encryption::DeterministicText })
}
}
}
fn validate_encrypted_field(field: &syn::Field) -> syn::Result<()> {
if !parse_field_encrypted(field)?.is_encrypted() {
return Ok(());
}
let is_string = matches!(&field.ty, syn::Type::Path(p) if p.path.segments.last().is_some_and(|s| s.ident == "String"));
if !is_string {
return Err(syn::Error::new_spanned(
&field.ty,
"`#[encrypted]` is only supported on non-null `String` fields in v1 \
(encrypt before storing structured/optional data)",
));
}
if has_attr(field, "default")
|| has_attr(field, "lock_version")
|| has_attr(field, "id")
|| has_attr(field, "position")
{
return Err(syn::Error::new_spanned(
field,
"`#[encrypted]` cannot be combined with `#[default]`, `#[lock_version]`, \
or `#[id]`: those fields bypass the insert path, so the column would \
store an unencrypted value. Set the encrypted value explicitly on insert.",
));
}
if has_attr(field, "searchable") {
return Err(syn::Error::new_spanned(
field,
"`#[encrypted]` cannot be combined with `#[searchable]`: full-text search \
indexes the stored column, which holds ciphertext, so plaintext searches \
would never match. Remove `#[searchable]` from the encrypted field (keep a \
separate non-encrypted column if you need to search).",
));
}
if field_has_serde_rename(field) {
return Err(syn::Error::new_spanned(
field,
"`#[encrypted]` fields cannot use `#[serde(rename = ...)]` in v1: the \
column is registered under its Rust name, which must match the \
serialized key used by version history / log scrubbing / admin redaction.",
));
}
Ok(())
}
fn field_is_translatable(field: &syn::Field) -> bool {
has_attr(field, "translatable")
}
fn validate_translatable_field(field: &syn::Field) -> syn::Result<()> {
if !field_is_translatable(field) {
return Ok(());
}
let is_translated = matches!(
&field.ty,
syn::Type::Path(p) if p.path.segments.last().is_some_and(|s| s.ident == "Translated")
&& p.path.segments.last().is_some_and(|s| s.arguments.is_empty())
);
if !is_translated {
return Err(syn::Error::new_spanned(
&field.ty,
"`#[translatable]` requires the field type `autumn_web::i18n::Translated` \
(a per-locale container), not a plain string. Change the field to \
`pub <name>: autumn_web::i18n::Translated`; it renders the active \
locale through `Display` and keeps every other locale intact on write. \
The check is syntactic — a type alias for `Translated` is not \
recognised, and conversely any type whose last path segment is \
`Translated` is accepted, so spell the real type here.",
));
}
for (marker, why) in [
(
"encrypted",
"an encrypted column stores one opaque ciphertext envelope, which has no \
per-locale structure to resolve",
),
(
"searchable",
"full-text search indexes the stored column, which for a translatable field \
is a JSON container — the index would match locale tags and JSON \
punctuation, not the prose",
),
(
"normalize",
"normalizers rewrite a single string; they cannot see inside the per-locale \
container",
),
(
"unique",
"uniqueness would compare whole JSON containers, so two records translated \
into different locale sets would never collide even with identical text",
),
(
"indexed",
"an equality index over a JSON container matches whole containers, never a \
single locale's value",
),
("id", "a primary key must be a single scalar value"),
(
"lock_version",
"the optimistic-lock column is framework-managed and must stay a plain integer",
),
(
"position",
"the position column is framework-managed and must stay a plain integer",
),
(
"state_machine",
"a state column must hold one state name, not a per-locale container",
),
] {
if has_attr(field, marker) {
return Err(syn::Error::new_spanned(
field,
format!(
"`#[translatable]` cannot be combined with `#[{marker}]`: {why}. \
Keep a separate non-translatable column for that."
),
));
}
}
if field_has_serde_rename(field) {
return Err(syn::Error::new_spanned(
field,
"`#[translatable]` fields cannot use `#[serde(rename = ...)]`: the column is \
registered under its Rust name, which must match the field name passed to \
`available_locales(..)` / `is_translated(..)`.",
));
}
if field_has_diesel_column_name(field) {
return Err(syn::Error::new_spanned(
field,
"`#[translatable]` fields cannot use `#[diesel(column_name = ...)]`: the column \
is registered for framework surfaces under its Rust name, so a renamed database \
column would be advertised under a name that does not exist on the table. Name \
the Rust field after the column instead.",
));
}
Ok(())
}
fn unraw_ident(ident: &syn::Ident) -> String {
let raw = ident.to_string();
raw.strip_prefix("r#").unwrap_or(&raw).to_owned()
}
fn field_has_diesel_column_name(field: &syn::Field) -> bool {
let mut found = false;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("diesel")) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("column_name") {
found = true;
}
let _ = meta
.value()
.and_then(syn::parse::ParseBuffer::parse::<syn::Expr>);
Ok(())
});
}
found
}
fn emit_translatable_items(model: &syn::Ident, fields: &[&syn::Ident]) -> TokenStream {
if fields.is_empty() {
return quote! {};
}
let names: Vec<String> = fields.iter().map(|f| unraw_ident(f)).collect();
let per_field = fields.iter().map(|ident| {
let name = ident.to_string();
let localized = format_ident!("{}_localized", ident);
let in_locale = format_ident!("{}_in", ident);
let setter = format_ident!("set_{}", ident);
let locales = format_ident!("{}_locales", ident);
let is_translated = format_ident!("{}_is_translated", ident);
let doc_localized = format!(
"`{name}` resolved against the request's active locale, walking the \
configured fallback chain on miss. `None` once the chain is exhausted."
);
let doc_in =
format!("`{name}` resolved against an explicit locale, then the fallback chain.");
let doc_set = format!(
"Store `value` as `{name}`'s translation for `locale`, leaving every other \
locale untouched."
);
let doc_locales = format!("Every locale `{name}` has been translated into, in tag order.");
let doc_is_translated = format!("Whether `{name}` has its own translation for `locale`.");
quote! {
#[doc = #doc_localized]
#[must_use]
pub fn #localized(&self) -> ::core::option::Option<&str> {
self.#ident.resolve()
}
#[doc = #doc_in]
#[must_use]
pub fn #in_locale(&self, locale: &str) -> ::core::option::Option<&str> {
self.#ident.resolve_in(locale)
}
#[doc = #doc_set]
pub fn #setter(
&mut self,
locale: impl ::core::convert::Into<::std::string::String>,
value: impl ::core::convert::Into<::std::string::String>,
) -> &mut Self {
self.#ident.set(locale, value);
self
}
#[doc = #doc_locales]
#[must_use]
pub fn #locales(&self) -> ::std::vec::Vec<&str> {
self.#ident.available_locales()
}
#[doc = #doc_is_translated]
#[must_use]
pub fn #is_translated(&self, locale: &str) -> bool {
self.#ident.is_translated(locale)
}
}
});
let match_arms = fields.iter().zip(names.iter()).map(|(ident, name)| {
quote! { #name => ::core::option::Option::Some(&self.#ident), }
});
quote! {
impl #model {
#(#per_field)*
#[must_use]
pub const fn translatable_fields() -> &'static [&'static str] {
Self::__AUTUMN_TRANSLATABLE_COLUMNS
}
#[must_use]
pub fn translated(
&self,
field: &str,
) -> ::core::option::Option<&::autumn_web::i18n::Translated> {
match field {
#(#match_arms)*
_ => ::core::option::Option::None,
}
}
#[must_use]
pub fn available_locales(&self, field: &str) -> ::std::vec::Vec<&str> {
self.translated(field)
.map(::autumn_web::i18n::Translated::available_locales)
.unwrap_or_default()
}
#[must_use]
pub fn is_translated(&self, field: &str, locale: &str) -> bool {
self.translated(field)
.is_some_and(|t| t.is_translated(locale))
}
#[must_use]
pub fn localized(&self, field: &str) -> ::core::option::Option<&str> {
self.translated(field)
.and_then(::autumn_web::i18n::Translated::resolve)
}
}
}
}
#[derive(Clone)]
enum Normalizer {
Trim,
Downcase,
Upcase,
Squish,
With(syn::Path),
}
fn parse_field_normalize(field: &syn::Field) -> syn::Result<Vec<Normalizer>> {
let mut ops = Vec::new();
for attr in &field.attrs {
if !attr.path().is_ident("normalize") {
continue;
}
let is_empty = match &attr.meta {
syn::Meta::Path(_) => true,
syn::Meta::List(list) => list.tokens.is_empty(),
syn::Meta::NameValue(_) => false,
};
if is_empty {
return Err(syn::Error::new_spanned(
attr,
"`#[normalize]` requires at least one normalizer, e.g. \
`#[normalize(trim, downcase)]`",
));
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("trim") {
ops.push(Normalizer::Trim);
} else if meta.path.is_ident("downcase") {
ops.push(Normalizer::Downcase);
} else if meta.path.is_ident("upcase") {
ops.push(Normalizer::Upcase);
} else if meta.path.is_ident("squish") {
ops.push(Normalizer::Squish);
} else if meta.path.is_ident("with") {
let path: syn::Path = meta.value()?.parse()?;
ops.push(Normalizer::With(path));
} else {
return Err(meta.error(
"unsupported `#[normalize]` option; expected one of \
`trim`, `downcase`, `upcase`, `squish`, or `with = path`",
));
}
Ok(())
})?;
}
Ok(ops)
}
fn field_has_normalize(field: &syn::Field) -> bool {
has_attr(field, "normalize")
}
fn validate_normalize_field(field: &syn::Field) -> syn::Result<()> {
if !field_has_normalize(field) {
return Ok(());
}
parse_field_normalize(field)?;
let is_string = matches!(&field.ty, syn::Type::Path(p) if p.path.segments.last().is_some_and(|s| s.ident == "String"));
if !is_string {
return Err(syn::Error::new_spanned(
&field.ty,
"`#[normalize]` is only supported on non-null `String` fields \
(normalize a `String` column; `Option<String>` and other types are \
out of scope in this slice)",
));
}
Ok(())
}
fn emit_normalize_expr(ops: &[Normalizer], value_expr: &TokenStream) -> TokenStream {
let steps = ops.iter().map(|op| match op {
Normalizer::Trim => quote! { __autumn_n = ::autumn_web::normalize::trim(&__autumn_n); },
Normalizer::Downcase => {
quote! { __autumn_n = ::autumn_web::normalize::downcase(&__autumn_n); }
}
Normalizer::Upcase => {
quote! { __autumn_n = ::autumn_web::normalize::upcase(&__autumn_n); }
}
Normalizer::Squish => {
quote! { __autumn_n = ::autumn_web::normalize::squish(&__autumn_n); }
}
Normalizer::With(path) => quote! { __autumn_n = #path(&__autumn_n); },
});
quote! {{
let mut __autumn_n: ::std::string::String = #value_expr;
#(#steps)*
__autumn_n
}}
}
fn attrs_have_serde_rename_all(attrs: &[syn::Attribute]) -> bool {
let mut found = false;
for attr in attrs.iter().filter(|a| a.path().is_ident("serde")) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename_all") {
found = true;
}
if let Ok(value) = meta.value() {
let _: syn::Result<syn::Lit> = value.parse();
}
Ok(())
});
}
found
}
fn field_has_serde_rename(field: &syn::Field) -> bool {
let mut renamed = false;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("serde")) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
renamed = true;
}
if let Ok(value) = meta.value() {
let _: syn::Result<syn::Lit> = value.parse();
}
Ok(())
});
}
renamed
}
pub fn serde_rename_all_serialize_rule(attrs: &[syn::Attribute]) -> Option<String> {
let mut rule = None;
for attr in attrs.iter().filter(|a| a.path().is_ident("serde")) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename_all") {
if let Ok(value) = meta.value() {
if let Ok(syn::Lit::Str(s)) = value.parse::<syn::Lit>() {
rule = Some(s.value());
}
} else {
let _ = meta.parse_nested_meta(|inner| {
if let Ok(value) = inner.value()
&& let Ok(syn::Lit::Str(s)) = value.parse::<syn::Lit>()
&& inner.path.is_ident("serialize")
{
rule = Some(s.value());
}
Ok(())
});
}
} else if let Ok(value) = meta.value() {
let _: syn::Result<syn::Lit> = value.parse();
}
Ok(())
});
}
rule
}
fn field_serde_serialize_rename(field: &syn::Field) -> Option<String> {
let mut renamed = None;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("serde")) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
if let Ok(value) = meta.value() {
if let Ok(syn::Lit::Str(s)) = value.parse::<syn::Lit>() {
renamed = Some(s.value());
}
} else {
let _ = meta.parse_nested_meta(|inner| {
if let Ok(value) = inner.value()
&& let Ok(syn::Lit::Str(s)) = value.parse::<syn::Lit>()
&& inner.path.is_ident("serialize")
{
renamed = Some(s.value());
}
Ok(())
});
}
} else if let Ok(value) = meta.value() {
let _: syn::Result<syn::Lit> = value.parse();
}
Ok(())
});
}
renamed
}
fn apply_serde_rename_all_rule(rule: &str, field: &str) -> Option<String> {
fn pascal(field: &str) -> String {
field
.split('_')
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().collect::<String>() + chars.as_str()
})
})
.collect()
}
match rule {
"lowercase" | "snake_case" => Some(field.to_owned()),
"UPPERCASE" | "SCREAMING_SNAKE_CASE" => Some(field.to_ascii_uppercase()),
"PascalCase" => Some(pascal(field)),
"camelCase" => {
let pascal = pascal(field);
let mut chars = pascal.chars();
chars
.next()
.map(|first| first.to_lowercase().collect::<String>() + chars.as_str())
}
"kebab-case" => Some(field.replace('_', "-")),
"SCREAMING-KEBAB-CASE" => Some(field.to_ascii_uppercase().replace('_', "-")),
_ => None,
}
}
fn schema_property_name(field: &syn::Field, rename_all_rule: Option<&str>) -> Option<String> {
let ident = field.ident.as_ref()?;
let raw = ident.to_string();
let raw = raw.strip_prefix("r#").unwrap_or(&raw).to_owned();
Some(
field_serde_serialize_rename(field)
.or_else(|| rename_all_rule.and_then(|rule| apply_serde_rename_all_rule(rule, &raw)))
.unwrap_or(raw),
)
}
fn parse_model_searchable_lang(attrs: &[syn::Attribute]) -> syn::Result<Option<String>> {
for attr in attrs {
if attr.path().is_ident("searchable") {
if matches!(attr.meta, syn::Meta::Path(_)) {
return Ok(Some("simple".to_string()));
}
let mut lang = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("language") {
let value: syn::LitStr = meta.value()?.parse()?;
lang = Some(value.value());
Ok(())
} else {
Err(meta.error("unsupported searchable attribute"))
}
})?;
return Ok(Some(lang.unwrap_or_else(|| "simple".to_string())));
}
}
Ok(None)
}
fn parse_model_shard_key(attrs: &[syn::Attribute]) -> syn::Result<Option<String>> {
for attr in attrs {
if attr.path().is_ident("shard_key") {
let syn::Meta::NameValue(ref nv) = attr.meta else {
return Err(syn::Error::new_spanned(
attr,
"shard_key attribute requires a string value: #[shard_key = \"field\"]",
));
};
let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(ref lit_str),
..
}) = nv.value
else {
return Err(syn::Error::new_spanned(
&nv.value,
"shard_key value must be a string literal",
));
};
return Ok(Some(lit_str.value()));
}
}
Ok(None)
}
#[derive(Debug)]
enum FieldSearchable {
NotSearchable,
SearchableDefault,
SearchableWithWeight(String),
}
#[derive(Debug)]
struct FieldSearchableConfig {
kind: FieldSearchable,
embed: bool,
}
fn is_string_or_option_string(ty: &syn::Type) -> bool {
fn is_string(ty: &syn::Type) -> bool {
matches!(ty, syn::Type::Path(tp) if tp.path.segments.last()
.is_some_and(|s| s.ident == "String"))
}
if is_string(ty) {
return true;
}
let syn::Type::Path(tp) = ty else {
return false;
};
let Some(segment) = tp.path.segments.last() else {
return false;
};
if segment.ident != "Option" {
return false;
}
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
return false;
};
matches!(args.args.first(), Some(syn::GenericArgument::Type(inner)) if is_string(inner))
}
fn parse_field_searchable(field: &syn::Field) -> syn::Result<FieldSearchableConfig> {
for attr in &field.attrs {
if attr.path().is_ident("searchable") {
if matches!(attr.meta, syn::Meta::Path(_)) {
return Ok(FieldSearchableConfig {
kind: FieldSearchable::SearchableDefault,
embed: false,
});
}
let mut weight = None;
let mut embed = false;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("weight") {
let value: syn::LitStr = meta.value()?.parse()?;
weight = Some(value.value());
Ok(())
} else if meta.path.is_ident("embed") {
if embed {
return Err(meta.error("duplicate `embed` in #[searchable(...)]"));
}
embed = true;
Ok(())
} else {
Err(meta.error(
"unsupported field searchable attribute (expected `weight = \"A\"` or `embed`)",
))
}
})?;
return Ok(FieldSearchableConfig {
kind: weight.map_or(
FieldSearchable::SearchableDefault,
FieldSearchable::SearchableWithWeight,
),
embed,
});
}
}
Ok(FieldSearchableConfig {
kind: FieldSearchable::NotSearchable,
embed: false,
})
}
#[derive(Clone, Copy)]
enum SerdeAdapterMode {
Serialize,
Deserialize,
}
#[derive(Default)]
struct SerdeAdapterAttrs {
with: Option<LitStr>,
serialize_with: Option<LitStr>,
deserialize_with: Option<LitStr>,
}
fn serde_adapter_attrs(field: &Field) -> SerdeAdapterAttrs {
let mut adapters = SerdeAdapterAttrs::default();
for attr in field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("serde"))
{
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("with") {
adapters.with = Some(meta.value()?.parse()?);
} else if meta.path.is_ident("serialize_with") {
adapters.serialize_with = Some(meta.value()?.parse()?);
} else if meta.path.is_ident("deserialize_with") {
adapters.deserialize_with = Some(meta.value()?.parse()?);
}
Ok(())
});
}
adapters
}
fn hook_serde_adapter_attrs(field: &Field, mode: SerdeAdapterMode) -> Vec<TokenStream> {
let adapters = serde_adapter_attrs(field);
let mut entries = Vec::new();
if let Some(with) = adapters.with {
entries.push(quote! { with = #with });
}
match mode {
SerdeAdapterMode::Serialize => {
if let Some(serialize_with) = adapters.serialize_with {
entries.push(quote! { serialize_with = #serialize_with });
}
}
SerdeAdapterMode::Deserialize => {
if let Some(deserialize_with) = adapters.deserialize_with {
entries.push(quote! { deserialize_with = #deserialize_with });
}
}
}
if entries.is_empty() {
Vec::new()
} else {
vec![quote! { #[serde(#(#entries),*)] }]
}
}
fn has_hook_serde_adapter(field: &Field, mode: SerdeAdapterMode) -> bool {
let adapters = serde_adapter_attrs(field);
adapters.with.is_some()
|| match mode {
SerdeAdapterMode::Serialize => adapters.serialize_with.is_some(),
SerdeAdapterMode::Deserialize => adapters.deserialize_with.is_some(),
}
}
enum SerdeDefaultKind {
Default,
Path(syn::Path),
}
fn serde_default_kind(field: &Field) -> Option<SerdeDefaultKind> {
let mut default = None;
for attr in field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("serde"))
{
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("default") {
if meta.input.peek(syn::Token![=]) {
let value: LitStr = meta.value()?.parse()?;
if let Ok(path) = value.parse::<syn::Path>() {
default = Some(SerdeDefaultKind::Path(path));
}
} else {
default = Some(SerdeDefaultKind::Default);
}
}
Ok(())
});
}
default
}
fn commit_hook_missing_field_default_expr(field: &Field) -> Option<TokenStream> {
match serde_default_kind(field) {
Some(SerdeDefaultKind::Default) => Some(quote! { ::core::default::Default::default() }),
Some(SerdeDefaultKind::Path(path)) => Some(quote! { #path() }),
None if is_option_type(&field.ty) => Some(quote! { ::core::option::Option::None }),
None => None,
}
}
fn factory_assoc_type(field: &Field) -> Option<syn::Ident> {
for attr in &field.attrs {
if attr.path().is_ident("factory_assoc")
&& let Ok(ident) = attr.parse_args::<syn::Ident>()
{
return Some(ident);
}
}
None
}
fn ty_last_ident(ty: &syn::Type) -> Option<String> {
if let syn::Type::Path(tp) = ty {
tp.path.segments.last().map(|s| s.ident.to_string())
} else {
None
}
}
fn ty_last_generic_ident(ty: &syn::Type) -> Option<String> {
if let syn::Type::Path(tp) = ty {
let seg = tp.path.segments.last()?;
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
for arg in &args.args {
if let syn::GenericArgument::Type(inner) = arg {
return ty_last_ident(inner);
}
}
}
}
None
}
fn option_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
if let syn::Type::Path(tp) = ty {
let seg = tp.path.segments.last()?;
if seg.ident != "Option" {
return None;
}
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
for arg in &args.args {
if let syn::GenericArgument::Type(inner) = arg {
return Some(inner);
}
}
}
}
None
}
fn fake_expr_for_field(ident: &syn::Ident, ty: &syn::Type) -> Option<TokenStream> {
let raw = ident.to_string();
let name = raw.strip_prefix("r#").unwrap_or(&raw).to_ascii_lowercase();
if let Some(inner) = option_inner_type(ty) {
let inner_expr = fake_expr_core(&name, inner)?;
return Some(quote! { ::core::option::Option::Some(#inner_expr) });
}
fake_expr_core(&name, ty)
}
fn fake_expr_core(name: &str, ty: &syn::Type) -> Option<TokenStream> {
let last = ty_last_ident(ty)?;
match last.as_str() {
"String" => Some(fake_string_expr(name)),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "u128" | "i128" | "usize"
| "isize" => {
let cast = format_ident!("{last}");
let hi: i64 = match last.as_str() {
"i8" => i64::from(i8::MAX),
"u8" => i64::from(u8::MAX),
_ => 1000,
};
Some(quote! { (::autumn_web::fake::int_range(0, #hi) as #cast) })
}
"f32" | "f64" => {
let cast = format_ident!("{last}");
Some(quote! { (::autumn_web::fake::decimal_f64() as #cast) })
}
"bool" => Some(quote! { ::autumn_web::fake::boolean() }),
"Decimal" => Some(quote! { ::autumn_web::fake::decimal() }),
"Uuid" => Some(quote! { ::autumn_web::fake::uuid() }),
"DateTime" if ty_last_generic_ident(ty).as_deref() == Some("Utc") => {
Some(quote! { ::autumn_web::fake::recent_datetime() })
}
"NaiveDateTime" => Some(quote! { ::autumn_web::fake::recent_datetime().naive_utc() }),
"NaiveDate" => Some(quote! { ::autumn_web::fake::recent_datetime().date_naive() }),
_ => None,
}
}
fn fake_string_expr(name: &str) -> TokenStream {
if name.contains("email") {
quote! { ::autumn_web::fake::email() }
} else if name == "username" || name.contains("user_name") {
quote! { ::autumn_web::fake::username() }
} else if name.contains("url") || name.contains("link") || name.contains("website") {
quote! { ::autumn_web::fake::url() }
} else if name.contains("slug") {
quote! {{
let __autumn_slug = ::autumn_web::fake::words(3);
__autumn_slug
.split_whitespace()
.collect::<::std::vec::Vec<&str>>()
.join("-")
}}
} else if name.contains("body")
|| name.contains("content")
|| name.contains("description")
|| name.contains("summary")
|| name.contains("bio")
|| name.contains("text")
{
quote! { ::autumn_web::fake::paragraph() }
} else if name.contains("first_name") || name.contains("firstname") {
quote! { ::autumn_web::fake::first_name() }
} else if name.contains("last_name") || name.contains("lastname") || name.contains("surname") {
quote! { ::autumn_web::fake::last_name() }
} else if name == "name" {
quote! { ::autumn_web::fake::name() }
} else if name.contains("title") || name.contains("name") {
quote! { ::autumn_web::fake::words(3) }
} else {
quote! { ::autumn_web::fake::sentence() }
}
}
fn validate_factory_assoc_attrs(fields: &[&Field]) -> Option<TokenStream> {
for field in fields {
for attr in &field.attrs {
if attr.path().is_ident("factory_assoc") {
if let Err(err) = attr.parse_args::<syn::Ident>() {
return Some(err.to_compile_error());
}
if is_option_type(&field.ty) {
return Some(
syn::Error::new_spanned(
attr,
"#[factory_assoc] cannot be applied to an Option<T> field; \
factory_assoc is designed for non-nullable FK fields (e.g. i64). \
Use a plain field setter to supply a nullable association.",
)
.to_compile_error(),
);
}
}
}
}
None
}
fn excluded_from_new(field: &Field) -> bool {
has_attr(field, "id")
|| has_attr(field, "default")
|| has_attr(field, "lock_version")
|| has_attr(field, "position")
}
fn pascal_case(s: &str) -> String {
let s = s.strip_prefix("r#").unwrap_or(s);
s.split('_')
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().to_string() + &chars.collect::<String>()
})
})
.collect()
}
fn is_option_type(ty: &syn::Type) -> bool {
if let syn::Type::Path(tp) = ty {
tp.path
.segments
.last()
.is_some_and(|seg| seg.ident == "Option")
} else {
false
}
}
fn type_name_str(ty: &syn::Type) -> String {
crate::api_doc::last_segment_name(ty).unwrap_or_else(|| "unknown".to_owned())
}
fn humanize_field_label(name: &str) -> String {
let name = name.strip_prefix("r#").unwrap_or(name);
name.split('_')
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().to_string() + &chars.collect::<String>()
})
})
.collect::<Vec<_>>()
.join(" ")
}
fn form_control_tokens(inner_ty: &syn::Type, nullable: bool) -> TokenStream {
let name = type_name_str(inner_ty);
match name.as_str() {
"bool" => {
if nullable {
quote! {
::autumn_web::form::FieldControl::Select {
options: ::std::vec![
(::std::string::String::from(""), ::std::string::String::from("— Unset —")),
(::std::string::String::from("true"), ::std::string::String::from("Yes")),
(::std::string::String::from("false"), ::std::string::String::from("No")),
],
}
}
} else {
quote! { ::autumn_web::form::FieldControl::Checkbox }
}
}
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
| "usize" => quote! {
::autumn_web::form::FieldControl::Number {
step: ::core::option::Option::Some(::std::string::String::from("1")),
}
},
"f32" | "f64" | "Decimal" | "BigDecimal" => quote! {
::autumn_web::form::FieldControl::Number {
step: ::core::option::Option::Some(::std::string::String::from("any")),
}
},
"NaiveDate" => quote! { ::autumn_web::form::FieldControl::Date },
"NaiveDateTime" => quote! { ::autumn_web::form::FieldControl::DateTime },
"DateTime" => {
let picker_zone = crate::api_doc::unwrap_single_generic(inner_ty, "DateTime")
.is_some_and(|tz| matches!(type_name_str(&tz).as_str(), "Utc" | "Local"));
if picker_zone {
quote! { ::autumn_web::form::FieldControl::DateTime }
} else {
quote! { ::autumn_web::form::FieldControl::Text }
}
}
_ => quote! { ::autumn_web::form::FieldControl::Text },
}
}
fn datetime_local_serde_attr(ty: &syn::Type) -> Option<TokenStream> {
let nullable = is_option_type(ty);
let inner = if nullable {
crate::api_doc::unwrap_single_generic(ty, "Option")?
} else {
ty.clone()
};
let base = match type_name_str(&inner).as_str() {
"NaiveDateTime" => "deserialize_naive_datetime_local",
"DateTime" => {
let tz = crate::api_doc::unwrap_single_generic(&inner, "DateTime")?;
match type_name_str(&tz).as_str() {
"Utc" => "deserialize_datetime_local_utc",
"Local" => "deserialize_datetime_local_local",
_ => return None,
}
}
_ => return None,
};
if nullable {
let path = format!("::autumn_web::form::{base}_option");
Some(quote! { #[serde(default, deserialize_with = #path)] })
} else {
let path = format!("::autumn_web::form::{base}");
Some(quote! { #[serde(deserialize_with = #path)] })
}
}
fn emit_form_model_impl(
name: &syn::Ident,
fields_for_new: &[&&Field],
rename_all_rule: Option<&str>,
) -> TokenStream {
let field_exprs: Vec<TokenStream> = fields_for_new
.iter()
.filter_map(|f| {
let ident = f.ident.as_ref()?;
let field_name = ident.to_string();
let field_name = field_name.strip_prefix("r#").unwrap_or(&field_name);
let label = humanize_field_label(field_name);
let nullable = is_option_type(&f.ty);
let inner = crate::api_doc::unwrap_single_generic(&f.ty, "Option")
.unwrap_or_else(|| f.ty.clone());
let control = form_control_tokens(&inner, nullable);
let required = !nullable;
let serialized_name = field_serde_serialize_rename(f).or_else(|| {
rename_all_rule.and_then(|rule| apply_serde_rename_all_rule(rule, field_name))
});
let value_name = serialized_name
.filter(|serialized| serialized != field_name)
.map(|serialized| quote! { .with_value_name(#serialized) });
Some(quote! {
::autumn_web::form::FormField::new(
#field_name,
#label,
#control,
#required,
)
#value_name
})
})
.collect();
quote! {
impl ::autumn_web::form::FormModel for #name {
fn form_fields() -> ::std::vec::Vec<::autumn_web::form::FormField> {
::std::vec![
#(#field_exprs),*
]
}
}
}
}
fn emit_json_schema_tokens_for_field(field: &Field) -> TokenStream {
if field_is_translatable(field) {
return quote! {
::autumn_web::reexports::serde_json::json!({
"type": "object",
"description": "Per-locale content, keyed by locale tag (issue #1384).",
"additionalProperties": { "type": "string" }
})
};
}
emit_json_schema_tokens(&field.ty)
}
fn emit_json_schema_tokens(ty: &syn::Type) -> TokenStream {
if let Some(inner) = crate::api_doc::unwrap_single_generic(ty, "Option") {
let inner_tokens = emit_json_schema_tokens(&inner);
return quote! {{
let __inner = #inner_tokens;
::autumn_web::reexports::serde_json::json!({ "oneOf": [__inner, { "type": "null" }] })
}};
}
if let Some(inner) = crate::api_doc::unwrap_single_generic(ty, "Vec") {
let inner_tokens = emit_json_schema_tokens(&inner);
return quote! {{
let __items = #inner_tokens;
::autumn_web::reexports::serde_json::json!({ "type": "array", "items": __items })
}};
}
let name = type_name_str(ty);
crate::api_doc::primitive_json_type(&name).map_or_else(
|| {
quote! {{
let __ref_path = ::std::format!(
"#/components/schemas/{}",
::core::any::type_name::<#ty>()
);
::autumn_web::reexports::serde_json::json!({ "$ref": __ref_path })
}}
},
|json_type| {
quote! { ::autumn_web::reexports::serde_json::json!({ "type": #json_type }) }
},
)
}
pub fn emit_schema_fn_body(
fields: &[&&Field],
all_optional: bool,
rename_all_rule: Option<&str>,
) -> TokenStream {
emit_schema_fn_body_ext(fields, all_optional, &[], rename_all_rule)
}
fn emit_schema_fn_body_ext(
fields: &[&&Field],
all_optional: bool,
extra_required: &[&&Field],
rename_all_rule: Option<&str>,
) -> TokenStream {
let insertions: Vec<TokenStream> = fields
.iter()
.chain(extra_required.iter())
.map(|f| {
let field_name = schema_property_name(f, rename_all_rule)
.unwrap_or_else(|| f.ident.as_ref().unwrap().to_string());
let schema_expr = emit_json_schema_tokens_for_field(f);
quote! {
__props.insert(#field_name.to_owned(), #schema_expr);
}
})
.collect();
let mut required_names: Vec<String> = if all_optional {
Vec::new()
} else {
fields
.iter()
.filter(|f| !is_option_type(&f.ty))
.filter_map(|f| schema_property_name(f, rename_all_rule))
.collect()
};
for f in extra_required {
if let Some(name) = schema_property_name(f, rename_all_rule) {
required_names.push(name);
}
}
let required_tokens: Vec<TokenStream> = required_names
.iter()
.map(|name| {
quote! { ::autumn_web::reexports::serde_json::json!(#name) }
})
.collect();
quote! {
let mut __props = ::autumn_web::reexports::serde_json::Map::new();
#(#insertions)*
let mut __schema = ::autumn_web::reexports::serde_json::Map::new();
__schema.insert(
"type".to_owned(),
::autumn_web::reexports::serde_json::json!("object"),
);
__schema.insert(
"properties".to_owned(),
::autumn_web::reexports::serde_json::Value::Object(__props),
);
let __required: ::std::vec::Vec<::autumn_web::reexports::serde_json::Value> =
::std::vec![#(#required_tokens),*];
if !__required.is_empty() {
__schema.insert(
"required".to_owned(),
::autumn_web::reexports::serde_json::Value::Array(__required),
);
}
::autumn_web::reexports::serde_json::Value::Object(__schema)
}
}
struct StateMachineTransition {
from: String,
to: String,
guard: Option<String>,
on_commit: Option<syn::Path>,
on: Option<String>,
}
enum StateMachineSource {
Inline(Vec<StateMachineTransition>),
Lifecycle {
path: syn::Path,
effects: Vec<StateMachineTransition>,
},
}
struct StateMachineSpec {
field_ident: syn::Ident,
source: StateMachineSource,
}
fn validate_guard_ident(lit: &syn::LitStr) -> syn::Result<String> {
let guard_str = lit.value();
syn::parse_str::<syn::Ident>(&guard_str).map_err(|_| {
syn::Error::new_spanned(
lit,
format!(
"`{guard_str}` is not a valid Rust identifier; \
guard names must be a plain function name such as `can_ship`"
),
)
})?;
Ok(guard_str)
}
fn parse_transition_list(
content: syn::parse::ParseStream<'_>,
) -> syn::Result<Vec<StateMachineTransition>> {
let mut transitions = Vec::new();
while !content.is_empty() {
let from: syn::Ident = content.parse()?;
content.parse::<syn::Token![->]>()?;
let to: syn::Ident = content.parse()?;
let mut guard: Option<String> = None;
let mut on_commit: Option<syn::Path> = None;
let mut on: Option<String> = None;
if content.peek(syn::Token![:]) {
content.parse::<syn::Token![:]>()?;
if content.peek(syn::LitStr) {
let lit: syn::LitStr = content.parse()?;
guard = Some(validate_guard_ident(&lit)?);
} else {
loop {
let key: syn::Ident = content.parse()?;
content.parse::<syn::Token![=]>()?;
if key == "guard" {
if guard.is_some() {
return Err(syn::Error::new_spanned(
&key,
"duplicate `guard` on a single transition",
));
}
let lit: syn::LitStr = content.parse()?;
guard = Some(validate_guard_ident(&lit)?);
} else if key == "on_commit" {
if on_commit.is_some() {
return Err(syn::Error::new_spanned(
&key,
"duplicate `on_commit` on a single transition",
));
}
on_commit = Some(content.parse::<syn::Path>()?);
} else if key == "on" {
if on.is_some() {
return Err(syn::Error::new_spanned(
&key,
"duplicate `on` on a single transition",
));
}
let lit: syn::LitStr = content.parse()?;
on = Some(validate_guard_ident(&lit)?);
} else {
return Err(syn::Error::new_spanned(
&key,
"expected `guard = \"...\"`, `on = \"...\"`, or `on_commit = <Job>`",
));
}
if !content.peek(syn::Token![,]) {
break;
}
let ahead = content.fork();
ahead.parse::<syn::Token![,]>()?;
if ahead.peek(syn::Ident) && ahead.peek2(syn::Token![=]) {
content.parse::<syn::Token![,]>()?;
} else {
break;
}
}
}
}
transitions.push(StateMachineTransition {
from: from.to_string(),
to: to.to_string(),
guard,
on_commit,
on,
});
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
}
}
Ok(transitions)
}
fn parse_state_machine_source(
input: syn::parse::ParseStream<'_>,
) -> syn::Result<StateMachineSource> {
let key: syn::Ident = input.parse()?;
if key == "transitions" {
let content;
syn::parenthesized!(content in input);
Ok(StateMachineSource::Inline(parse_transition_list(&content)?))
} else if key == "lifecycle" {
input.parse::<syn::Token![=]>()?;
let path: syn::Path = input.parse()?;
let effects: Vec<StateMachineTransition> = if input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
let effects_kw: syn::Ident = input.parse()?;
if effects_kw != "effects" {
return Err(syn::Error::new(
effects_kw.span(),
"expected `effects(...)` after `lifecycle = <Enum>`",
));
}
let content;
syn::parenthesized!(content in input);
let parsed = parse_transition_list(&content)?;
for t in &parsed {
if t.guard.is_some() {
return Err(syn::Error::new(
effects_kw.span(),
"guards are not supported on `lifecycle = <Enum>` effects; \
lifecycle transitions are unguarded (declare effects only)",
));
}
if t.on.is_none() && t.on_commit.is_none() {
return Err(syn::Error::new(
effects_kw.span(),
"each `effects(...)` edge must declare `on = \"...\"` and/or `on_commit = <Job>`",
));
}
}
parsed
} else {
Vec::new()
};
Ok(StateMachineSource::Lifecycle { path, effects })
} else {
Err(syn::Error::new(
key.span(),
"expected `transitions(...)` or `lifecycle = <Enum>`",
))
}
}
fn parse_state_machine_spec(field: &syn::Field) -> syn::Result<Option<StateMachineSpec>> {
let Some(ident) = field.ident.as_ref() else {
return Ok(None);
};
let mut spec: Option<StateMachineSpec> = None;
for attr in &field.attrs {
if attr.path().is_ident("state_machine") {
if spec.is_some() {
return Err(syn::Error::new_spanned(
attr,
"multiple `#[state_machine]` attributes are not allowed on a single field",
));
}
let is_string = matches!(&field.ty, syn::Type::Path(p)
if p.path.segments.last().is_some_and(|s| s.ident == "String"));
if !is_string {
return Err(syn::Error::new_spanned(
&field.ty,
"`#[state_machine]` is only supported on `String` fields",
));
}
let source = attr.parse_args_with(parse_state_machine_source)?;
spec = Some(StateMachineSpec {
field_ident: ident.clone(),
source,
});
}
}
Ok(spec)
}
struct StateMachineNames {
const_name: syn::Ident,
can_fn: syn::Ident,
transition_fn: syn::Ident,
field_str: String,
}
fn state_machine_names(field: &syn::Ident) -> StateMachineNames {
let raw_field_str = field.to_string();
let field_str = raw_field_str
.strip_prefix("r#")
.unwrap_or(&raw_field_str)
.to_string();
let field_upper = field_str.to_uppercase();
StateMachineNames {
const_name: format_ident!("__AUTUMN_SM_{field_upper}_TRANSITIONS"),
can_fn: format_ident!("can_transition_{field_str}_to"),
transition_fn: format_ident!("transition_{field_str}_to"),
field_str,
}
}
fn emit_state_machine_impl(
model_name: &syn::Ident,
spec: &StateMachineSpec,
pk_ident: Option<&syn::Ident>,
) -> TokenStream {
match &spec.source {
StateMachineSource::Inline(transitions) => {
emit_state_machine_inline(model_name, &spec.field_ident, transitions, pk_ident)
}
StateMachineSource::Lifecycle { path, effects } => {
emit_state_machine_lifecycle(model_name, &spec.field_ident, path, effects, pk_ident)
}
}
}
fn emit_state_machine_inline(
model_name: &syn::Ident,
field: &syn::Ident,
transitions: &[StateMachineTransition],
pk_ident: Option<&syn::Ident>,
) -> TokenStream {
let StateMachineNames {
const_name,
can_fn,
transition_fn,
field_str,
} = state_machine_names(field);
let const_entries: Vec<TokenStream> = transitions
.iter()
.map(|t| {
let from = &t.from;
let to = &t.to;
t.guard.as_ref().map_or_else(
|| quote! { (#from, #to, ::core::option::Option::None) },
|g| quote! { (#from, #to, ::core::option::Option::Some(#g)) },
)
})
.collect();
let match_arms: Vec<TokenStream> = transitions
.iter()
.map(|t| {
let from = &t.from;
let to = &t.to;
t.guard.as_ref().map_or_else(
|| quote! { (#from, #to) => true },
|g| {
let guard_fn = format_ident!("{g}");
quote! { (#from, #to) => self.#guard_fn() }
},
)
})
.collect();
let on_conn_impl = emit_state_machine_on_conn(
model_name,
field,
&field_str,
&transition_fn,
transitions,
pk_ident,
);
quote! {
impl #model_name {
#[doc(hidden)]
pub const #const_name: &'static [(&'static str, &'static str, ::core::option::Option<&'static str>)] = &[
#(#const_entries,)*
];
pub fn #can_fn(&self, target: &str) -> bool {
match (&*self.#field, target) {
#(#match_arms,)*
_ => false,
}
}
pub fn #transition_fn(&self, target: &str) -> ::autumn_web::AutumnResult<::std::string::String> {
if self.#can_fn(target) {
::core::result::Result::Ok(::std::string::String::from(target))
} else {
::core::result::Result::Err(::autumn_web::AutumnError::bad_request_msg(
::std::format!(
"Cannot transition `{}` from `{}` to `{}`",
#field_str,
self.#field,
target,
),
))
}
}
}
#on_conn_impl
}
}
fn emit_state_machine_on_conn(
model_name: &syn::Ident,
field: &syn::Ident,
field_str: &str,
transition_fn: &syn::Ident,
transitions: &[StateMachineTransition],
pk_ident: Option<&syn::Ident>,
) -> TokenStream {
let has_effects = transitions
.iter()
.any(|t| t.on_commit.is_some() || t.on.is_some());
if !has_effects {
return TokenStream::new();
}
let on_conn_fn = format_ident!("{transition_fn}_on_conn");
let model_str = model_name.to_string();
let record_id_expr = pk_ident.map_or_else(
|| quote! { ::std::string::String::new() },
|pk| quote! { ::std::format!("{:?}", self.#pk) },
);
let effect_arms: Vec<TokenStream> = transitions
.iter()
.filter_map(|t| {
if t.on.is_none() && t.on_commit.is_none() {
return None;
}
let from = &t.from;
let to = &t.to;
let sync_effect = t.on.as_ref().map(|name| {
let handler = format_ident!("{name}");
quote! { self.#handler(conn).await?; }
});
let commit_effect = t.on_commit.as_ref().map(|job| {
quote! {
let __record_id: ::std::string::String = #record_id_expr;
let __idempotency_key = ::std::format!(
"{}:{}:{}:{}:{}",
#model_str, #field_str, __record_id, #from, #to,
);
let __effect = ::autumn_web::TransitionEffect {
model: ::std::string::String::from(#model_str),
field: ::std::string::String::from(#field_str),
record_id: __record_id,
from_state: ::std::string::String::from(#from),
to_state: ::std::string::String::from(#to),
idempotency_key: __idempotency_key,
};
::autumn_web::job::enqueue_on_conn(
<#job>::NAME,
&__effect,
conn,
).await?;
}
});
Some(quote! {
(#from, #to) => {
#sync_effect
#commit_effect
}
})
})
.collect();
quote! {
impl #model_name {
pub async fn #on_conn_fn(
&self,
conn: &mut ::autumn_web::reexports::diesel_async::AsyncPgConnection,
target: &str,
) -> ::autumn_web::AutumnResult<::std::string::String> {
let __new_state = self.#transition_fn(target)?;
match (&*self.#field, target) {
#(#effect_arms,)*
_ => {}
}
::core::result::Result::Ok(__new_state)
}
}
}
}
fn strip_raw(s: &str) -> &str {
s.strip_prefix("r#").unwrap_or(s)
}
fn emit_state_machine_lifecycle(
model_name: &syn::Ident,
field: &syn::Ident,
path: &syn::Path,
effects: &[StateMachineTransition],
pk_ident: Option<&syn::Ident>,
) -> TokenStream {
let StateMachineNames {
const_name,
can_fn,
transition_fn,
field_str,
} = state_machine_names(field);
let normalized_effects: Vec<StateMachineTransition> = effects
.iter()
.map(|t| StateMachineTransition {
from: strip_raw(&t.from).to_owned(),
to: strip_raw(&t.to).to_owned(),
guard: t.guard.clone(),
on_commit: t.on_commit.clone(),
on: t.on.clone(),
})
.collect();
let effects = &normalized_effects;
let on_conn_impl = emit_state_machine_on_conn(
model_name,
field,
&field_str,
&transition_fn,
effects,
pk_ident,
);
let effect_edge_assertions: Vec<TokenStream> = effects
.iter()
.map(|t| {
let from = &t.from;
let to = &t.to;
let msg = format!(
"state-machine effect declared on edge `{from} -> {to}`, which is not a \
transition of the referenced lifecycle enum; declare the effect on a real \
transition edge (or add the edge to the lifecycle)"
);
quote! {
const _: () = ::core::assert!(
::autumn_web::__transition_edge_declared(
<#path as ::autumn_web::Lifecycle>::STATE_MACHINE_TRANSITIONS,
#from,
#to,
),
#msg,
);
}
})
.collect();
quote! {
#(#effect_edge_assertions)*
impl #model_name {
#[doc(hidden)]
pub const #const_name: &'static [(&'static str, &'static str, ::core::option::Option<&'static str>)] =
<#path as ::autumn_web::Lifecycle>::STATE_MACHINE_TRANSITIONS;
pub fn #can_fn(&self, target: &str) -> bool {
let __current: &str = &self.#field;
Self::#const_name
.iter()
.any(|&(__from, __to, __guard)| {
__from == __current && __to == target && __guard.is_none()
})
}
pub fn #transition_fn(&self, target: &str) -> ::autumn_web::AutumnResult<::std::string::String> {
if self.#can_fn(target) {
::core::result::Result::Ok(::std::string::String::from(target))
} else {
::core::result::Result::Err(::autumn_web::AutumnError::bad_request_msg(
::std::format!(
"Cannot transition `{}` from `{}` to `{}`",
#field_str,
self.#field,
target,
),
))
}
}
}
#on_conn_impl
}
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::cognitive_complexity)]
pub fn model_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
let input: DeriveInput = match syn::parse2(item) {
Ok(input) => input,
Err(err) => return err.to_compile_error(),
};
let syn::Data::Struct(syn::DataStruct {
fields: syn::Fields::Named(ref fields),
..
}) = input.data
else {
return syn::Error::new_spanned(
&input.ident,
"#[model] can only be applied to structs with named fields",
)
.to_compile_error();
};
let table_name = match parse_attr_args(attr) {
Ok(model_args) => model_args
.table
.unwrap_or_else(|| infer_table_name(&input.ident)),
Err(err) => return err.to_compile_error(),
};
let table_ident = syn::Ident::new(&table_name, input.ident.span());
let name = &input.ident;
let vis = &input.vis;
let outer_attrs = &input.attrs;
let searchable_lang = match parse_model_searchable_lang(outer_attrs) {
Ok(lang) => lang,
Err(err) => return err.to_compile_error(),
};
let is_searchable = searchable_lang.is_some();
let search_language = searchable_lang.unwrap_or_else(|| "simple".to_string());
let shard_key_field = match parse_model_shard_key(outer_attrs) {
Ok(key) => key,
Err(err) => return err.to_compile_error(),
};
let associations = match resolve_associations(name, outer_attrs) {
Ok(assocs) => assocs,
Err(err) => return err.to_compile_error(),
};
let association_items = emit_association_items(name, &table_ident, vis, &associations);
let dependents_impl = emit_dependents_impl(name, &associations);
let votable = match resolve_votable(name, outer_attrs) {
Ok(spec) => spec,
Err(err) => return err.to_compile_error(),
};
let commentable = match resolve_commentable(name, outer_attrs) {
Ok(spec) => spec,
Err(err) => return err.to_compile_error(),
};
let filtered_outer_attrs: Vec<&syn::Attribute> = outer_attrs
.iter()
.filter(|a| {
!a.path().is_ident("searchable")
&& !is_association_attr(a)
&& !is_votable_attr(a)
&& !is_commentable_attr(a)
&& !a.path().is_ident("shard_key")
})
.collect();
let new_name = format_ident!("New{name}");
let update_name = format_ident!("Update{name}");
let changeset_name = format_ident!("__{}Changeset", name);
let all_fields: Vec<&Field> = fields.named.iter().collect();
for field in &all_fields {
if let Err(err) = validate_field_schema_markers(field) {
return err.to_compile_error();
}
}
if let Some(ref key) = shard_key_field {
let field_exists = key == "id"
|| all_fields
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == key));
if !field_exists {
let attr = outer_attrs
.iter()
.find(|a| a.path().is_ident("shard_key"))
.expect("attribute was parsed above");
return syn::Error::new_spanned(
attr,
format!("shard_key field `{key}` not found on model"),
)
.to_compile_error();
}
}
let votable_items = match votable {
None => TokenStream::new(),
Some(ref spec) => {
let aggregate_field = all_fields
.iter()
.find(|f| f.ident.as_ref().is_some_and(|i| i == &spec.column));
let Some(aggregate_field) = aggregate_field else {
let attr = outer_attrs
.iter()
.find(|a| is_votable_attr(a))
.expect("attribute was parsed above");
return syn::Error::new_spanned(
attr,
format!(
"votable aggregate column `{}` not found on model `{name}`; \
add the field (e.g. `pub {}: i64`) or override it with \
`#[votable(..., column = <field>)]`",
spec.column, spec.column,
),
)
.to_compile_error();
};
let aggregate_ty = aggregate_field.ty.to_token_stream().to_string();
let definitely_not_i64: &[&str] = &[
"i8", "i16", "i32", "i128", "u8", "u16", "u32", "u64", "u128", "usize", "isize",
"f32", "f64", "bool", "String",
];
if definitely_not_i64.contains(&aggregate_ty.as_str())
|| aggregate_ty.starts_with("Option <")
{
return syn::Error::new_spanned(
&aggregate_field.ty,
format!(
"votable aggregate column `{}` on model `{name}` must be \
`i64` (BIGINT), found `{aggregate_ty}`; the aggregate is \
`SUM(value)` / `COUNT(*)` and is written back as an \
`i64` — widen the column and the field",
spec.column,
),
)
.to_compile_error();
}
let has_deleted_at = all_fields
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == "deleted_at"));
let has_tenant_id = all_fields
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == "tenant_id"));
let id_fields: Vec<&syn::Ident> = all_fields
.iter()
.filter(|f| has_attr(f, "id"))
.filter_map(|f| f.ident.as_ref())
.collect();
if id_fields.len() > 1 {
let attr = outer_attrs
.iter()
.find(|a| is_votable_attr(a))
.expect("attribute was parsed above");
let listed = id_fields
.iter()
.map(|i| format!("`{i}`"))
.collect::<Vec<_>>()
.join(", ");
return syn::Error::new_spanned(
attr,
format!(
"`#[votable]` requires a single `i64` primary key: model \
`{name}` declares a composite key ({listed}), and \
`react(reactor_id, target_id)` identifies its target by \
one id — the edge table and the aggregate UPDATE cannot \
address a composite-keyed row"
),
)
.to_compile_error();
}
let pk_ident = all_fields
.iter()
.find(|f| has_attr(f, "id"))
.or_else(|| {
all_fields.iter().find(|f| match &f.ty {
syn::Type::Path(tp) => tp.path.is_ident("i32") || tp.path.is_ident("i64"),
_ => false,
})
})
.and_then(|f| f.ident.as_ref());
emit_votable_items(
name,
&table_ident,
vis,
spec,
has_deleted_at,
has_tenant_id,
pk_ident,
)
}
};
let commentable_items = match commentable {
None => TokenStream::new(),
Some(ref spec) => {
if let Some(counter_column) = spec.counter_column.as_deref() {
let counter_field = all_fields
.iter()
.find(|f| f.ident.as_ref().is_some_and(|i| i == counter_column));
let Some(counter_field) = counter_field else {
return syn::Error::new(
spec.span,
format!(
"commentable counter column `{counter_column}` not found on \
model `{name}`; add the field (e.g. `#[default] pub \
{counter_column}: i64`), point the attribute at an \
existing one with `#[commentable(..., counter_cache = \
<field>)]`, or opt out with `#[commentable(..., \
counter_cache = false)]`"
),
)
.to_compile_error();
};
let counter_ty = counter_field.ty.to_token_stream().to_string();
let definitely_not_i64: &[&str] = &[
"i8", "i16", "i32", "i128", "u8", "u16", "u32", "u64", "u128", "usize",
"isize", "f32", "f64", "bool", "String",
];
if definitely_not_i64.contains(&counter_ty.as_str())
|| counter_ty.starts_with("Option <")
{
return syn::Error::new_spanned(
&counter_field.ty,
format!(
"commentable counter column `{counter_column}` on model \
`{name}` must be `i64` (BIGINT), found `{counter_ty}`; the \
counter is maintained with `SET c = c + 1` and read back \
as an `i64` — widen the column and the field"
),
)
.to_compile_error();
}
}
let cmt_has_deleted_at = all_fields
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == "deleted_at"));
let cmt_has_tenant_id = all_fields
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == "tenant_id"));
let cmt_id_fields: Vec<&syn::Ident> = all_fields
.iter()
.filter(|f| has_attr(f, "id"))
.filter_map(|f| f.ident.as_ref())
.collect();
if cmt_id_fields.len() > 1 {
let listed = cmt_id_fields
.iter()
.map(|i| format!("`{i}`"))
.collect::<Vec<_>>()
.join(", ");
return syn::Error::new(
spec.span,
format!(
"`#[commentable]` requires a single `i64` primary key: model \
`{name}` declares a composite key ({listed}), and \
`commentable_id` is one column — it cannot address a \
composite-keyed row"
),
)
.to_compile_error();
}
let cmt_pk_ident = all_fields
.iter()
.find(|f| has_attr(f, "id"))
.or_else(|| {
all_fields.iter().find(|f| match &f.ty {
syn::Type::Path(tp) => tp.path.is_ident("i32") || tp.path.is_ident("i64"),
_ => false,
})
})
.and_then(|f| f.ident.as_ref());
emit_commentable_items(
name,
vis,
spec,
&table_name,
&crate::commentable::ParentShape {
has_deleted_at: cmt_has_deleted_at,
has_tenant_id: cmt_has_tenant_id,
is_sharded: shard_key_field.is_some(),
pk_ident: cmt_pk_ident,
},
)
}
};
let counter_caches_impl = {
let cc_has_deleted_at = all_fields
.iter()
.any(|f| f.ident.as_ref().is_some_and(|i| i == "deleted_at"));
let cc_pk_ident = all_fields
.iter()
.find(|f| has_attr(f, "id"))
.or_else(|| {
all_fields.iter().find(|f| match &f.ty {
syn::Type::Path(tp) => tp.path.is_ident("i32") || tp.path.is_ident("i64"),
_ => false,
})
})
.and_then(|f| f.ident.as_ref());
match emit_counter_caches_impl(
name,
&table_name,
cc_pk_ident,
cc_has_deleted_at,
&associations,
&all_fields,
) {
Ok(tokens) => tokens,
Err(err) => return err.to_compile_error(),
}
};
let mut search_field_names = Vec::new();
let mut search_field_weights = Vec::new();
let mut search_field_idents: Vec<syn::Ident> = Vec::new();
let mut search_embed_field: Option<String> = None;
for field in &all_fields {
let cfg = match parse_field_searchable(field) {
Ok(cfg) => cfg,
Err(err) => return err.to_compile_error(),
};
match cfg.kind {
FieldSearchable::NotSearchable => {
if cfg.embed {
return syn::Error::new_spanned(
field,
"`embed` requires the field to be #[searchable]",
)
.to_compile_error();
}
}
weight_type => {
let field_ident = field.ident.as_ref().unwrap();
let weight = match weight_type {
FieldSearchable::SearchableWithWeight(w) => w,
FieldSearchable::SearchableDefault | FieldSearchable::NotSearchable => {
"D".to_string()
}
};
if weight.len() != 1 {
return syn::Error::new_spanned(
field_ident,
"searchable weight must be a single character (A, B, C, or D)",
)
.to_compile_error();
}
let weight_char = weight.chars().next().unwrap();
if !['A', 'B', 'C', 'D'].contains(&weight_char) {
return syn::Error::new_spanned(
field_ident,
"searchable weight must be A, B, C, or D",
)
.to_compile_error();
}
if cfg.embed {
if let Some(existing) = &search_embed_field {
return syn::Error::new_spanned(
field_ident,
format!(
"only one field may be marked #[searchable(embed)]; `{existing}` \
already is. A model has a single embedding per record — \
concatenate the fields you want embedded into one column."
),
)
.to_compile_error();
}
search_embed_field = Some(field_ident.to_string());
}
search_field_names.push(field_ident.to_string());
search_field_weights.push(weight_char);
search_field_idents.push(field_ident.clone());
}
}
}
if !is_searchable && search_embed_field.is_some() {
return syn::Error::new_spanned(
name,
"#[searchable(embed)] requires the model to be marked #[searchable] \
(add `#[searchable]` or `#[searchable(language = \"...\")]` above the struct)",
)
.to_compile_error();
}
let id_fields: Vec<&&Field> = all_fields.iter().filter(|f| has_attr(f, "id")).collect();
let id_field_names: Vec<&syn::Ident> = if id_fields.is_empty() {
all_fields
.iter()
.filter(|f| {
if let syn::Type::Path(tp) = &f.ty {
tp.path.is_ident("i32") || tp.path.is_ident("i64")
} else {
false
}
})
.take(1)
.filter_map(|f| f.ident.as_ref())
.collect()
} else {
id_fields.iter().filter_map(|f| f.ident.as_ref()).collect()
};
let pk_field_for_factory: Option<(&syn::Ident, &syn::Type)> = if id_fields.is_empty() {
all_fields
.iter()
.find(|f| {
if let syn::Type::Path(tp) = &f.ty {
tp.path.is_ident("i32") || tp.path.is_ident("i64")
} else {
false
}
})
.and_then(|f| f.ident.as_ref().map(|id| (id, &f.ty)))
} else {
id_fields
.first()
.and_then(|f| f.ident.as_ref().map(|id| (id, &f.ty)))
};
let sm_pk_ident: Option<&syn::Ident> = pk_field_for_factory.map(|(id, _)| id);
let mut state_machine_impls: Vec<TokenStream> = Vec::new();
for field in &all_fields {
match parse_state_machine_spec(field) {
Ok(Some(spec)) => {
state_machine_impls.push(emit_state_machine_impl(name, &spec, sm_pk_ident));
}
Ok(None) => {}
Err(err) => return err.to_compile_error(),
}
}
let search_pk_ident: Option<&syn::Ident> = pk_field_for_factory.and_then(|(id, ty)| {
matches!(ty, syn::Type::Path(tp) if tp.path.is_ident("i64")).then_some(id)
});
let search_indexed_impl = match (is_searchable, search_pk_ident) {
(true, Some(pk_ident)) if !search_field_idents.is_empty() => {
let search_pk_column = pk_ident.to_string();
let field_names = search_field_names.clone();
let field_weights = search_field_weights.clone();
let field_idents = search_field_idents.clone();
let embed_const = search_embed_field.as_ref().map_or_else(
|| quote! { ::core::option::Option::None },
|field| quote! { ::core::option::Option::Some(#field) },
);
let embed_extract = search_embed_field.as_ref().map_or_else(
|| quote! {},
|field| {
let ident = syn::Ident::new(field, name.span());
quote! {
let __autumn_embed =
::autumn_web::search::SearchTextValue::search_text_value(&self.#ident);
if !__autumn_embed.is_empty() {
__autumn_doc.embed_text = ::core::option::Option::Some(__autumn_embed);
}
}
},
);
let has_tenant_column = all_fields.iter().any(|f| {
f.ident.as_ref().is_some_and(|i| i == "tenant_id")
&& is_string_or_option_string(&f.ty)
});
let tenant_scoped_expr = if has_tenant_column {
quote! { <Self>::__autumn_repo_tenant_scope() }
} else {
quote! { false }
};
let tenant_extract = if has_tenant_column {
quote! {
let __autumn_tenant =
::autumn_web::search::SearchTextValue::search_text_value(&self.tenant_id);
if !__autumn_tenant.is_empty() {
__autumn_doc.tenant_id = ::core::option::Option::Some(__autumn_tenant);
}
}
} else {
quote! {}
};
quote! {
impl ::autumn_web::search::SearchIndexed for #name {
const SEARCH_INDEX: &'static str = #table_name;
const SEARCH_EMBED_FIELD: ::core::option::Option<&'static str> = #embed_const;
fn index_definition() -> ::autumn_web::search::IndexDefinition {
use ::autumn_web::preload::AutumnPreloadScopeExt as _;
const __AUTUMN_SEARCH_INDEX_FIELDS:
&[::autumn_web::search::SearchIndexField] = &[
#(::autumn_web::search::SearchIndexField::new(
#field_names,
#field_weights,
)),*
];
::autumn_web::search::IndexDefinition::new(
#table_name,
#search_language,
__AUTUMN_SEARCH_INDEX_FIELDS,
#embed_const,
#tenant_scoped_expr,
)
.with_key_column(#search_pk_column)
.with_soft_delete(<Self>::__autumn_repo_soft_delete_scope())
}
fn search_id(&self) -> i64 {
self.#pk_ident
}
fn search_document(&self) -> ::autumn_web::search::SearchDocument {
let mut __autumn_doc = ::autumn_web::search::SearchDocument::new(
#table_name,
self.#pk_ident,
);
#(
__autumn_doc = __autumn_doc.with_field(
#field_names,
#field_weights,
::autumn_web::search::SearchTextValue::search_text_value(
&self.#field_idents,
),
);
)*
#embed_extract
#tenant_extract
__autumn_doc
}
}
}
}
_ => quote! {},
};
let fields_for_new: Vec<&&Field> = all_fields
.iter()
.filter(|f| {
!excluded_from_new(f)
&& f.ident
.as_ref()
.is_some_and(|id| !id_field_names.contains(&id))
})
.collect();
let lock_version_field: Option<&&Field> =
all_fields.iter().find(|f| has_attr(f, "lock_version"));
if let Some(err) = validate_factory_assoc_attrs(&all_fields) {
return err;
}
let mut encrypted_columns: Vec<(String, bool, bool, bool)> = Vec::new();
for f in &all_fields {
if let Err(err) = validate_encrypted_field(f) {
return err.to_compile_error();
}
match parse_field_encrypted(f) {
Ok(spec) if spec.is_encrypted() => {
let col = f.ident.as_ref().unwrap().to_string();
encrypted_columns.push((
col,
spec.mode == EncryptedMode::Deterministic,
spec.admin_visible,
spec.versioned_ciphertext,
));
}
Ok(_) => {}
Err(err) => return err.to_compile_error(),
}
}
let mut translatable_columns: Vec<&syn::Ident> = Vec::new();
for f in &all_fields {
if let Err(err) = validate_translatable_field(f) {
return err.to_compile_error();
}
if field_is_translatable(f)
&& let Some(ident) = f.ident.as_ref()
{
translatable_columns.push(ident);
}
}
let translatable_column_names: Vec<String> = translatable_columns
.iter()
.map(|i| unraw_ident(i))
.collect();
let translatable_inventory: Vec<TokenStream> = translatable_column_names
.iter()
.map(|col| {
quote! {
::autumn_web::reexports::inventory::submit! {
::autumn_web::i18n::TranslatableColumnDescriptor {
model: stringify!(#name),
table: #table_name,
column: #col,
}
}
}
})
.collect();
let translatable_items = emit_translatable_items(name, &translatable_columns);
let mut normalized_columns: Vec<(&syn::Ident, String, Vec<Normalizer>)> = Vec::new();
for f in &all_fields {
if let Err(err) = validate_normalize_field(f) {
return err.to_compile_error();
}
if !field_has_normalize(f) {
continue;
}
let ident = f.ident.as_ref().unwrap();
let lookup_key = ident.to_string();
match parse_field_normalize(f) {
Ok(ops) => normalized_columns.push((ident, lookup_key, ops)),
Err(err) => return err.to_compile_error(),
}
}
if !encrypted_columns.is_empty() && attrs_have_serde_rename_all(outer_attrs) {
return syn::Error::new_spanned(
name,
"`#[serde(rename_all = ...)]` cannot be combined with `#[encrypted]` fields in v1: \
encrypted columns are registered under their Rust names, which must match the \
serialized keys used by version history / log scrubbing / admin redaction.",
)
.to_compile_error();
}
let encrypted_column_names: Vec<&str> =
encrypted_columns.iter().map(|(c, ..)| c.as_str()).collect();
let encrypted_use = if encrypted_columns.is_empty() {
quote! {}
} else {
quote! {
#[allow(unused_imports)]
use ::autumn_web::reexports::diesel::ExpressionMethods as _;
}
};
let commit_hook_encrypt_stmt = if encrypted_columns.is_empty() {
quote! {}
} else {
quote! {
::autumn_web::encryption::encrypt_persisted_columns_in_value(
#table_name,
&mut __autumn_value,
);
}
};
let commit_hook_decrypt_stmt = if encrypted_columns.is_empty() {
quote! {}
} else {
quote! {
::autumn_web::encryption::decrypt_persisted_columns_in_value(
#table_name,
&mut __autumn_decoded_value,
);
}
};
let lock_version_ident: Option<&syn::Ident> = lock_version_field.and_then(|f| f.ident.as_ref());
let mutable_idents: Vec<&syn::Ident> = fields_for_new
.iter()
.map(|f| f.ident.as_ref().unwrap())
.chain(lock_version_ident)
.collect();
let (
name_debug_derive,
name_debug_impl,
new_debug_derive,
new_debug_impl,
update_debug_derive,
update_debug_impl,
changeset_debug_derive,
changeset_debug_impl,
) = if encrypted_columns.is_empty() {
(
quote! { Debug, },
quote! {},
quote! { Debug, },
quote! {},
quote! { Debug, },
quote! {},
quote! { Debug, },
quote! {},
)
} else {
let all_idents: Vec<&syn::Ident> = all_fields
.iter()
.map(|f| f.ident.as_ref().unwrap())
.collect();
let new_idents: Vec<&syn::Ident> = fields_for_new
.iter()
.map(|f| f.ident.as_ref().unwrap())
.collect();
(
quote! {},
redacting_debug_impl(name, &all_idents, &encrypted_column_names),
quote! {},
redacting_debug_impl(&new_name, &new_idents, &encrypted_column_names),
quote! {},
redacting_debug_impl(&update_name, &mutable_idents, &encrypted_column_names),
quote! {},
redacting_debug_impl(&changeset_name, &mutable_idents, &encrypted_column_names),
)
};
let encrypted_inventory: Vec<TokenStream> = encrypted_columns
.iter()
.map(
|(col, deterministic, admin_visible, versioned_ciphertext)| {
quote! {
::autumn_web::reexports::inventory::submit! {
::autumn_web::encryption::EncryptedColumnDescriptor {
model: stringify!(#name),
table: #table_name,
column: #col,
deterministic: #deterministic,
admin_visible: #admin_visible,
versioned_ciphertext: #versioned_ciphertext,
}
}
}
},
)
.collect();
let has_validation = all_fields.iter().any(|f| !validate_attrs(f).is_empty());
let query_fields: Vec<TokenStream> = all_fields
.iter()
.map(|f| {
let ident = &f.ident;
let ty = &f.ty;
let attrs = user_attrs(f);
let val_attrs = validate_attrs(f);
let enc = encrypted_wrapper_path(
parse_field_encrypted_mode(f).unwrap_or(EncryptedMode::None),
)
.map(|w| quote! { #[diesel(serialize_as = #w, deserialize_as = #w)] });
let private = (field_hidden_from_json(f) && !field_already_skips_serialization(f))
.then(|| quote! { #[serde(skip_serializing)] });
quote! { #(#val_attrs)* #(#attrs)* #enc #private pub #ident: #ty }
})
.collect();
let new_fields: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = &f.ident;
let ty = &f.ty;
let val_attrs = validate_attrs(f);
let enc = encrypted_wrapper_path(
parse_field_encrypted_mode(f).unwrap_or(EncryptedMode::None),
)
.map(|w| quote! { #[diesel(serialize_as = #w)] });
let bool_default = (!is_option_type(ty) && type_name_str(ty) == "bool")
.then(|| quote! { #[serde(default)] });
let datetime_local = datetime_local_serde_attr(ty);
quote! { #(#val_attrs)* #enc #bool_default #datetime_local pub #ident: #ty }
})
.collect();
let mut update_fields: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = &f.ident;
let ty = &f.ty;
let val_attrs = validate_attrs_for_patch(f);
quote! {
#(#val_attrs)*
#[serde(default)]
pub #ident: ::autumn_web::hooks::Patch<#ty>
}
})
.collect();
if let Some(lv_field) = lock_version_field {
let ident = &lv_field.ident;
let ty = &lv_field.ty;
update_fields.push(quote! {
pub #ident: #ty
});
}
let field_enum_name = format_ident!("{name}Field");
let field_enum_variants: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
let variant = format_ident!("{}", pascal_case(&ident.to_string()));
quote! { #variant }
})
.collect();
let validate_derive = if has_validation {
quote! { #[derive(::autumn_web::reexports::validator::Validate)] }
} else {
quote! {}
};
let merged_validate_stmt = if has_validation {
quote! {
{
#[allow(unused_imports)]
use ::autumn_web::validation::{
MaybeValidateFallback as _, MaybeValidateViaValidator as _,
};
(&::autumn_web::validation::MaybeValidate(&after)).autumn_maybe_validate()?;
}
}
} else {
quote! {}
};
let mut merge_arms: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
let is_option = is_option_type(&f.ty);
if is_option {
quote! {
match &patch.#ident {
::autumn_web::hooks::Patch::Set(v) => after.#ident = v.clone(),
::autumn_web::hooks::Patch::Clear => after.#ident = None,
::autumn_web::hooks::Patch::Unchanged => {}
}
}
} else {
quote! {
match &patch.#ident {
::autumn_web::hooks::Patch::Set(v) => after.#ident = v.clone(),
::autumn_web::hooks::Patch::Clear => {
return Err(::autumn_web::AutumnError::bad_request_msg(
format!("Cannot clear non-nullable field `{}`", stringify!(#ident))
));
}
::autumn_web::hooks::Patch::Unchanged => {}
}
}
}
})
.collect();
if let Some(lv_field) = lock_version_field {
let ident = lv_field.ident.as_ref().unwrap();
merge_arms.push(quote! {
after.#ident = current.#ident.wrapping_add(1);
});
}
let draft_accessor_sigs: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
let ty = &f.ty;
quote! {
fn #ident(&mut self) -> ::autumn_web::hooks::DraftField<'_, #ty>;
}
})
.collect();
let draft_accessors: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
let ty = &f.ty;
quote! {
fn #ident(&mut self) -> ::autumn_web::hooks::DraftField<'_, #ty> {
::autumn_web::hooks::DraftField::new(&self.before.#ident, &mut self.after.#ident)
}
}
})
.collect();
let draft_ext_name = format_ident!("{name}DraftExt");
let column_count = all_fields.len();
let new_column_count = fields_for_new.len();
let tenant_id_field = all_fields
.iter()
.find(|f| f.ident.as_ref().is_some_and(|id| id == "tenant_id"))
.copied();
let deleted_at_field = all_fields
.iter()
.find(|f| f.ident.as_ref().is_some_and(|id| id == "deleted_at"))
.copied();
let soft_delete_retain = match deleted_at_field {
Some(f) if is_option_type(&f.ty) => quote! {
if <Self>::__autumn_repo_soft_delete_scope() {
rows.retain(|__r| ::core::option::Option::is_none(&__r.deleted_at));
}
},
_ => quote! {},
};
let tenant_retain = tenant_id_field.as_ref().map_or_else(
|| quote! {},
|f| {
let cmp = if is_option_type(&f.ty) {
quote! { __r.tenant_id.as_deref() == ::core::option::Option::Some(__t.as_str()) }
} else {
quote! { __r.tenant_id == __t }
};
quote! {
if <Self>::__autumn_repo_tenant_scope()
&& !::autumn_web::preload::preload_across_tenants()
{
match ::autumn_web::tenancy::CURRENT_TENANT
.try_with(|__c| __c.clone())
.ok()
.flatten()
{
::core::option::Option::Some(__t) => {
rows.retain(|__r| #cmp);
}
::core::option::Option::None => {
return ::core::result::Result::Err(
::autumn_web::AutumnError::internal_server_error_msg(
"Query scoped to tenant, but no tenant context was established"
)
);
}
}
}
}
},
);
let preload_scope_in_scope = if deleted_at_field.is_some() || tenant_id_field.is_some() {
quote! { use ::autumn_web::preload::AutumnPreloadScopeExt as _; }
} else {
quote! {}
};
let preload_retain_rows = if deleted_at_field.is_some() || tenant_id_field.is_some() {
quote! { mut rows }
} else {
quote! { rows }
};
let preload_retain_impl = quote! {
impl #name {
#[doc(hidden)]
pub fn __autumn_preload_retain(
#preload_retain_rows: ::std::vec::Vec<Self>,
) -> ::autumn_web::AutumnResult<::std::vec::Vec<Self>> {
#preload_scope_in_scope
#soft_delete_retain
#tenant_retain
::core::result::Result::Ok(rows)
}
#[doc(hidden)]
pub fn __autumn_preload_keep(
row: Self,
) -> ::autumn_web::AutumnResult<::core::option::Option<Self>> {
let mut __kept = <Self>::__autumn_preload_retain(::std::vec![row])?;
::core::result::Result::Ok(__kept.pop())
}
}
};
let new_has_tenant_id = fields_for_new
.iter()
.any(|f| f.ident.as_ref().is_some_and(|id| id == "tenant_id"));
let can_set_tenant_id_impl = if new_has_tenant_id {
let f = fields_for_new
.iter()
.find(|f| f.ident.as_ref().is_some_and(|id| id == "tenant_id"))
.unwrap();
let is_option = is_option_type(&f.ty);
let val = if is_option {
quote! { ::core::option::Option::Some(::core::option::Option::Some(t)) }
} else {
quote! { ::core::option::Option::Some(t) }
};
quote! {
impl ::autumn_web::repository::CanSetTenantId for #changeset_name {
fn set_tenant_id(&mut self, t: ::std::string::String) {
self.tenant_id = #val;
}
}
}
} else {
quote! {
impl ::autumn_web::repository::CanSetTenantId for #changeset_name {
fn set_tenant_id(&mut self, _t: ::std::string::String) {}
}
}
};
let model_tenant_id_meta_impl = tenant_id_field.as_ref().map_or_else(
|| {
quote! {
impl ::autumn_web::tenancy::ModelTenantIdMeta for #new_name {
const HAS_MANUAL_TENANT_ID: bool = false;
fn try_set_tenant_id(&mut self, _tenant_id: &str) {}
}
impl ::autumn_web::tenancy::ModelTenantIdMeta for #name {
const HAS_MANUAL_TENANT_ID: bool = false;
fn try_set_tenant_id(&mut self, _tenant_id: &str) {}
}
}
},
|f| {
let is_option = is_option_type(&f.ty);
let set_field = if is_option {
quote! { self.tenant_id = ::core::option::Option::Some(tenant_id.to_string()); }
} else {
quote! { self.tenant_id = tenant_id.to_string(); }
};
let new_set_field = if new_has_tenant_id {
set_field.clone()
} else {
quote! {}
};
quote! {
impl ::autumn_web::tenancy::ModelTenantIdMeta for #new_name {
const HAS_MANUAL_TENANT_ID: bool = #new_has_tenant_id;
fn try_set_tenant_id(&mut self, tenant_id: &str) {
#new_set_field
}
}
impl ::autumn_web::tenancy::ModelTenantIdMeta for #name {
const HAS_MANUAL_TENANT_ID: bool = true;
fn try_set_tenant_id(&mut self, tenant_id: &str) {
#set_field
}
}
}
},
);
let mut upsert_columns: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
quote! {
#table_ident::#ident.eq(::autumn_web::reexports::diesel::upsert::excluded(#table_ident::#ident))
}
})
.collect();
if upsert_columns.is_empty() {
upsert_columns.push(quote! {
#table_ident::id.eq(::autumn_web::reexports::diesel::pg::upsert::excluded(#table_ident::id))
});
}
if let Some(lv_field) = lock_version_field {
let ident = lv_field.ident.as_ref().unwrap();
upsert_columns.push(quote! {
#table_ident::#ident.eq(#table_ident::#ident + 1)
});
}
let mut upsert_types: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
quote! {
::autumn_web::reexports::diesel::dsl::Eq<
#table_ident::#ident,
::autumn_web::reexports::diesel::upsert::Excluded<#table_ident::#ident>
>
}
})
.collect();
if upsert_types.is_empty() {
upsert_types.push(quote! {
::autumn_web::reexports::diesel::dsl::Eq<
#table_ident::id,
::autumn_web::reexports::diesel::upsert::Excluded<#table_ident::id>
>
});
}
if let Some(lv_field) = lock_version_field {
let ident = lv_field.ident.as_ref().unwrap();
let ty = &lv_field.ty;
upsert_types.push(quote! {
::autumn_web::reexports::diesel::dsl::Eq<
#table_ident::#ident,
::autumn_web::reexports::diesel::helper_types::Add<
#table_ident::#ident,
::autumn_web::reexports::diesel::expression::bound::Bound<
<#table_ident::#ident as ::autumn_web::reexports::diesel::Expression>::SqlType,
#ty
>
>
>
});
}
let has_tenant_id = tenant_id_field.is_some();
let execute_upsert_body = if has_tenant_id {
lock_version_field.map_or_else(
|| quote! {
if let ::core::option::Option::Some(t) = tenant_id {
let stmt = ::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, #table_ident::tenant_id.eq(t.to_string()));
stmt.get_results::<Self>(conn).await
} else {
stmt.get_results::<Self>(conn).await
}
},
|lv_field| {
let lv_ident = lv_field.ident.as_ref().unwrap();
quote! {
let lv_cond = #table_ident::#lv_ident.eq(::autumn_web::reexports::diesel::pg::upsert::excluded(#table_ident::#lv_ident));
if let ::core::option::Option::Some(t) = tenant_id {
let stmt = ::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, lv_cond.and(#table_ident::tenant_id.eq(t.to_string())));
stmt.get_results::<Self>(conn).await
} else {
let stmt = ::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, lv_cond);
stmt.get_results::<Self>(conn).await
}
}
},
)
} else {
lock_version_field.map_or_else(
|| quote! {
stmt.get_results::<Self>(conn).await
},
|lv_field| {
let lv_ident = lv_field.ident.as_ref().unwrap();
quote! {
let lv_cond = #table_ident::#lv_ident.eq(::autumn_web::reexports::diesel::pg::upsert::excluded(#table_ident::#lv_ident));
let stmt = ::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, lv_cond);
stmt.get_results::<Self>(conn).await
}
},
)
};
let execute_upsert_body_sqlite = {
let build_stmt = quote! {
let stmt = ::autumn_web::reexports::diesel::insert_into(#table_ident::table)
.values(__autumn_row)
.on_conflict(#table_ident::id)
.do_update()
.set(Self::__autumn_upsert_set());
};
if has_tenant_id {
lock_version_field.map_or_else(
|| quote! {
#build_stmt
let __autumn_r = if let ::core::option::Option::Some(t) = tenant_id {
::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, #table_ident::tenant_id.eq(t.to_string()))
.get_result::<Self>(conn)
.await
.optional()?
} else {
stmt.get_result::<Self>(conn).await.optional()?
};
if let ::core::option::Option::Some(__autumn_r) = __autumn_r {
__autumn_upserted.push(__autumn_r);
}
},
|lv_field| {
let lv_ident = lv_field.ident.as_ref().unwrap();
quote! {
#build_stmt
let lv_cond = #table_ident::#lv_ident.eq(::autumn_web::reexports::diesel::upsert::excluded(#table_ident::#lv_ident));
let __autumn_r = if let ::core::option::Option::Some(t) = tenant_id {
::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, lv_cond.and(#table_ident::tenant_id.eq(t.to_string())))
.get_result::<Self>(conn)
.await
.optional()?
} else {
::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, lv_cond)
.get_result::<Self>(conn)
.await
.optional()?
};
if let ::core::option::Option::Some(__autumn_r) = __autumn_r {
__autumn_upserted.push(__autumn_r);
}
}
},
)
} else {
lock_version_field.map_or_else(
|| quote! {
#build_stmt
let __autumn_r = stmt.get_result::<Self>(conn).await.optional()?;
if let ::core::option::Option::Some(__autumn_r) = __autumn_r {
__autumn_upserted.push(__autumn_r);
}
},
|lv_field| {
let lv_ident = lv_field.ident.as_ref().unwrap();
quote! {
#build_stmt
let lv_cond = #table_ident::#lv_ident.eq(::autumn_web::reexports::diesel::upsert::excluded(#table_ident::#lv_ident));
let __autumn_r = ::autumn_web::reexports::diesel::query_dsl::methods::FilterDsl::filter(stmt, lv_cond)
.get_result::<Self>(conn)
.await
.optional()?;
if let ::core::option::Option::Some(__autumn_r) = __autumn_r {
__autumn_upserted.push(__autumn_r);
}
}
},
)
}
};
let compare_fields = fields_for_new.iter().map(|f| {
let ident = &f.ident;
quote! { input.#ident == record.#ident }
});
let compare_expr = if fields_for_new.is_empty() {
quote! { true }
} else {
quote! { #(#compare_fields)&&* }
};
let mut changeset_fields: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = &f.ident;
let ty = &f.ty;
let enc = encrypted_wrapper_path(
parse_field_encrypted_mode(f).unwrap_or(EncryptedMode::None),
)
.map(|w| quote! { #[diesel(serialize_as = #w)] });
quote! { #enc pub #ident: Option<#ty> }
})
.collect();
if let Some(lv_field) = lock_version_field {
let ident = &lv_field.ident;
let ty = &lv_field.ty;
changeset_fields.push(quote! { pub #ident: Option<#ty> });
}
let mut changeset_conversions: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
let is_option = is_option_type(&f.ty);
if is_option {
quote! {
#ident: match &self.#ident {
::autumn_web::hooks::Patch::Set(v) => Some(v.clone()),
::autumn_web::hooks::Patch::Clear => Some(None),
::autumn_web::hooks::Patch::Unchanged => None,
}
}
} else {
quote! {
#ident: match &self.#ident {
::autumn_web::hooks::Patch::Set(v) => Some(v.clone()),
::autumn_web::hooks::Patch::Clear => {
panic!("Cannot clear non-nullable field `{}`", stringify!(#ident));
}
::autumn_web::hooks::Patch::Unchanged => None,
}
}
}
})
.collect();
if let Some(lv_field) = lock_version_field {
let ident = lv_field.ident.as_ref().unwrap();
changeset_conversions.push(quote! {
#ident: Some(self.#ident.wrapping_add(1))
});
}
let factory_name = format_ident!("{name}Factory");
let (pk_id, pk_ty): (&syn::Ident, &syn::Type) = pk_field_for_factory.unwrap_or_else(|| {
panic!("#[model]: could not detect primary-key field for factory generation")
});
let model_primary_key_impl = quote! {
impl ::autumn_web::repository::ModelPrimaryKey for #name {
type IdType = #pk_ty;
fn primary_key_value(&self) -> Self::IdType {
::core::clone::Clone::clone(&self.#pk_id)
}
}
};
let has_assoc_fields = fields_for_new
.iter()
.any(|f| factory_assoc_type(f).is_some());
let factory_struct_fields: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = &f.ident;
let ty = &f.ty;
if factory_assoc_type(f).is_some() {
quote! { pub #ident: ::core::option::Option<#ty> }
} else {
quote! { pub #ident: #ty }
}
})
.collect();
let factory_default_fields: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = &f.ident;
if factory_assoc_type(f).is_some() {
quote! { #ident: ::core::option::Option::None }
} else {
quote! { #ident: ::core::default::Default::default() }
}
})
.collect();
let factory_setters: Vec<TokenStream> = fields_for_new
.iter()
.flat_map(|f| {
let ident = f.ident.as_ref().unwrap();
let ty = &f.ty;
let field_lit = ident.to_string();
factory_assoc_type(f).map_or_else(
|| {
vec![quote! {
#[must_use]
pub fn #ident(mut self, val: impl ::core::convert::Into<#ty>) -> Self {
self.#ident = val.into();
self.__autumn_set.insert(#field_lit);
self
}
}]
},
|assoc_type| {
let explicit_setter = quote! {
#[must_use]
pub fn #ident(mut self, val: impl ::core::convert::Into<#ty>) -> Self {
self.#ident = ::core::option::Option::Some(val.into());
self.__autumn_set.insert(#field_lit);
self
}
};
let field_str = ident.to_string();
let assoc_snake = if field_str.ends_with("_id") {
format_ident!("{}", &field_str[..field_str.len() - 3])
} else {
format_ident!("{}_assoc", field_str)
};
let pre_built_setter = quote! {
#[must_use]
pub fn #assoc_snake(mut self, val: &#assoc_type) -> Self {
self.#ident = ::core::option::Option::Some(val.__autumn_pk());
self.__autumn_set.insert(#field_lit);
self
}
};
vec![explicit_setter, pre_built_setter]
},
)
})
.collect();
let factory_value_bindings: Vec<TokenStream> = fields_for_new
.iter()
.filter(|f| factory_assoc_type(f).is_none())
.map(|f| {
let ident = f.ident.as_ref().unwrap();
let field_lit = ident.to_string();
fake_expr_for_field(ident, &f.ty).map_or_else(
|| {
quote! {
let #ident = ::core::clone::Clone::clone(&self.#ident);
}
},
|fake_expr| {
quote! {
let #ident = if self.__autumn_fake
&& !self.__autumn_set.contains(#field_lit)
{
#fake_expr
} else {
::core::clone::Clone::clone(&self.#ident)
};
}
},
)
})
.collect();
let build_assoc_bindings: Vec<TokenStream> = fields_for_new
.iter()
.filter_map(|f| {
factory_assoc_type(f)?;
let ident = f.ident.as_ref().unwrap();
Some(quote! {
let #ident = self.#ident.unwrap_or_default();
})
})
.collect();
let create_assoc_bindings: Vec<TokenStream> = fields_for_new
.iter()
.filter_map(|f| {
let assoc_type = factory_assoc_type(f)?;
let ident = f.ident.as_ref().unwrap();
Some(quote! {
let #ident = match self.#ident {
::core::option::Option::Some(id) => id,
::core::option::Option::None => {
#assoc_type::factory().create(pool).await.__autumn_pk()
}
};
})
})
.collect();
let new_construct_fields: Vec<TokenStream> = fields_for_new
.iter()
.map(|f| {
let ident = f.ident.as_ref().unwrap();
quote! { #ident }
})
.collect();
let create_inner_body = quote! {
use ::autumn_web::reexports::diesel::prelude::*;
use ::autumn_web::reexports::diesel_async::RunQueryDsl;
#(#factory_value_bindings)*
#(#create_assoc_bindings)*
let new_record = #new_name {
#(#new_construct_fields,)*
};
let mut conn = pool
.get()
.await
.expect("factory: failed to acquire db connection");
::autumn_web::reexports::diesel::insert_into(#table_ident::table)
.values(new_record)
.returning(#name::as_returning())
.get_result(&mut *conn)
.await
.expect("factory: insert failed")
};
let factory_create_method = if has_assoc_fields {
quote! {
pub async fn create(
self,
pool: &::autumn_web::reexports::diesel_async::pooled_connection::deadpool::Pool<
::autumn_web::RuntimeConnection,
>,
) -> #name {
let __depth = ::autumn_web::__private::FACTORY_DEPTH
.try_with(|d| d + 1)
.unwrap_or(1_u32);
assert!(
__depth <= 32,
"factory `{}`: cyclic #[factory_assoc] chain exceeds depth 32 — \
break the cycle by supplying a pre-built instance via a pre-built setter.",
stringify!(#name),
);
::autumn_web::__private::FACTORY_DEPTH
.scope(__depth, async move { #create_inner_body })
.await
}
}
} else {
quote! {
pub async fn create(
self,
pool: &::autumn_web::reexports::diesel_async::pooled_connection::deadpool::Pool<
::autumn_web::RuntimeConnection,
>,
) -> #name {
#create_inner_body
}
}
};
let factory_create_many_method = quote! {
pub async fn create_many(
self,
count: usize,
pool: &::autumn_web::reexports::diesel_async::pooled_connection::deadpool::Pool<
::autumn_web::RuntimeConnection,
>,
) -> ::std::vec::Vec<#name> {
let mut out = ::std::vec::Vec::with_capacity(count);
for _ in 0..count {
out.push(::core::clone::Clone::clone(&self).create(pool).await);
}
out
}
};
let lock_version_actual_body: TokenStream = lock_version_field.map_or_else(
|| quote! { ::core::option::Option::None },
|lv_field| {
let ident = lv_field.ident.as_ref().unwrap();
quote! { ::core::option::Option::Some(self.#ident as i64) }
},
);
let lock_version_expected_body: TokenStream = lock_version_field.map_or_else(
|| quote! { ::core::option::Option::None },
|lv_field| {
let ident = lv_field.ident.as_ref().unwrap();
quote! { ::core::option::Option::Some(self.#ident as i64) }
},
);
let etag_method: TokenStream = lock_version_field.map_or_else(
|| quote! {},
|lv_field| {
let ident = lv_field.ident.as_ref().unwrap();
quote! {
#[inline]
pub fn etag(&self) -> ::autumn_web::etag::ETag {
::autumn_web::etag::IntoETag::into_etag(self.#ident as i64)
}
}
},
);
let schema_rename_all_rule = serde_rename_all_serialize_rule(outer_attrs);
let schema_rename_all_rule = schema_rename_all_rule.as_deref();
let all_field_refs: Vec<&&Field> = all_fields.iter().collect();
let query_struct_schema_body =
emit_schema_fn_body(&all_field_refs, false, schema_rename_all_rule);
let new_struct_schema_body =
emit_schema_fn_body(&fields_for_new, false, schema_rename_all_rule);
let update_struct_schema_body = {
let extra: &[&&Field] = lock_version_field.as_slice();
emit_schema_fn_body_ext(&fields_for_new, true, extra, schema_rename_all_rule)
};
let commit_hook_serialize_fields: Vec<TokenStream> = all_fields
.iter()
.map(|f| {
let ident = f.ident.as_ref().expect("named field");
let ty = &f.ty;
let field_name = LitStr::new(&ident.to_string(), ident.span());
let field_value = if has_hook_serde_adapter(f, SerdeAdapterMode::Serialize) {
let serde_attrs = hook_serde_adapter_attrs(f, SerdeAdapterMode::Serialize);
quote! {
{
#[derive(::serde::Serialize)]
struct __AutumnCommitHookSerializeField {
#(#serde_attrs)*
value: #ty,
}
let __autumn_field = __AutumnCommitHookSerializeField {
value: self.#ident.clone(),
};
let __autumn_field_value =
::autumn_web::reexports::serde_json::to_value(&__autumn_field)
.map_err(|__error| {
::autumn_web::AutumnError::internal_server_error_msg(format!(
"serialize repository commit hook record field {}.{}: {}",
stringify!(#name),
#field_name,
__error
))
})?;
match __autumn_field_value {
::autumn_web::reexports::serde_json::Value::Object(mut __autumn_field_object) => {
__autumn_field_object.remove("value").ok_or_else(|| {
::autumn_web::AutumnError::internal_server_error_msg(format!(
"serialize repository commit hook record field {}.{}: missing adapter output",
stringify!(#name),
#field_name
))
})?
}
__autumn_other => {
return Err(::autumn_web::AutumnError::internal_server_error_msg(format!(
"serialize repository commit hook record field {}.{}: expected adapter object, got {}",
stringify!(#name),
#field_name,
__autumn_other
)));
}
}
}
}
} else {
quote! {
::autumn_web::reexports::serde_json::to_value(&self.#ident)
.map_err(|__error| {
::autumn_web::AutumnError::internal_server_error_msg(format!(
"serialize repository commit hook record field {}.{}: {}",
stringify!(#name),
#field_name,
__error
))
})?
}
};
quote! {
__autumn_object.insert(
::std::string::String::from(#field_name),
#field_value
);
}
})
.collect();
let commit_hook_deserialize_fields: Vec<TokenStream> = all_fields
.iter()
.map(|f| {
let ident = f.ident.as_ref().expect("named field");
let ty = &f.ty;
let field_name = LitStr::new(&ident.to_string(), ident.span());
let missing_default = commit_hook_missing_field_default_expr(f);
let field_value = if has_hook_serde_adapter(f, SerdeAdapterMode::Deserialize) {
let serde_attrs = hook_serde_adapter_attrs(f, SerdeAdapterMode::Deserialize);
quote! {
{
#[derive(::serde::Deserialize)]
struct __AutumnCommitHookDeserializeField {
#(#serde_attrs)*
value: #ty,
}
let mut __autumn_wrapper_object =
::autumn_web::reexports::serde_json::Map::new();
__autumn_wrapper_object.insert(
::std::string::String::from("value"),
__autumn_field,
);
let __autumn_wrapper: __AutumnCommitHookDeserializeField =
::autumn_web::reexports::serde_json::from_value(
::autumn_web::reexports::serde_json::Value::Object(
__autumn_wrapper_object,
),
)
.map_err(|__error| {
::autumn_web::AutumnError::internal_server_error_msg(format!(
"deserialize repository commit hook record field {}.{}: {}",
stringify!(#name),
#field_name,
__error
))
})?;
__autumn_wrapper.value
}
}
} else {
quote! {
::autumn_web::reexports::serde_json::from_value(__autumn_field)
.map_err(|__error| {
::autumn_web::AutumnError::internal_server_error_msg(format!(
"deserialize repository commit hook record field {}.{}: {}",
stringify!(#name),
#field_name,
__error
))
})?
}
};
missing_default.map_or_else(
|| {
quote! {
let #ident: #ty = {
let __autumn_field = __autumn_object.remove(#field_name)
.ok_or_else(|| {
::autumn_web::AutumnError::internal_server_error_msg(format!(
"deserialize repository commit hook record field {}.{}: missing field",
stringify!(#name),
#field_name
))
})?;
#field_value
};
}
},
|missing_default| {
quote! {
let #ident: #ty = match __autumn_object.remove(#field_name) {
::core::option::Option::Some(__autumn_field) => {
#field_value
}
::core::option::Option::None => {
#missing_default
}
};
}
},
)
})
.collect();
let commit_hook_construct_fields: Vec<TokenStream> = all_fields
.iter()
.map(|f| {
let ident = f.ident.as_ref().expect("named field");
quote! { #ident: #ident }
})
.collect();
let commit_hook_serialize_bounds: Vec<TokenStream> = all_fields
.iter()
.filter(|f| !has_hook_serde_adapter(f, SerdeAdapterMode::Serialize))
.map(|f| {
let ty = &f.ty;
quote! { #ty: ::serde::Serialize }
})
.collect();
let mut commit_hook_deserialize_bounds: Vec<TokenStream> = all_fields
.iter()
.filter(|f| !has_hook_serde_adapter(f, SerdeAdapterMode::Deserialize))
.map(|f| {
let ty = &f.ty;
quote! { #ty: ::serde::de::DeserializeOwned }
})
.collect();
commit_hook_deserialize_bounds.extend(
all_fields
.iter()
.filter(|f| !is_option_type(&f.ty))
.filter(|f| matches!(serde_default_kind(f), Some(SerdeDefaultKind::Default)))
.map(|f| {
let ty = &f.ty;
quote! { #ty: ::core::default::Default }
}),
);
let commit_hook_serialize_where = if commit_hook_serialize_bounds.is_empty() {
quote! {}
} else {
quote! { where #(#commit_hook_serialize_bounds,)* }
};
let commit_hook_deserialize_where = if commit_hook_deserialize_bounds.is_empty() {
quote! {}
} else {
quote! { where #(#commit_hook_deserialize_bounds,)* }
};
let form_model_impl = emit_form_model_impl(
name,
&fields_for_new,
serde_rename_all_serialize_rule(outer_attrs).as_deref(),
);
let names_for_new: std::collections::HashSet<String> = fields_for_new
.iter()
.filter_map(|f| f.ident.as_ref().map(std::string::ToString::to_string))
.collect();
let normalize_new_stmts: Vec<TokenStream> = normalized_columns
.iter()
.filter(|(ident, _, _)| names_for_new.contains(&ident.to_string()))
.map(|(ident, _, ops)| {
let expr = emit_normalize_expr(ops, "e! { ::core::mem::take(&mut self.#ident) });
quote! { self.#ident = #expr; }
})
.collect();
let normalize_model_stmts: Vec<TokenStream> = normalized_columns
.iter()
.map(|(ident, _, ops)| {
let expr = emit_normalize_expr(ops, "e! { ::core::mem::take(&mut self.#ident) });
quote! { self.#ident = #expr; }
})
.collect();
let normalize_draft_stmts: Vec<TokenStream> = normalized_columns
.iter()
.map(|(ident, _, ops)| {
let expr = emit_normalize_expr(ops, "e! { ::core::mem::take(&mut after.#ident) });
quote! { after.#ident = #expr; }
})
.collect();
let normalize_lookup_arms: Vec<TokenStream> = normalized_columns
.iter()
.map(|(_, lookup_key, ops)| {
let expr = emit_normalize_expr(ops, "e! { value.to_owned() });
quote! { #lookup_key => ::core::option::Option::Some(#expr), }
})
.collect();
let normalize_impls = quote! {
impl ::autumn_web::normalize::Normalize for #new_name {
fn normalize(&mut self) {
#(#normalize_new_stmts)*
}
}
impl ::autumn_web::normalize::Normalize for #name {
fn normalize(&mut self) {
#(#normalize_model_stmts)*
}
}
impl ::autumn_web::normalize::NormalizedModel for #name {
fn normalize_lookup(
column: &str,
value: &str,
) -> ::core::option::Option<::std::string::String> {
match column {
#(#normalize_lookup_arms)*
_ => ::core::option::Option::None,
}
}
}
};
let list_boxed_ty = quote! {
::autumn_web::reexports::diesel::helper_types::IntoBoxed<
'__lq,
#table_ident::table,
::autumn_web::RuntimeBackend,
>
};
let list_pk: Option<&syn::Ident> = if id_field_names.len() == 1 {
Some(id_field_names[0])
} else {
None
};
let mut list_sort_arms: Vec<TokenStream> = Vec::new();
let mut list_filter_arms: Vec<TokenStream> = Vec::new();
for field in &all_fields {
let Some(ident) = field.ident.as_ref() else {
continue;
};
let raw = ident.to_string();
let col = raw.strip_prefix("r#").unwrap_or(&raw).to_string();
let is_option = option_inner_type(&field.ty).is_some();
let base_ty = option_inner_type(&field.ty).unwrap_or(&field.ty);
let Some(last) = ty_last_ident(base_ty) else {
continue;
};
let orderable = matches!(
last.as_str(),
"String"
| "i16"
| "i32"
| "i64"
| "bool"
| "f32"
| "f64"
| "Decimal"
| "Uuid"
| "NaiveDateTime"
| "NaiveDate"
| "NaiveTime"
| "DateTime"
);
if orderable {
let is_pk = list_pk.is_some_and(|pk| pk == ident);
let tie_break = match (is_pk, list_pk) {
(true, _) | (_, None) => quote! {},
(false, Some(pk)) => quote! { .then_order_by(#table_ident::#pk.desc()) },
};
list_sort_arms.push(quote! {
::core::option::Option::Some(#col) => match __dir {
::autumn_web::pagination::SortDir::Asc =>
__q.order(#table_ident::#ident.asc()) #tie_break,
::autumn_web::pagination::SortDir::Desc =>
__q.order(#table_ident::#ident.desc()) #tie_break,
},
});
}
if !is_option {
match last.as_str() {
"String" => list_filter_arms.push(quote! {
#col => { __q = __q.filter(#table_ident::#ident.eq(__val.to_owned())); }
}),
"i16" | "i32" | "i64" | "bool" => {
let parse_ty = format_ident!("{last}");
list_filter_arms.push(quote! {
#col => {
if let ::core::result::Result::Ok(__v) = __val.parse::<#parse_ty>() {
__q = __q.filter(#table_ident::#ident.eq(__v));
}
}
});
}
_ => {}
}
}
}
let list_default_order = list_pk.map_or_else(
|| quote! { __q },
|pk| quote! { __q.order(#table_ident::#pk.desc()) },
);
let list_filter_body = if list_filter_arms.is_empty() {
quote! { let _ = &__query; __q }
} else {
quote! {
use ::autumn_web::reexports::diesel::prelude::*;
for (__col, __val) in __query.filters() {
match __col {
#(#list_filter_arms)*
_ => {}
}
}
__q
}
};
let list_order_body = if list_sort_arms.is_empty() {
quote! {
use ::autumn_web::reexports::diesel::prelude::*;
let _ = __query;
#list_default_order
}
} else {
quote! {
use ::autumn_web::reexports::diesel::prelude::*;
let __dir = __query.direction();
match __query.sort() {
#(#list_sort_arms)*
_ => #list_default_order,
}
}
};
let list_query_helpers = quote! {
impl #name {
#[doc(hidden)]
#[allow(unused_mut, clippy::allow_attributes)]
pub fn __autumn_list_apply_filters<'__lq>(
mut __q: #list_boxed_ty,
__query: &::autumn_web::pagination::ListQuery,
) -> #list_boxed_ty {
#list_filter_body
}
#[doc(hidden)]
#[allow(clippy::allow_attributes)]
pub fn __autumn_list_apply_order<'__lq>(
__q: #list_boxed_ty,
__query: &::autumn_web::pagination::ListQuery,
) -> #list_boxed_ty {
#list_order_body
}
}
};
quote! {
#encrypted_use
#list_query_helpers
#[derive(#name_debug_derive Clone, ::diesel::Queryable, ::diesel::Selectable, ::diesel::AsChangeset, ::diesel::Insertable)]
#[derive(::serde::Serialize, ::serde::Deserialize)]
#validate_derive
#[diesel(table_name = #table_ident)]
#(#filtered_outer_attrs)*
#vis struct #name {
#(#query_fields,)*
}
#name_debug_impl
#form_model_impl
#normalize_impls
#[derive(#new_debug_derive Clone, ::diesel::Insertable)]
#[derive(::serde::Serialize, ::serde::Deserialize)]
#validate_derive
#[diesel(table_name = #table_ident)]
#vis struct #new_name {
#(#new_fields,)*
}
#new_debug_impl
#[derive(#update_debug_derive Clone, Default)]
#[derive(::serde::Serialize, ::serde::Deserialize)]
#validate_derive
#vis struct #update_name {
#(#update_fields,)*
}
#update_debug_impl
#[doc(hidden)]
#[derive(#changeset_debug_derive Clone, ::diesel::AsChangeset)]
#[diesel(table_name = #table_ident)]
pub struct #changeset_name {
#(#changeset_fields,)*
}
#changeset_debug_impl
impl #name {
#[doc(hidden)]
pub const __AUTUMN_ENCRYPTED_COLUMNS: &'static [&'static str] =
&[#(#encrypted_column_names),*];
#[doc(hidden)]
pub const __AUTUMN_TRANSLATABLE_COLUMNS: &'static [&'static str] =
&[#(#translatable_column_names),*];
}
#(#encrypted_inventory)*
#translatable_items
#(#translatable_inventory)*
impl #update_name {
#[doc(hidden)]
#[must_use]
pub fn __to_changeset(&self) -> #changeset_name {
#changeset_name {
#(#changeset_conversions,)*
}
}
}
impl #name {
pub const __AUTUMN_COLUMN_COUNT: usize = #column_count;
#[doc(hidden)]
pub fn __autumn_column_count(&self) -> usize {
Self::__AUTUMN_COLUMN_COUNT
}
#[doc(hidden)]
pub fn __autumn_upsert_set() -> impl ::autumn_web::reexports::diesel::query_builder::AsChangeset<
Target = #table_ident::table,
Changeset = impl ::autumn_web::reexports::diesel::query_builder::QueryFragment<::autumn_web::RuntimeBackend> + ::core::marker::Send + ::core::marker::Sync + 'static
> + ::core::marker::Send + ::core::marker::Sync + 'static {
use ::autumn_web::reexports::diesel::ExpressionMethods as _;
(#(#upsert_columns,)*)
}
#[doc(hidden)]
pub async fn __autumn_execute_upsert(
chunk: &[Self],
tenant_id: ::core::option::Option<&str>,
conn: &mut ::autumn_web::RuntimeConnection,
) -> ::core::result::Result<::std::vec::Vec<Self>, ::autumn_web::reexports::diesel::result::Error> {
use ::autumn_web::reexports::diesel::prelude::*;
use ::autumn_web::reexports::diesel_async::RunQueryDsl;
::autumn_web::backend_select! {
pg => {
let stmt = ::autumn_web::reexports::diesel::insert_into(#table_ident::table)
.values(chunk.to_vec())
.on_conflict(#table_ident::id)
.do_update()
.set(Self::__autumn_upsert_set());
#execute_upsert_body
},
sqlite => {
let mut __autumn_upserted = ::std::vec::Vec::new();
for __autumn_row in chunk.to_vec() {
#execute_upsert_body_sqlite
}
::core::result::Result::Ok(__autumn_upserted)
},
}
}
#[doc(hidden)]
pub fn __autumn_correlate_new(
inputs: &[#new_name],
record: &Self,
matched: &mut [bool],
) -> ::core::option::Option<usize> {
for (i, input) in inputs.iter().enumerate() {
if !matched[i] {
if #compare_expr {
return ::core::option::Option::Some(i);
}
}
}
::core::option::Option::None
}
#[doc(hidden)]
pub fn __autumn_correlate_model(
inputs: &[Self],
record: &Self,
matched: &mut [bool],
) -> ::core::option::Option<usize> {
for (i, input) in inputs.iter().enumerate() {
if !matched[i] {
if #compare_expr {
return ::core::option::Option::Some(i);
}
}
}
::core::option::Option::None
}
}
impl ::autumn_web::repository::AutumnUpsertSetExt for #name {
type UpsertSet = ::autumn_web::reexports::diesel::dsl::Eq<
#table_ident::id,
#table_ident::id,
>;
fn __autumn_upsert_set() -> Self::UpsertSet {
use ::autumn_web::reexports::diesel::ExpressionMethods as _;
#table_ident::id.eq(#table_ident::id)
}
}
impl ::autumn_web::repository::AutumnUpsertExecutionExt for #name {
type Model = Self;
async fn __autumn_execute_upsert(
chunk: &[Self::Model],
tenant_id: ::core::option::Option<&str>,
conn: &mut ::autumn_web::RuntimeConnection,
) -> ::core::result::Result<::std::vec::Vec<Self::Model>, ::autumn_web::reexports::diesel::result::Error> {
Self::__autumn_execute_upsert(chunk, tenant_id, conn).await
}
}
impl ::autumn_web::repository::AutumnCorrelateExt for #name {
type NewModel = #new_name;
fn __autumn_correlate_new(
inputs: &[Self::NewModel],
record: &Self,
matched: &mut [bool],
) -> ::core::option::Option<usize> {
Self::__autumn_correlate_new(inputs, record, matched)
}
fn __autumn_correlate_model(
inputs: &[Self],
record: &Self,
matched: &mut [bool],
) -> ::core::option::Option<usize> {
Self::__autumn_correlate_model(inputs, record, matched)
}
}
impl #new_name {
pub const __AUTUMN_COLUMN_COUNT: usize = #new_column_count;
#[doc(hidden)]
pub fn __autumn_column_count(&self) -> usize {
Self::__AUTUMN_COLUMN_COUNT
}
}
#can_set_tenant_id_impl
#model_tenant_id_meta_impl
#model_primary_key_impl
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#vis enum #field_enum_name {
#(#field_enum_variants,)*
}
#vis trait #draft_ext_name {
fn from_patch(current: &#name, patch: &#update_name) -> ::autumn_web::AutumnResult<::autumn_web::hooks::UpdateDraft<#name>>;
#(#draft_accessor_sigs)*
}
impl #draft_ext_name for ::autumn_web::hooks::UpdateDraft<#name> {
fn from_patch(current: &#name, patch: &#update_name) -> ::autumn_web::AutumnResult<Self> {
let mut after = current.clone();
#(#merge_arms)*
#(#normalize_draft_stmts)*
#merged_validate_stmt
Ok(Self::new_with_changes(current.clone(), after))
}
#(#draft_accessors)*
}
#[derive(Debug, Clone)]
#vis struct #factory_name {
#(#factory_struct_fields,)*
#[doc(hidden)]
pub __autumn_set: ::std::collections::HashSet<&'static str>,
#[doc(hidden)]
pub __autumn_fake: bool,
}
impl ::core::default::Default for #factory_name {
fn default() -> Self {
Self {
#(#factory_default_fields,)*
__autumn_set: ::std::collections::HashSet::new(),
__autumn_fake: false,
}
}
}
impl #factory_name {
#(#factory_setters)*
#[must_use]
pub fn fake(mut self) -> Self {
self.__autumn_fake = true;
self
}
#[must_use]
pub fn fake_all(self) -> Self {
self.fake()
}
#[must_use]
pub fn build(self) -> #new_name {
#(#factory_value_bindings)*
#(#build_assoc_bindings)*
#new_name {
#(#new_construct_fields,)*
}
}
#[must_use]
pub fn build_many(self, count: usize) -> ::std::vec::Vec<#new_name> {
let mut out = ::std::vec::Vec::with_capacity(count);
for _ in 0..count {
out.push(::core::clone::Clone::clone(&self).build());
}
out
}
#factory_create_method
#factory_create_many_method
}
impl #name {
#[must_use]
pub fn factory() -> #factory_name {
#factory_name::default()
}
#[doc(hidden)]
#[inline]
pub fn __autumn_pk(&self) -> #pk_ty {
self.#pk_id.clone()
}
}
::autumn_web::__autumn_register_fake_seeder!(#name, stringify!(#name));
impl #name {
#[doc(hidden)]
pub fn __autumn_commit_hook_to_value(
&self,
) -> ::autumn_web::AutumnResult<::autumn_web::reexports::serde_json::Value>
#commit_hook_serialize_where
{
let mut __autumn_object = ::autumn_web::reexports::serde_json::Map::new();
#(#commit_hook_serialize_fields)*
let mut __autumn_value =
::autumn_web::reexports::serde_json::Value::Object(__autumn_object);
#commit_hook_encrypt_stmt
Ok(__autumn_value)
}
#[doc(hidden)]
pub fn __autumn_commit_hook_from_value(
__autumn_value: ::autumn_web::reexports::serde_json::Value,
) -> ::autumn_web::AutumnResult<Self>
#commit_hook_deserialize_where
{
let mut __autumn_decoded_value = __autumn_value;
#commit_hook_decrypt_stmt
let mut __autumn_object = match __autumn_decoded_value {
::autumn_web::reexports::serde_json::Value::Object(__autumn_object) => __autumn_object,
__autumn_other => {
return Err(::autumn_web::AutumnError::internal_server_error_msg(format!(
"deserialize repository commit hook record for {}: expected object, got {}",
stringify!(#name),
__autumn_other
)));
}
};
#(#commit_hook_deserialize_fields)*
Ok(Self {
#(#commit_hook_construct_fields,)*
})
}
}
impl #name {
#[doc(hidden)]
#[inline]
pub fn __autumn_lock_version_actual(&self) -> ::core::option::Option<i64> {
#lock_version_actual_body
}
#etag_method
}
impl #update_name {
#[doc(hidden)]
#[inline]
pub fn __autumn_lock_version_expected(&self) -> ::core::option::Option<i64> {
#lock_version_expected_body
}
}
impl ::autumn_web::openapi::OpenApiSchema for #name {
fn schema_name() -> &'static str { stringify!(#name) }
fn schema() -> ::serde_json::Value {
#query_struct_schema_body
}
}
impl ::autumn_web::openapi::OpenApiSchema for #new_name {
fn schema_name() -> &'static str { stringify!(#new_name) }
fn schema() -> ::serde_json::Value {
#new_struct_schema_body
}
}
impl ::autumn_web::openapi::OpenApiSchema for #update_name {
fn schema_name() -> &'static str { stringify!(#update_name) }
fn schema() -> ::serde_json::Value {
#update_struct_schema_body
}
}
impl ::autumn_web::repository::AutumnSearchableModel for #name {
const IS_SEARCHABLE: bool = #is_searchable;
const SEARCH_LANGUAGE: &'static str = #search_language;
const SEARCH_FIELDS: &'static [(&'static str, char)] = &[
#((#search_field_names, #search_field_weights)),*
];
}
#search_indexed_impl
#(#state_machine_impls)*
#preload_retain_impl
#association_items
#dependents_impl
#counter_caches_impl
#votable_items
#commentable_items
}
}
pub fn infer_table_name(ident: &syn::Ident) -> String {
let name = ident.to_string();
let snake = pascal_to_snake(&name);
let (prefix, last) = snake.rfind('_').map_or(("", snake.as_str()), |idx| {
(&snake[..=idx], &snake[idx + 1..])
});
format!("{prefix}{}", pluralize_word(last))
}
fn pluralize_word(word: &str) -> String {
if word.is_empty() {
return String::new();
}
match word {
"person" => return "people".to_owned(),
"child" => return "children".to_owned(),
"man" => return "men".to_owned(),
"woman" => return "women".to_owned(),
"mouse" => return "mice".to_owned(),
"goose" => return "geese".to_owned(),
_ => {}
}
let lower = word.to_ascii_lowercase();
if lower.ends_with("ss")
|| lower.ends_with('x')
|| lower.ends_with('z')
|| lower.ends_with("ch")
|| lower.ends_with("sh")
{
return format!("{word}es");
}
if lower.ends_with('y') {
let prefix = &word[..word.len() - 1];
if let Some(prev) = prefix.chars().next_back()
&& !"aeiouAEIOU".contains(prev)
{
return format!("{prefix}ies");
}
}
format!("{word}s")
}
pub fn pascal_to_snake(s: &str) -> String {
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('_');
}
result.push(ch.to_ascii_lowercase());
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fake_int_ranges_never_truncate_the_cast() {
let expr = |ty: syn::Type| {
fake_expr_core("count", &ty)
.expect("integer type should infer a fake expr")
.to_string()
};
let i8_expr = expr(syn::parse_quote!(i8));
assert!(i8_expr.contains("as i8"), "{i8_expr}");
assert!(
i8_expr.contains("127i64"),
"i8 must clamp to i8::MAX: {i8_expr}"
);
let u8_expr = expr(syn::parse_quote!(u8));
assert!(u8_expr.contains("as u8"), "{u8_expr}");
assert!(
u8_expr.contains("255i64"),
"u8 must clamp to u8::MAX: {u8_expr}"
);
for wide in ["i16", "i32", "i64", "u16", "u32", "u64", "usize", "isize"] {
let ty: syn::Type = syn::parse_str(wide).unwrap();
let e = expr(ty);
assert!(e.contains(&format!("as {wide}")), "{e}");
assert!(e.contains("1000"), "{wide} should keep 1000 default: {e}");
}
let i128_expr = expr(syn::parse_quote!(i128));
assert!(
i128_expr.contains("as i128"),
"i128 must be matched: {i128_expr}"
);
let u128_expr = expr(syn::parse_quote!(u128));
assert!(
u128_expr.contains("as u128"),
"u128 must be matched: {u128_expr}"
);
}
#[test]
fn field_serde_serialize_rename_parses_plain_and_split_forms() {
let field: syn::Field = syn::Field::parse_named
.parse2(quote! { #[serde(rename = "headline")] pub title: String })
.unwrap();
assert_eq!(
field_serde_serialize_rename(&field).as_deref(),
Some("headline")
);
let field: syn::Field = syn::Field::parse_named
.parse2(quote! {
#[serde(rename(serialize = "out", deserialize = "in"))]
pub title: String
})
.unwrap();
assert_eq!(field_serde_serialize_rename(&field).as_deref(), Some("out"));
let field: syn::Field = syn::Field::parse_named
.parse2(quote! { #[serde(rename(deserialize = "in"))] pub title: String })
.unwrap();
assert_eq!(field_serde_serialize_rename(&field), None);
let field: syn::Field = syn::Field::parse_named
.parse2(quote! { #[serde(default)] pub title: String })
.unwrap();
assert_eq!(field_serde_serialize_rename(&field), None);
}
#[test]
fn schema_property_name_resolves_renames_and_strips_raw_idents() {
let field: syn::Field = syn::Field::parse_named
.parse2(quote! { #[serde(rename = "kind")] pub category: String })
.unwrap();
assert_eq!(
schema_property_name(&field, Some("camelCase")).as_deref(),
Some("kind")
);
let field: syn::Field = syn::Field::parse_named
.parse2(quote! { pub word_count: i64 })
.unwrap();
assert_eq!(
schema_property_name(&field, Some("camelCase")).as_deref(),
Some("wordCount")
);
let field: syn::Field = syn::Field::parse_named
.parse2(quote! { pub r#type: String })
.unwrap();
assert_eq!(schema_property_name(&field, None).as_deref(), Some("type"));
let field: syn::Field = syn::Field::parse_named
.parse2(quote! { pub title: String })
.unwrap();
assert_eq!(schema_property_name(&field, None).as_deref(), Some("title"));
}
#[test]
fn serde_rename_all_serialize_rule_parses_plain_and_split_forms() {
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[serde(rename_all = "camelCase")])];
assert_eq!(
serde_rename_all_serialize_rule(&attrs).as_deref(),
Some("camelCase")
);
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(
#[serde(rename_all(serialize = "kebab-case", deserialize = "camelCase"))]
)];
assert_eq!(
serde_rename_all_serialize_rule(&attrs).as_deref(),
Some("kebab-case")
);
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(#[serde(deny_unknown_fields)])];
assert_eq!(serde_rename_all_serialize_rule(&attrs), None);
}
#[test]
fn apply_serde_rename_all_rule_mirrors_serde_field_casings() {
let cases = [
("lowercase", "word_count", "word_count"),
("snake_case", "word_count", "word_count"),
("UPPERCASE", "word_count", "WORD_COUNT"),
("SCREAMING_SNAKE_CASE", "word_count", "WORD_COUNT"),
("PascalCase", "word_count", "WordCount"),
("camelCase", "word_count", "wordCount"),
("camelCase", "title", "title"),
("kebab-case", "word_count", "word-count"),
("SCREAMING-KEBAB-CASE", "word_count", "WORD-COUNT"),
];
for (rule, field, expected) in cases {
assert_eq!(
apply_serde_rename_all_rule(rule, field).as_deref(),
Some(expected),
"rule {rule} on {field}"
);
}
assert_eq!(apply_serde_rename_all_rule("bogusCase", "word_count"), None);
}
#[test]
fn belongs_to_explicit_fk_derives_name_from_fk() {
let post = syn::parse_quote!(Post);
let user = syn::parse_quote!(User);
let (fk, name) = resolve_fk_and_name(AssocKind::BelongsTo, &post, &user, Some("author_id"));
assert_eq!(fk, "author_id");
assert_eq!(name, "author");
}
#[test]
fn belongs_to_infers_fk_and_name_from_target() {
let post = syn::parse_quote!(Post);
let subreddit = syn::parse_quote!(Subreddit);
let (fk, name) = resolve_fk_and_name(AssocKind::BelongsTo, &post, &subreddit, None);
assert_eq!(fk, "subreddit_id");
assert_eq!(name, "subreddit");
}
#[test]
fn has_many_infers_fk_from_source_and_pluralizes_name() {
let post = syn::parse_quote!(Post);
let comment = syn::parse_quote!(Comment);
let (fk, name) = resolve_fk_and_name(AssocKind::HasMany, &post, &comment, None);
assert_eq!(fk, "post_id");
assert_eq!(name, "comments");
}
#[test]
fn has_many_pluralizes_name_with_irregular_rules() {
let store = syn::parse_quote!(Store);
let category = syn::parse_quote!(Category);
let (fk, name) = resolve_fk_and_name(AssocKind::HasMany, &store, &category, None);
assert_eq!(fk, "store_id");
assert_eq!(name, "categories");
}
#[test]
fn has_one_infers_fk_from_source_and_singular_name() {
let user = syn::parse_quote!(User);
let profile = syn::parse_quote!(Profile);
let (fk, name) = resolve_fk_and_name(AssocKind::HasOne, &user, &profile, None);
assert_eq!(fk, "user_id");
assert_eq!(name, "profile");
}
#[test]
fn resolve_associations_parses_all_kinds() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[belongs_to(User, fk = author_id)]),
syn::parse_quote!(#[has_many(Comment)]),
syn::parse_quote!(#[belongs_to(Subreddit)]),
];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 3);
assert_eq!(assocs[0].kind, AssocKind::BelongsTo);
assert_eq!(assocs[0].fk, "author_id");
assert_eq!(assocs[0].name, "author");
assert_eq!(assocs[1].kind, AssocKind::HasMany);
assert_eq!(assocs[1].fk, "post_id");
assert_eq!(assocs[1].name, "comments");
assert_eq!(assocs[2].name, "subreddit");
}
#[test]
fn resolve_associations_rejects_unknown_key() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(User, bogus = author_id)])];
assert!(resolve_associations(&model, &attrs).is_err());
}
fn expect_assoc_error(model: &syn::Ident, attrs: &[syn::Attribute]) -> String {
match resolve_associations(model, attrs) {
Ok(_) => panic!("expected the association to be rejected"),
Err(err) => err.to_string(),
}
}
#[test]
fn counter_cache_bare_flag_derives_the_column_from_the_child() {
let model: syn::Ident = syn::parse_quote!(Comment);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(Post, counter_cache)])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 1);
assert!(assocs[0].counter_cache.is_some());
assert_eq!(
counter_cache_column(&model, &assocs[0]).as_deref(),
Some("comment_count"),
"the default column is singular: {{snake(child)}}_count"
);
}
#[test]
fn counter_cache_accepts_an_explicit_column() {
let model: syn::Ident = syn::parse_quote!(Subscription);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(Subreddit, counter_cache = "subscriber_count")])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(
counter_cache_column(&model, &assocs[0]).as_deref(),
Some("subscriber_count")
);
}
#[test]
fn a_belongs_to_without_counter_cache_records_none() {
let model: syn::Ident = syn::parse_quote!(Comment);
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(#[belongs_to(Post)])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert!(assocs[0].counter_cache.is_none());
assert_eq!(counter_cache_column(&model, &assocs[0]), None);
}
#[test]
fn counter_cache_tenant_is_explicit_and_recorded() {
let model: syn::Ident = syn::parse_quote!(Comment);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[belongs_to(Post, counter_cache, counter_cache_tenant = "tenant_id")]),
];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(
assocs[0]
.counter_cache
.as_ref()
.and_then(|d| d.tenant_column.clone())
.as_deref(),
Some("tenant_id")
);
}
#[test]
fn counter_cache_defaults_to_no_tenant_predicate() {
let model: syn::Ident = syn::parse_quote!(Comment);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(Post, counter_cache)])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert!(
assocs[0]
.counter_cache
.as_ref()
.expect("counter cache")
.tenant_column
.is_none()
);
}
#[test]
fn counter_cache_tenant_without_counter_cache_is_rejected() {
let model: syn::Ident = syn::parse_quote!(Comment);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(Post, counter_cache_tenant = "tenant_id")])];
let message = expect_assoc_error(&model, &attrs);
assert!(message.contains("requires `counter_cache`"), "{message}");
}
#[test]
fn counter_cache_on_has_many_is_rejected() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Comment, counter_cache)])];
let message = expect_assoc_error(&model, &attrs);
assert!(
message.contains("`#[belongs_to]` option")
&& message.contains("Move it to the child model's"),
"the error must name the leg to move it to: {message}"
);
}
#[test]
fn counter_cache_on_a_join_table_association_is_rejected() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(Tag, through = post_tags, counter_cache)])];
assert!(resolve_associations(&model, &attrs).is_err());
}
#[test]
fn a_non_identifier_counter_cache_column_is_rejected() {
let model: syn::Ident = syn::parse_quote!(Comment);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[belongs_to(Post, counter_cache = "comment_count; DROP TABLE posts")]),
];
let message = expect_assoc_error(&model, &attrs);
assert!(message.contains("plain identifier"), "{message}");
}
#[test]
fn two_legs_onto_one_parent_column_collide() {
let model: syn::Ident = syn::parse_quote!(Message);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[belongs_to(User, fk = sender_id, name = sender, counter_cache)]),
syn::parse_quote!(#[belongs_to(User, fk = recipient_id, name = recipient, counter_cache)]),
];
let message = expect_assoc_error(&model, &attrs);
assert!(message.contains("users.message_count"), "{message}");
}
#[test]
fn two_legs_onto_different_parent_tables_may_share_a_column_name() {
let model: syn::Ident = syn::parse_quote!(Message);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[belongs_to(User, fk = sender_id, name = sender, counter_cache)]),
syn::parse_quote!(#[belongs_to(Room, fk = room_id, counter_cache)]),
];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 2);
}
#[test]
fn has_many_dependent_destroy_is_recorded() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Comment, dependent = destroy)])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 1);
assert_eq!(assocs[0].kind, AssocKind::HasMany);
assert_eq!(assocs[0].fk, "post_id");
assert_eq!(assocs[0].dependent, Some(DependentAction::Destroy));
}
#[test]
fn has_many_on_delete_destroy_is_recorded() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Comment, on_delete = destroy)])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 1);
assert_eq!(assocs[0].dependent, Some(DependentAction::Destroy));
}
#[test]
fn has_many_dependent_unknown_action_is_rejected() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Comment, dependent = bogus)])];
let Err(err) = resolve_associations(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
assert!(
msg.contains("bogus"),
"expected the unknown action named, got: {msg}"
);
assert!(
msg.contains("destroy")
&& msg.contains("delete_all")
&& msg.contains("nullify")
&& msg.contains("restrict"),
"expected the valid actions listed, got: {msg}"
);
}
#[test]
fn has_one_dependent_nullify_is_recorded() {
let model: syn::Ident = syn::parse_quote!(User);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_one(Profile, dependent = nullify)])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 1);
assert_eq!(assocs[0].kind, AssocKind::HasOne);
assert_eq!(assocs[0].dependent, Some(DependentAction::Nullify));
}
#[test]
fn model_emits_dependents_impl_for_declared_cascade() {
let item: TokenStream = quote! {
#[has_many(Comment, dependent = destroy)]
struct Post {
#[id]
id: i64,
title: String,
}
};
let generated = model_macro(quote! {}, item).to_string();
assert!(
generated.contains("fn dependents"),
"expected an inherent dependents() fn, got: {generated}"
);
assert!(
generated.contains("RuntimeDependentSpec"),
"expected RuntimeDependentSpec entries, got: {generated}"
);
assert!(
generated.contains("PgCommentRepository"),
"expected the child repo resolved by convention, got: {generated}"
);
assert!(
generated.contains("__autumn_apply_dependent_on_conn"),
"expected the cascade thunk to call the leaf executor, got: {generated}"
);
}
#[test]
fn belongs_to_dependent_is_rejected_as_meaningless() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(User, dependent = destroy)])];
let Err(err) = resolve_associations(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
assert!(
msg.contains("belongs_to"),
"expected a belongs_to-specific rejection, got: {msg}"
);
}
#[test]
fn has_many_without_dependent_still_parses() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[has_many(Comment)]),
syn::parse_quote!(#[has_many(Comment, fk = "post_id")]),
];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 2);
assert_eq!(assocs[0].fk, "post_id");
assert_eq!(assocs[1].fk, "post_id");
}
#[test]
fn name_override_disambiguates_same_target() {
let model: syn::Ident = syn::parse_quote!(User);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[has_many(Post, fk = author_id, name = authored)]),
syn::parse_quote!(#[has_many(Post, fk = approver_id, name = approved)]),
];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 2);
assert_eq!(assocs[0].fk, "author_id");
assert_eq!(assocs[0].name, "authored");
assert_eq!(assocs[1].fk, "approver_id");
assert_eq!(assocs[1].name, "approved");
}
#[test]
fn has_many_through_infers_join_columns_and_name() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Tag, through = post_tags)])];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs.len(), 1);
assert_eq!(assocs[0].kind, AssocKind::HasMany);
assert_eq!(assocs[0].fk, "post_id");
assert_eq!(assocs[0].name, "tags");
let through = assocs[0].through.as_ref().expect("through present");
assert_eq!(through.table, "post_tags");
assert_eq!(through.target_fk, "tag_id");
}
#[test]
fn has_many_through_accepts_fk_and_target_fk_overrides() {
let model: syn::Ident = syn::parse_quote!(Article);
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(
#[has_many(Label, through = taggings, fk = piece_id, target_fk = sticker_id)]
)];
let assocs = resolve_associations(&model, &attrs).expect("parse ok");
assert_eq!(assocs[0].fk, "piece_id");
assert_eq!(assocs[0].name, "labels");
let through = assocs[0].through.as_ref().expect("through present");
assert_eq!(through.table, "taggings");
assert_eq!(through.target_fk, "sticker_id");
}
#[test]
fn through_rejected_on_belongs_to_and_has_one() {
let model: syn::Ident = syn::parse_quote!(Post);
let belongs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[belongs_to(User, through = post_users)])];
assert!(resolve_associations(&model, &belongs).is_err());
let has_one: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_one(Profile, through = post_profiles)])];
assert!(resolve_associations(&model, &has_one).is_err());
}
#[test]
fn dependent_on_through_association_is_rejected() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Tag, through = post_tags, dependent = destroy)])];
let Err(err) = resolve_associations(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
assert!(
msg.contains("through"),
"expected a through-specific rejection, got: {msg}"
);
assert!(
msg.contains("dependent") || msg.contains("on_delete"),
"expected the cascade key named, got: {msg}"
);
}
#[test]
fn on_delete_on_through_association_is_rejected() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Tag, through = post_tags, on_delete = nullify)])];
assert!(resolve_associations(&model, &attrs).is_err());
}
#[test]
fn target_fk_without_through_is_error() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Tag, target_fk = tag_id)])];
assert!(resolve_associations(&model, &attrs).is_err());
}
#[test]
fn m2m_colliding_mutation_method_names_rejected() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[has_many(Tag, through = post_tags)]),
syn::parse_quote!(#[has_many(Tag, through = featured_post_tags, name = featured_tags)]),
];
assert!(resolve_associations(&model, &attrs).is_err());
}
#[test]
fn m2m_helper_override_re_enables_two_relations_to_same_target() {
let model: syn::Ident = syn::parse_quote!(User);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(
#[has_many(User, through = friendships, name = followers,
fk = followed_id, target_fk = follower_id,
helper = follower)]
),
syn::parse_quote!(
#[has_many(User, through = friendships, name = following,
fk = follower_id, target_fk = followed_id,
helper = following)]
),
];
assert!(
resolve_associations(&model, &attrs).is_ok(),
"distinct `helper = ...` overrides must re-enable dual m2m to one target"
);
}
#[test]
fn m2m_helper_override_matching_derived_still_collides() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[has_many(Tag, through = post_tags)]),
syn::parse_quote!(
#[has_many(Label, through = post_labels, name = labels, helper = tag)]
),
];
assert!(
resolve_associations(&model, &attrs).is_err(),
"an override colliding with a derived singular must still be rejected"
);
}
#[test]
fn m2m_helper_override_requires_through() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[has_many(Comment, helper = commentary)])];
assert!(resolve_associations(&model, &attrs).is_err());
}
#[test]
fn model_macro_m2m_helper_override_generates_distinct_helpers() {
let generated = model_macro(
quote! {},
quote! {
#[has_many(User, through = friendships, name = followers,
fk = followed_id, target_fk = follower_id,
helper = follower)]
#[has_many(User, through = friendships, name = following,
fk = follower_id, target_fk = followed_id,
helper = following)]
pub struct User {
#[id]
pub id: i64,
}
},
)
.to_string();
assert!(
generated.contains("add_follower"),
"expected add_follower helper, got: {generated}"
);
assert!(
generated.contains("remove_follower"),
"expected remove_follower helper"
);
assert!(
generated.contains("add_following"),
"expected add_following helper"
);
assert!(
generated.contains("remove_following"),
"expected remove_following helper"
);
assert!(
!generated.contains("add_user"),
"must not fall back to the colliding target-derived add_user"
);
assert!(
generated.contains("set_followers") && generated.contains("set_following"),
"the set_ (replace-all) helpers keep their per-association accessor names"
);
}
#[test]
fn m2m_mutation_singular_derives_from_target_type() {
let category: syn::Ident = syn::parse_quote!(Category); let person: syn::Ident = syn::parse_quote!(Person); let comment: syn::Ident = syn::parse_quote!(Comment); assert_eq!(m2m_mutation_singular(&category), "category");
assert_eq!(m2m_mutation_singular(&person), "person");
assert_eq!(m2m_mutation_singular(&comment), "comment");
}
#[test]
fn model_macro_m2m_mutation_helper_singular_uses_target_for_irregular_plurals() {
let generated = model_macro(
quote! {},
quote! {
#[has_many(Category, through = post_categories)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
},
)
.to_string();
assert!(
generated.contains("add_category"),
"expected add_category helper, got: {generated}"
);
assert!(
generated.contains("remove_category"),
"expected remove_category helper"
);
assert!(
!generated.contains("add_categorie"),
"must not emit the broken de-pluralized `add_categorie`"
);
assert!(
generated.contains("set_categories"),
"the set_ (replace-all) helper keeps the smart plural accessor name"
);
}
#[test]
fn model_macro_m2m_mutation_helper_singular_uses_target_for_irregular_person() {
let generated = model_macro(
quote! {},
quote! {
#[has_many(Person, through = team_people)]
pub struct Team {
#[id]
pub id: i64,
}
},
)
.to_string();
assert!(
generated.contains("add_person"),
"expected add_person helper, got: {generated}"
);
assert!(
generated.contains("remove_person"),
"expected remove_person helper"
);
assert!(
!generated.contains("add_people"),
"must not emit the broken `add_people`"
);
}
#[test]
fn model_macro_m2m_emits_hidden_join_table_module() {
let generated = model_macro(
quote! {},
quote! {
#[has_many(Tag, through = post_tags)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
},
)
.to_string();
assert!(
generated.contains("__autumn_m2m_post_tags") || generated.contains("__autumn_m2m"),
"expected a hidden m2m join-table module, got: {generated}"
);
assert!(
generated.contains("table !"),
"expected a diesel table! invocation"
);
assert!(
generated.contains("allow_tables_to_appear_in_same_query"),
"expected the join table to be allowed alongside the target table"
);
}
#[test]
fn model_macro_m2m_loader_uses_single_inner_join_and_keep() {
let generated = model_macro(
quote! {},
quote! {
#[has_many(Tag, through = post_tags)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
},
)
.to_string();
assert!(
generated.contains("inner_join"),
"expected a single inner_join loader"
);
assert!(
generated.contains("eq_any"),
"expected a batched WHERE ... IN filter"
);
assert!(
generated.contains("__autumn_preload_keep"),
"expected the m2m loader to scope rows with the per-row keep predicate"
);
}
#[test]
fn model_macro_m2m_emits_mutation_trait() {
let generated = model_macro(
quote! {},
quote! {
#[has_many(Tag, through = post_tags)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
},
)
.to_string();
assert!(
generated.contains("PostTagsMutations"),
"expected a per-association mutation trait"
);
assert!(
generated.contains("add_tag"),
"expected an add_tag mutation helper"
);
assert!(
generated.contains("remove_tag"),
"expected a remove_tag mutation helper"
);
assert!(
generated.contains("set_tags"),
"expected a set_tags (replace-all) mutation helper"
);
assert!(
generated.contains("on_conflict_do_nothing") || generated.contains("on_conflict"),
"expected add_tag to be idempotent via ON CONFLICT DO NOTHING"
);
assert!(
generated.contains("M2mConnSource"),
"expected the mutation trait to be blanket-implemented over M2mConnSource"
);
}
#[test]
fn votable_defaults_map_onto_conventional_columns() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, aggregate = sum)])];
let spec = resolve_votable(&model, &attrs)
.expect("parse ok")
.expect("a #[votable] spec");
assert_eq!(spec.reactor.to_string(), "User");
assert_eq!(spec.aggregate, VoteAggregate::Sum);
assert_eq!(spec.name, "vote");
assert_eq!(spec.table, "votes");
assert_eq!(spec.reactor_fk, "user_id");
assert_eq!(spec.target_fk, "post_id");
assert_eq!(spec.value_column.as_deref(), Some("value"));
assert_eq!(spec.column, "score");
}
#[test]
fn votable_defaults_to_sum_when_aggregate_is_omitted() {
let model: syn::Ident = syn::parse_quote!(Post);
let bare: Vec<syn::Attribute> = vec![syn::parse_quote!(#[votable(by = User)])];
let explicit: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, aggregate = sum)])];
let bare = resolve_votable(&model, &bare)
.expect("parse ok")
.expect("a #[votable] spec");
let explicit = resolve_votable(&model, &explicit)
.expect("parse ok")
.expect("a #[votable] spec");
assert_eq!(bare.aggregate, VoteAggregate::Sum);
assert_eq!(bare.aggregate, explicit.aggregate);
assert_eq!(bare.table, explicit.table);
assert_eq!(bare.column, explicit.column);
assert_eq!(bare.value_column, explicit.value_column);
}
#[test]
fn votable_count_mode_infers_name_count_column() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, aggregate = count)])];
let spec = resolve_votable(&model, &attrs)
.expect("parse ok")
.expect("a #[votable] spec");
assert_eq!(spec.aggregate, VoteAggregate::Count);
assert_eq!(spec.name, "vote");
assert_eq!(spec.table, "votes");
assert_eq!(spec.column, "vote_count");
assert_eq!(
spec.value_column, None,
"a count-mode edge table stores no value column"
);
}
#[test]
fn votable_name_override_drives_table_and_column() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, aggregate = count, name = like)])];
let spec = resolve_votable(&model, &attrs)
.expect("parse ok")
.expect("a #[votable] spec");
assert_eq!(spec.name, "like");
assert_eq!(spec.table, "likes");
assert_eq!(spec.column, "like_count");
assert_eq!(spec.reactor_fk, "user_id");
assert_eq!(spec.target_fk, "post_id");
}
#[test]
fn votable_explicit_overrides_win() {
let model: syn::Ident = syn::parse_quote!(Article);
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(
#[votable(by = Member, aggregate = sum, name = rating, table = ratings,
reactor_fk = member_id, target_fk = piece_id,
value_column = weight, column = rating_total)]
)];
let spec = resolve_votable(&model, &attrs)
.expect("parse ok")
.expect("a #[votable] spec");
assert_eq!(spec.reactor.to_string(), "Member");
assert_eq!(spec.aggregate, VoteAggregate::Sum);
assert_eq!(spec.name, "rating");
assert_eq!(spec.table, "ratings");
assert_eq!(spec.reactor_fk, "member_id");
assert_eq!(spec.target_fk, "piece_id");
assert_eq!(spec.value_column.as_deref(), Some("weight"));
assert_eq!(spec.column, "rating_total");
}
#[test]
fn votable_accepts_string_literal_values() {
let model: syn::Ident = syn::parse_quote!(Post);
let idents: Vec<syn::Attribute> = vec![syn::parse_quote!(
#[votable(by = User, name = vote, table = votes, reactor_fk = user_id,
target_fk = post_id, value_column = value, column = score)]
)];
let literals: Vec<syn::Attribute> = vec![syn::parse_quote!(
#[votable(by = User, name = "vote", table = "votes", reactor_fk = "user_id",
target_fk = "post_id", value_column = "value", column = "score")]
)];
let idents = resolve_votable(&model, &idents)
.expect("bare idents parse ok")
.expect("a #[votable] spec");
let literals = resolve_votable(&model, &literals)
.expect("string literals parse ok")
.expect("a #[votable] spec");
assert_eq!(idents.name, literals.name);
assert_eq!(idents.table, literals.table);
assert_eq!(idents.reactor_fk, literals.reactor_fk);
assert_eq!(idents.target_fk, literals.target_fk);
assert_eq!(idents.value_column, literals.value_column);
assert_eq!(idents.column, literals.column);
}
#[test]
fn votable_requires_by() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(#[votable(aggregate = sum)])];
let Err(err) = resolve_votable(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
assert!(
msg.contains("requires `by = <ReactorModel>`"),
"expected the missing-`by` error to name the key, got: {msg}"
);
}
#[test]
fn votable_rejects_unknown_key() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, aggregat = sum)])];
let Err(err) = resolve_votable(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
for key in [
"by",
"aggregate",
"name",
"table",
"reactor_fk",
"target_fk",
"value_column",
"column",
] {
assert!(
msg.contains(key),
"expected the unknown-key error to enumerate `{key}`, got: {msg}"
);
}
assert!(
msg.contains("votable"),
"expected the unknown-key error to name the attribute, got: {msg}"
);
}
#[test]
fn votable_rejects_unknown_aggregate() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, aggregate = avg)])];
let Err(err) = resolve_votable(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
assert!(
msg.contains("unknown aggregate `avg`"),
"expected the offending aggregate quoted back, got: {msg}"
);
assert!(
msg.contains("sum") && msg.contains("count"),
"expected both supported modes named, got: {msg}"
);
}
#[test]
fn votable_rejects_value_column_in_count_mode() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(
#[votable(by = User, aggregate = count, value_column = value)]
)];
let Err(err) = resolve_votable(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
assert!(
msg.contains("value_column"),
"expected the offending key named, got: {msg}"
);
assert!(
msg.contains("aggregate = count"),
"expected the conflicting mode named, got: {msg}"
);
assert!(
msg.contains("aggregate = sum"),
"expected the fix (switch to sum, or drop the key) spelled out, got: {msg}"
);
}
#[test]
fn votable_rejects_duplicate_attribute() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> = vec![
syn::parse_quote!(#[votable(by = User, aggregate = sum)]),
syn::parse_quote!(#[votable(by = User, aggregate = count, name = like)]),
];
let Err(err) = resolve_votable(&model, &attrs) else {
panic!("expected an error");
};
let msg = err.to_string();
assert!(
msg.contains("at most one `#[votable]` per model"),
"expected the one-per-model rule stated, got: {msg}"
);
}
#[test]
fn votable_rejects_composite_primary_keys() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Enrollment {
#[id]
pub course_id: i64,
#[id]
pub student_id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"expected a compile error for a composite-keyed votable model, \
got: {generated}"
);
assert!(
generated.contains("requires a single `i64` primary key")
&& generated.contains("`course_id`, `student_id`"),
"expected the directed composite-key message naming both key \
components, got: {generated}"
);
}
#[test]
fn votable_rejects_non_identifier_string_values() {
let model: syn::Ident = syn::parse_quote!(Post);
for (key, attr) in [
(
"table",
syn::parse_quote!(#[votable(by = User, table = "my votes")]),
),
(
"name",
syn::parse_quote!(#[votable(by = User, name = "my vote")]),
),
(
"reactor_fk",
syn::parse_quote!(#[votable(by = User, reactor_fk = "user id")]),
),
(
"target_fk",
syn::parse_quote!(#[votable(by = User, target_fk = "post-id")]),
),
(
"value_column",
syn::parse_quote!(#[votable(by = User, value_column = "1value")]),
),
(
"column",
syn::parse_quote!(#[votable(by = User, column = "score; DROP TABLE posts")]),
),
("by", syn::parse_quote!(#[votable(by = "Not A Type")])),
] {
let attrs: Vec<syn::Attribute> = vec![attr];
let Err(err) = resolve_votable(&model, &attrs) else {
panic!("expected `{key}` with a non-identifier value to be rejected");
};
let msg = err.to_string();
assert!(
msg.contains("is not a valid identifier") || msg.contains("valid model type name"),
"expected a directed identifier error for `{key}`, got: {msg}"
);
assert!(
msg.contains(key),
"expected the error to name the offending key `{key}`, got: {msg}"
);
}
}
#[test]
fn votable_rejects_raw_identifier_values() {
let model: syn::Ident = syn::parse_quote!(Post);
let attrs: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, name = r#type)])];
let Err(err) = resolve_votable(&model, &attrs) else {
panic!("expected a raw-identifier `name` to be rejected");
};
let msg = err.to_string();
assert!(
msg.contains("is not a valid identifier"),
"expected the identifier error, got: {msg}"
);
assert!(
msg.contains("r#"),
"expected the error to mention the `r#` prefix, got: {msg}"
);
}
#[test]
fn votable_rejects_duplicate_key_within_one_attribute() {
let model: syn::Ident = syn::parse_quote!(Post);
let by: Vec<syn::Attribute> = vec![syn::parse_quote!(#[votable(by = User, by = Bot)])];
let Err(err) = resolve_votable(&model, &by) else {
panic!("expected a duplicate `by` to be rejected");
};
let msg = err.to_string();
assert!(
msg.contains("duplicate `by = ...`"),
"expected the duplicate-key error to name the key, got: {msg}"
);
assert!(
msg.contains("User") && msg.contains("Bot"),
"expected both the previous and the new value quoted back, got: {msg}"
);
let aggregate: Vec<syn::Attribute> =
vec![syn::parse_quote!(#[votable(by = User, aggregate = sum, aggregate = count)])];
let Err(err) = resolve_votable(&model, &aggregate) else {
panic!("expected a duplicate `aggregate` to be rejected");
};
let msg = err.to_string();
assert!(
msg.contains("duplicate `aggregate = ...`"),
"expected the duplicate-key error to name the key, got: {msg}"
);
assert!(
msg.contains("sum") && msg.contains("count"),
"expected both the previous and the new value quoted back, got: {msg}"
);
}
#[test]
fn votable_aggregate_column_must_exist_on_model() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"expected a compile error for the missing aggregate column, got: {generated}"
);
assert!(
generated.contains("votable aggregate column `score` not found on model `Post`"),
"expected the directed missing-column message, got: {generated}"
);
assert!(
generated.contains("column = "),
"expected the error to point at the `column = <field>` override, got: {generated}"
);
}
#[test]
fn votable_aggregate_column_must_be_i64() {
for (ty, rendered) in [
(quote! { i32 }, "i32"),
(quote! { Option<i64> }, "Option < i64 >"),
] {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: #ty,
}
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"expected a compile error for a `{rendered}` aggregate column, got: {generated}"
);
assert!(
generated
.contains("votable aggregate column `score` on model `Post` must be `i64`"),
"expected the directed wrong-type message, got: {generated}"
);
assert!(
generated.contains(&format!("found `{rendered}`")),
"expected the offending type quoted back, got: {generated}"
);
}
}
#[test]
fn votable_aggregate_accepts_equivalent_i64_spellings_via_typed_guard() {
for ty in [quote! { std::primitive::i64 }, quote! { i64 }] {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: #ty,
}
},
)
.to_string();
assert!(
!generated.contains("compile_error"),
"an i64-equivalent spelling must not be rejected, got: {generated}"
);
assert!(
generated.contains("__autumn_votable_model . score"),
"expected the aggregate-field type guard to be emitted, got: {generated}"
);
}
}
#[test]
fn votable_emits_hidden_edge_table_module() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("mod __autumn_votable_4_post_vote"),
"expected a length-prefixed hidden edge-table module, got: {generated}"
);
assert!(
generated.contains("votes (user_id , post_id)"),
"expected the edge table keyed on the composite (reactor, target) \
pair — the load-bearing ON CONFLICT arbiter, got: {generated}"
);
assert!(
generated.contains("user_id -> Int8") && generated.contains("post_id -> Int8"),
"expected non-nullable Int8 fk columns (a NULL target would defeat \
the unique arbiter), got: {generated}"
);
assert!(
generated.contains("value -> Int2"),
"expected the sum-mode value column typed SMALLINT, got: {generated}"
);
}
#[test]
fn votable_emits_target_projection_in_the_hidden_module() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("posts (id)"),
"expected a minimal target-table projection, got: {generated}"
);
assert!(
generated.contains("score -> Int8"),
"expected the aggregate column projected as BIGINT, got: {generated}"
);
}
#[test]
fn votable_target_projection_keys_on_the_models_real_primary_key() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Memo {
#[id]
pub memo_id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("memos (memo_id)"),
"expected the target projection keyed on the model's real primary \
key, got: {generated}"
);
assert!(
!generated.contains("memos (id)"),
"no hard-coded `id` primary key may survive on the target \
projection, got: {generated}"
);
assert!(
generated
.matches("memos :: memo_id . eq (target_id)")
.count()
>= 3,
"S1 (both backend arms) and S5 must filter the target table on the \
resolved primary key, got: {generated}"
);
}
#[test]
fn votable_emits_reactions_trait_and_blanket_impl() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("trait PostReactions"),
"expected a per-model reactions trait, got: {generated}"
);
assert!(
generated.contains("fn react"),
"expected the react() helper, got: {generated}"
);
assert!(
generated.contains("fn reaction_of"),
"expected the reaction_of() accessor, got: {generated}"
);
assert!(
generated.contains("M2mConnSource < Model = Post >"),
"expected the trait blanket-implemented over the repository's \
connection source, got: {generated}"
);
}
#[test]
fn votable_react_runs_in_an_immediate_transaction() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("scoped_immediate_transaction"),
"expected react() to wrap its statements in the write-path \
transaction primitive, got: {generated}"
);
}
#[test]
fn votable_react_locks_the_target_row_on_pg_only() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("backend_select"),
"expected the per-backend split, got: {generated}"
);
assert!(
generated.contains(
"pg => { __autumn_votable_4_post_vote :: posts :: table . filter \
(__autumn_votable_4_post_vote :: posts :: id . eq (target_id)) . select \
(__autumn_votable_4_post_vote :: posts :: id) . for_no_key_update ()"
),
"expected FOR NO KEY UPDATE chained onto the pg arm's S1 target \
select, got: {generated}"
);
let s1 = generated
.split_once("let __target")
.expect("the S1 locking select")
.1;
let pg_arm = s1
.split_once("sqlite =>")
.expect("the backend_select! sqlite arm")
.0;
assert!(
pg_arm.contains(". for_no_key_update ()"),
"expected the row lock inside the pg arm, got: {pg_arm}"
);
assert_eq!(
generated.matches("for_no_key_update").count(),
1,
"expected exactly one locking clause (pg arm only), got: {generated}"
);
}
#[test]
fn votable_react_upserts_with_an_explicit_arbiter() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains(
"on_conflict ((__autumn_votable_4_post_vote :: votes :: user_id , \
__autumn_votable_4_post_vote :: votes :: post_id ,))"
),
"expected the composite (reactor_fk, target_fk) ON CONFLICT \
arbiter, got: {generated}"
);
assert!(
generated.contains("do_update"),
"expected ON CONFLICT DO UPDATE (not DO NOTHING), got: {generated}"
);
assert!(
generated.contains("excluded"),
"expected the conflicting row to take EXCLUDED.value, got: {generated}"
);
}
#[test]
fn votable_count_mode_react_has_no_value_parameter() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = count)]
pub struct Post {
#[id]
pub id: i64,
pub vote_count: i64,
}
},
)
.to_string();
assert!(
generated.contains("trait PostReactions") && generated.contains("fn react"),
"expected the reactions trait in count mode too, got: {generated}"
);
assert!(
!generated.contains("value : i16"),
"count-mode react() must not take a value parameter, got: {generated}"
);
assert!(
!generated.contains("-> Int2"),
"a count-mode edge table has no value column, got: {generated}"
);
}
#[test]
fn votable_soft_delete_target_gates_the_lock_and_the_update() {
let soft = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
pub deleted_at: Option<chrono::NaiveDateTime>,
}
},
)
.to_string();
let hard = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
soft.matches("deleted_at . is_null ()").count() >= 3,
"expected the soft-delete guard on both S1 arms and the S5 update \
(>= 3 sites), got {}: {soft}",
soft.matches("deleted_at . is_null ()").count()
);
assert!(
soft.contains("deleted_at -> Nullable < Timestamp >"),
"expected deleted_at projected into the hidden target table, got: {soft}"
);
assert!(
!hard.contains("is_null"),
"a model without deleted_at must not emit a soft-delete guard, got: {hard}"
);
}
fn votable_slice(generated: &str) -> &str {
let start = generated
.find("mod __autumn_votable_")
.expect("the votable codegen starts at its hidden module");
&generated[start..]
}
#[test]
fn votable_tenant_scoped_target_is_filtered_in_s1_s5_and_reaction_of() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub tenant_id: String,
pub score: i64,
}
},
)
.to_string();
let votable = votable_slice(&generated);
assert!(
votable.contains("tenant_id -> Text"),
"expected tenant_id projected into the hidden target table, got: {votable}"
);
assert!(
votable.contains("self . __autumn_m2m_tenant_scope ()"),
"expected the tenant predicate resolved through the repository's \
M2mConnSource, not read from the task-local directly, got: {votable}"
);
assert_eq!(
votable.matches("tenant_id . eq").count(),
4,
"expected exactly 4 tenant-filtered target sites (S1 pg, S1 sqlite, \
S5, reaction_of's EXISTS), got: {votable}"
);
assert!(
votable.contains(":: dsl :: exists"),
"reaction_of's tenant check must be an EXISTS folded into the edge \
lookup, got: {votable}"
);
assert!(
!votable.contains("__in_tenant"),
"the two-statement tenant probe must not survive, got: {votable}"
);
assert!(
votable.contains("for_no_key_update"),
"the tenant-filtered pg arm must keep the row lock, got: {votable}"
);
assert_eq!(
votable.matches("posts :: id . eq (target_id)").count(),
7,
"expected a tenant-filtered AND an unfiltered arm at S1 (both \
backends) and S5, plus reaction_of's probe, got: {votable}"
);
}
#[test]
fn votable_model_without_tenant_id_emits_no_tenant_scoping() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
let votable = votable_slice(&generated);
assert!(
!votable.contains("tenant_id"),
"a model without tenant_id must emit no tenant column, predicate, \
or projection anywhere in the votable items, got: {votable}"
);
assert_eq!(
votable
.matches("self . __autumn_m2m_tenant_scope ()")
.count(),
2,
"react() and reaction_of() must each still call the tenant-scope \
method (discarded) so the cross-shard reject fires before any \
connection is acquired, got: {votable}"
);
assert!(
!votable.contains("__tenant"),
"no tenant runtime branch may survive on a tenant-less model, \
got: {votable}"
);
}
#[test]
fn votable_emits_i64_primary_key_and_reactor_type_guards() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("const _ : fn (& Post) -> i64"),
"expected the i64-primary-key guard, got: {generated}"
);
assert!(
generated.contains("PhantomData < User >"),
"expected the reactor-type name-resolution guard, got: {generated}"
);
}
#[test]
fn votable_react_returns_a_reaction_via_the_hidden_constructor() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("repository :: Reaction :: __new"),
"expected the Reaction to be built through its hidden constructor, \
got: {generated}"
);
assert!(
!generated.contains("Reaction { value"),
"a struct literal would not compile downstream of #[non_exhaustive], \
got: {generated}"
);
}
#[test]
fn votable_react_fails_loudly_when_the_aggregate_update_hits_no_row() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("let __persisted : usize"),
"expected the S5 update's affected-row count to be captured, got: {generated}"
);
assert!(
generated.contains("if __persisted == 0"),
"expected a zero-row S5 update to abort the transaction, got: {generated}"
);
}
#[test]
fn votable_reaction_of_reads_on_a_read_connection() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
generated.contains("self . __autumn_m2m_read_conn ()"),
"expected reaction_of to take a read connection, got: {generated}"
);
assert_eq!(
generated.matches("__autumn_m2m_write_conn").count(),
1,
"expected exactly one write-connection acquisition (react's), got: {generated}"
);
}
#[test]
fn votable_react_doc_warns_that_a_toggle_is_not_idempotent() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
!generated.contains("Idempotent"),
"react() is a toggle — the docs must not claim idempotence, got: {generated}"
);
assert!(
generated.contains("Not idempotent"),
"expected the toggle caveat in react()'s docs, got: {generated}"
);
assert!(
generated.contains("idempotency key"),
"expected the retry-safety guidance in react()'s docs, got: {generated}"
);
assert!(
generated.contains("does not pin read-your-writes") || generated.contains("read route"),
"expected reaction_of's read-routing note, got: {generated}"
);
}
#[test]
fn votable_attribute_is_stripped_from_the_diesel_struct() {
let generated = model_macro(
quote! {},
quote! {
#[votable(by = User, aggregate = sum)]
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
!generated.contains("# [votable"),
"the votable attribute must not leak onto the emitted struct, got: {generated}"
);
}
#[test]
fn model_without_votable_emits_no_reaction_items() {
let generated = model_macro(
quote! {},
quote! {
pub struct Post {
#[id]
pub id: i64,
pub score: i64,
}
},
)
.to_string();
assert!(
!generated.contains("Reactions"),
"expected no reactions trait without #[votable], got: {generated}"
);
assert!(
!generated.contains("__autumn_votable"),
"expected no hidden edge module without #[votable], got: {generated}"
);
}
#[test]
fn lock_version_attr_detected_by_has_attr() {
let field: syn::Field = syn::parse_quote! {
#[lock_version]
pub version: i32
};
assert!(has_attr(&field, "lock_version"));
}
#[test]
fn lock_version_field_is_excluded_from_new() {
let field: syn::Field = syn::parse_quote! {
#[lock_version]
pub lock_version: i32
};
assert!(excluded_from_new(&field));
}
#[test]
fn regular_field_is_not_excluded_from_new() {
let field: syn::Field = syn::parse_quote! {
pub title: String
};
assert!(!excluded_from_new(&field));
}
#[test]
fn id_field_is_still_excluded_from_new() {
let field: syn::Field = syn::parse_quote! {
#[id]
pub id: i64
};
assert!(excluded_from_new(&field));
}
#[test]
fn position_attr_detected_by_has_attr() {
let field: syn::Field = syn::parse_quote! {
#[position]
pub rank: i64
};
assert!(has_attr(&field, "position"));
}
#[test]
fn position_field_is_excluded_from_new() {
let field: syn::Field = syn::parse_quote! {
#[position]
pub rank: i64
};
assert!(excluded_from_new(&field));
}
#[test]
fn position_bare_attribute_with_args_is_rejected() {
let field: syn::Field = syn::parse_quote! {
#[position(scope = "board_id")]
pub rank: i64
};
let err = validate_field_schema_markers(&field).unwrap_err();
assert!(
err.to_string().contains("position"),
"unexpected error: {err}"
);
}
#[test]
fn position_attribute_is_stripped_from_the_diesel_struct() {
let generated = model_macro(
quote! {},
quote! {
pub struct Task {
#[id]
pub id: i64,
#[position]
pub rank: i64,
}
},
)
.to_string();
assert!(
!generated.contains("# [position]"),
"the position attribute must not leak onto the emitted struct, got: {generated}"
);
}
#[test]
fn position_field_excluded_from_new_and_update_structs() {
let generated = model_macro(
quote! {},
quote! {
pub struct Task {
#[id]
pub id: i64,
pub title: String,
#[position]
pub rank: i64,
}
},
)
.to_string();
let new_struct = generated
.split("struct NewTask")
.nth(1)
.expect("NewTask struct should be emitted");
let new_struct_body = &new_struct[..new_struct.find('}').unwrap_or(new_struct.len())];
assert!(
!new_struct_body.contains("rank"),
"NewTask must not contain the server-managed position field, got: {new_struct_body}"
);
let update_struct = generated
.split("struct UpdateTask")
.nth(1)
.expect("UpdateTask struct should be emitted");
let update_struct_body =
&update_struct[..update_struct.find('}').unwrap_or(update_struct.len())];
assert!(
!update_struct_body.contains("rank"),
"UpdateTask must not contain the server-managed position field, got: {update_struct_body}"
);
}
#[test]
fn encrypted_string_field_is_accepted() {
let field: syn::Field = syn::parse_quote! {
#[encrypted]
pub token: String
};
assert!(validate_encrypted_field(&field).is_ok());
}
#[test]
fn encrypted_plus_searchable_is_rejected() {
let field: syn::Field = syn::parse_quote! {
#[encrypted]
#[searchable]
pub token: String
};
let err = validate_encrypted_field(&field).unwrap_err();
assert!(err.to_string().contains("searchable"));
}
#[test]
fn translatable_schema_is_keyed_on_the_attribute_not_the_type_name() {
let generated = model_macro(
quote! { table = "posts" },
quote! {
pub struct Post {
#[id]
pub id: i64,
#[translatable]
pub title: ::autumn_web::i18n::Translated,
pub note: domain::Translated,
}
},
)
.to_string();
assert!(
generated.contains("additionalProperties"),
"translatable field should carry an inline map schema"
);
assert!(
generated.contains("components/schemas"),
"an unmarked `Translated` look-alike must keep its $ref"
);
}
#[test]
fn an_unmarked_translated_lookalike_alone_emits_no_map_schema() {
let generated = model_macro(
quote! { table = "posts" },
quote! {
pub struct Post {
#[id]
pub id: i64,
pub note: domain::Translated,
}
},
)
.to_string();
assert!(
!generated.contains("additionalProperties"),
"no `#[translatable]` field means no locale-map schema"
);
}
#[test]
fn translatable_field_is_accepted_on_a_translated_column() {
let field: syn::Field = syn::parse_quote! {
#[translatable]
pub title: Translated
};
assert!(validate_translatable_field(&field).is_ok());
let qualified: syn::Field = syn::parse_quote! {
#[translatable]
pub title: ::autumn_web::i18n::Translated
};
assert!(validate_translatable_field(&qualified).is_ok());
}
#[test]
fn translatable_on_a_plain_string_is_rejected_with_the_fix() {
let field: syn::Field = syn::parse_quote! {
#[translatable]
pub title: String
};
let err = validate_translatable_field(&field).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Translated"), "{msg}");
}
#[test]
fn translatable_option_is_rejected() {
let field: syn::Field = syn::parse_quote! {
#[translatable]
pub title: Option<Translated>
};
assert!(validate_translatable_field(&field).is_err());
}
#[test]
fn translatable_plus_encrypted_is_rejected() {
let field: syn::Field = syn::parse_quote! {
#[translatable]
#[encrypted]
pub title: Translated
};
let err = validate_translatable_field(&field).unwrap_err();
assert!(err.to_string().contains("encrypted"), "{err}");
}
#[test]
fn translatable_plus_searchable_is_rejected() {
let field: syn::Field = syn::parse_quote! {
#[translatable]
#[searchable]
pub title: Translated
};
let err = validate_translatable_field(&field).unwrap_err();
assert!(err.to_string().contains("searchable"), "{err}");
}
#[test]
fn translatable_plus_equality_markers_are_rejected() {
for marker in [
"unique",
"indexed",
"normalize",
"id",
"lock_version",
"position",
] {
let marker_ident = syn::Ident::new(marker, proc_macro2::Span::call_site());
let field: syn::Field = syn::parse_quote! {
#[translatable]
#[#marker_ident]
pub title: Translated
};
assert!(
validate_translatable_field(&field).is_err(),
"`#[{marker}]` must not combine with `#[translatable]`"
);
}
}
#[test]
fn translatable_model_emits_accessors_and_registry() {
let generated = model_macro(
quote! { table = "posts" },
quote! {
pub struct Post {
#[id]
pub id: i64,
#[translatable]
pub title: ::autumn_web::i18n::Translated,
pub body: String,
}
},
)
.to_string();
let query_struct = generated
.split("pub struct Post")
.nth(1)
.expect("Post struct emitted");
let body = &query_struct[..query_struct.find('}').unwrap_or(query_struct.len())];
assert!(
!body.contains("translatable"),
"attr must be stripped: {body}"
);
for expected in [
"fn title_localized",
"fn title_in",
"fn set_title",
"fn title_locales",
"fn title_is_translated",
] {
assert!(generated.contains(expected), "missing {expected}");
}
for expected in [
"fn translatable_fields",
"fn available_locales",
"fn is_translated",
"fn localized",
"__AUTUMN_TRANSLATABLE_COLUMNS",
] {
assert!(generated.contains(expected), "missing {expected}");
}
assert!(generated.contains("TranslatableColumnDescriptor"));
}
#[test]
fn model_without_translatable_fields_emits_no_translatable_accessors() {
let generated = model_macro(
quote! { table = "posts" },
quote! {
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
},
)
.to_string();
assert!(!generated.contains("TranslatableColumnDescriptor"));
assert!(!generated.contains("fn translatable_fields"));
assert!(!generated.contains("fn available_locales"));
assert!(generated.contains("__AUTUMN_TRANSLATABLE_COLUMNS"));
}
#[test]
fn tenant_id_detection_requires_a_string_shaped_column() {
assert!(is_string_or_option_string(&syn::parse_quote!(String)));
assert!(is_string_or_option_string(&syn::parse_quote!(
Option<String>
)));
assert!(is_string_or_option_string(&syn::parse_quote!(
::std::option::Option<::std::string::String>
)));
assert!(!is_string_or_option_string(&syn::parse_quote!(i64)));
assert!(!is_string_or_option_string(&syn::parse_quote!(Option<i64>)));
assert!(!is_string_or_option_string(&syn::parse_quote!(Uuid)));
}
#[test]
fn a_non_string_tenant_id_column_does_not_make_the_index_tenant_scoped() {
let input: TokenStream = quote! {
#[searchable]
pub struct Reading {
#[id]
pub id: i64,
#[searchable]
pub label: String,
pub tenant_id: i64,
}
};
let expanded = model_macro(quote! {}, input).to_string();
assert!(expanded.contains("SearchIndexed for Reading"), "{expanded}");
assert!(
!expanded.contains("__autumn_tenant"),
"a non-String tenant_id must not be extracted: {expanded}"
);
}
#[test]
fn searchable_embed_flag_is_parsed_alongside_the_weight() {
let field: syn::Field = syn::parse_quote! {
#[searchable(weight = "B", embed)]
pub body: String
};
let cfg = parse_field_searchable(&field).expect("parses");
assert!(cfg.embed);
assert!(matches!(
cfg.kind,
FieldSearchable::SearchableWithWeight(ref w) if w == "B"
));
}
#[test]
fn searchable_embed_alone_defaults_the_weight() {
let field: syn::Field = syn::parse_quote! {
#[searchable(embed)]
pub body: String
};
let cfg = parse_field_searchable(&field).expect("parses");
assert!(cfg.embed);
assert!(matches!(cfg.kind, FieldSearchable::SearchableDefault));
}
#[test]
fn searchable_without_embed_leaves_the_flag_off() {
let field: syn::Field = syn::parse_quote! {
#[searchable(weight = "A")]
pub title: String
};
assert!(!parse_field_searchable(&field).expect("parses").embed);
let bare: syn::Field = syn::parse_quote! {
#[searchable]
pub title: String
};
assert!(!parse_field_searchable(&bare).expect("parses").embed);
let none: syn::Field = syn::parse_quote! {
pub title: String
};
let cfg = parse_field_searchable(&none).expect("parses");
assert!(!cfg.embed);
assert!(matches!(cfg.kind, FieldSearchable::NotSearchable));
}
#[test]
fn unknown_searchable_key_names_the_supported_set() {
let field: syn::Field = syn::parse_quote! {
#[searchable(vectorize)]
pub body: String
};
let err = parse_field_searchable(&field).unwrap_err();
let message = err.to_string();
assert!(message.contains("weight"), "{message}");
assert!(message.contains("embed"), "{message}");
}
#[test]
fn duplicate_embed_is_rejected() {
let field: syn::Field = syn::parse_quote! {
#[searchable(embed, embed)]
pub body: String
};
let err = parse_field_searchable(&field).unwrap_err();
assert!(err.to_string().contains("duplicate"), "{err}");
}
#[test]
fn two_embed_fields_are_rejected_by_the_model_macro() {
let input: TokenStream = quote! {
#[searchable(language = "english")]
pub struct Article {
#[id]
pub id: i64,
#[searchable(weight = "A", embed)]
pub title: String,
#[searchable(weight = "B", embed)]
pub body: String,
}
};
let expanded = model_macro(quote! {}, input).to_string();
assert!(
expanded.contains("only one field may be marked"),
"{expanded}"
);
}
#[test]
fn embed_without_a_model_level_searchable_is_rejected() {
let input: TokenStream = quote! {
pub struct Article {
#[id]
pub id: i64,
#[searchable(embed)]
pub body: String,
}
};
let expanded = model_macro(quote! {}, input).to_string();
assert!(
expanded.contains("requires the model to be marked"),
"{expanded}"
);
}
#[test]
fn searchable_model_emits_the_search_indexed_impl() {
let input: TokenStream = quote! {
#[searchable(language = "english")]
pub struct Article {
#[id]
pub id: i64,
#[searchable(weight = "A")]
pub title: String,
#[searchable(weight = "B", embed)]
pub body: String,
}
};
let expanded = model_macro(quote! {}, input).to_string();
assert!(expanded.contains("SearchIndexed for Article"), "{expanded}");
assert!(expanded.contains("SEARCH_EMBED_FIELD"), "{expanded}");
}
#[test]
fn non_searchable_model_emits_no_search_indexed_impl() {
let input: TokenStream = quote! {
pub struct Article {
#[id]
pub id: i64,
pub title: String,
}
};
let expanded = model_macro(quote! {}, input).to_string();
assert!(
!expanded.contains("SearchIndexed for Article"),
"{expanded}"
);
}
#[test]
fn searchable_model_with_a_non_i64_key_emits_no_search_indexed_impl() {
let input: TokenStream = quote! {
#[searchable]
pub struct Article {
#[id]
pub id: uuid::Uuid,
#[searchable]
pub title: String,
}
};
let expanded = model_macro(quote! {}, input).to_string();
assert!(
!expanded.contains("SearchIndexed for Article"),
"{expanded}"
);
}
#[test]
fn already_skips_serialization_with_nested_list_before_skip() {
let field: syn::Field = syn::parse_quote! {
#[serde(bound(serialize = "T: Clone"), skip_serializing)]
pub secret: String
};
assert!(
field_already_skips_serialization(&field),
"a nested list before `skip_serializing` must not hide the skip"
);
}
#[test]
fn private_attr_filtered_from_user_attrs() {
let field: syn::Field = syn::parse_quote! {
#[private]
pub password_hash: String
};
let attrs = user_attrs(&field);
assert!(
attrs.iter().all(|a| !a.path().is_ident("private")),
"`#[private]` must be stripped from the query struct's attrs"
);
}
#[test]
fn private_field_gets_skip_serializing_in_query_struct() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct User {
#[id]
pub id: i64,
pub email: String,
#[private]
pub password_hash: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("skip_serializing"),
"a `#[private]` field must emit `#[serde(skip_serializing)]`: {generated}"
);
assert!(
generated.contains("pub password_hash : String"),
"the `#[private]` column must remain a normal queryable field: {generated}"
);
}
#[test]
fn private_field_still_writable_on_new_struct() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct User {
#[id]
pub id: i64,
pub email: String,
#[private]
pub password_hash: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("struct NewUser"),
"NewUser must be generated: {generated}"
);
let new_start = generated.find("struct NewUser").unwrap();
let new_section = &generated[new_start..new_start + 400];
assert!(
new_section.contains("password_hash"),
"NewUser must still bind the `#[private]` column for writes: {new_section}"
);
}
#[test]
fn encrypted_field_is_private_in_json_by_default() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Account {
#[id]
pub id: i64,
#[encrypted]
pub ssn: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("skip_serializing"),
"an `#[encrypted]` field must be skipped from Serialize by default: {generated}"
);
}
#[test]
fn encrypted_admin_visible_field_is_serialized() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Account {
#[id]
pub id: i64,
#[encrypted(admin_visible)]
pub tier: String,
}
},
);
let generated = output.to_string();
assert!(
!generated.contains("skip_serializing"),
"`admin_visible` encrypted field must remain serialized: {generated}"
);
}
#[test]
fn public_field_is_not_skipped() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Widget {
#[id]
pub id: i64,
pub name: String,
}
},
);
let generated = output.to_string();
assert!(
!generated.contains("skip_serializing"),
"a model with no private/encrypted fields must not skip anything: {generated}"
);
}
#[test]
fn normalize_attr_filtered_from_user_attrs() {
let field: syn::Field = syn::parse_quote! {
#[normalize(trim, downcase)]
pub email: String
};
let attrs = user_attrs(&field);
assert!(
attrs.iter().all(|a| !a.path().is_ident("normalize")),
"`#[normalize]` must be stripped from the query struct's attrs"
);
}
#[test]
fn parse_field_normalize_reads_builtins_left_to_right() {
let field: syn::Field = syn::parse_quote! {
#[normalize(trim, downcase, squish, upcase)]
pub email: String
};
let ops = parse_field_normalize(&field).unwrap();
assert_eq!(ops.len(), 4);
assert!(matches!(ops[0], Normalizer::Trim));
assert!(matches!(ops[1], Normalizer::Downcase));
assert!(matches!(ops[2], Normalizer::Squish));
assert!(matches!(ops[3], Normalizer::Upcase));
}
#[test]
fn parse_field_normalize_reads_with_escape_hatch() {
let field: syn::Field = syn::parse_quote! {
#[normalize(trim, with = my_crate::canonicalize)]
pub slug: String
};
let ops = parse_field_normalize(&field).unwrap();
assert_eq!(ops.len(), 2);
assert!(matches!(ops[0], Normalizer::Trim));
assert!(matches!(ops[1], Normalizer::With(_)));
}
#[test]
fn normalize_without_normalizers_is_rejected() {
let err_msg = |field: &syn::Field| match parse_field_normalize(field) {
Ok(_) => panic!("expected `#[normalize]` to error"),
Err(e) => e.to_string(),
};
let bare: syn::Field = syn::parse_quote! {
#[normalize]
pub email: String
};
assert!(
err_msg(&bare).contains("requires at least one normalizer"),
"bare `#[normalize]` must error"
);
let empty: syn::Field = syn::parse_quote! {
#[normalize()]
pub email: String
};
assert!(
err_msg(&empty).contains("requires at least one normalizer"),
"empty `#[normalize()]` must error"
);
}
#[test]
fn normalize_non_string_field_is_rejected() {
let field: syn::Field = syn::parse_quote! {
#[normalize(trim)]
pub age: i64
};
let err = validate_normalize_field(&field).unwrap_err();
assert!(
err.to_string().contains("String"),
"error must mention String: {err}"
);
let field: syn::Field = syn::parse_quote! {
#[normalize(trim)]
pub nickname: Option<String>
};
assert!(validate_normalize_field(&field).is_err());
}
#[test]
fn normalize_generates_normalize_and_lookup_impls() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct User {
#[id]
pub id: i64,
#[normalize(trim, downcase)]
pub email: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("impl :: autumn_web :: normalize :: Normalize for NewUser"),
"must generate `impl Normalize for NewUser`: {generated}"
);
assert!(
generated.contains("impl :: autumn_web :: normalize :: NormalizedModel for User"),
"must generate `impl NormalizedModel for User`: {generated}"
);
assert!(
generated.contains("\"email\""),
"normalize_lookup must match the `email` column: {generated}"
);
assert!(
generated.contains("normalize :: trim") && generated.contains("normalize :: downcase"),
"must chain the trim+downcase builtins: {generated}"
);
}
#[test]
fn update_model_drops_non_declarative_validators_but_new_model_keeps_them() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct User {
#[id]
pub id: i64,
#[validate(length(min = 1), custom(function = "v"))]
pub name: String,
}
},
);
let generated = output.to_string();
let new_start = generated
.find("struct NewUser")
.expect("NewUser struct must be generated");
let new_end = new_start
+ generated[new_start..]
.find('}')
.expect("NewUser struct must close");
let new_section = &generated[new_start..new_end];
assert!(
new_section.contains("length"),
"NewUser must keep the `length` validator: {new_section}"
);
assert!(
new_section.contains("custom"),
"NewUser must keep the `custom` validator: {new_section}"
);
let upd_start = generated
.find("struct UpdateUser")
.expect("UpdateUser struct must be generated");
let upd_end = upd_start
+ generated[upd_start..]
.find('}')
.expect("UpdateUser struct must close");
let upd_section = &generated[upd_start..upd_end];
assert!(
upd_section.contains("length"),
"UpdateUser must keep the declarative `length` validator: {upd_section}"
);
assert!(
!upd_section.contains("custom"),
"UpdateUser must NOT carry the non-declarative `custom` validator: {upd_section}"
);
}
#[test]
fn read_model_keeps_full_validator_set_and_from_patch_validates_merged_model() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct User {
#[id]
pub id: i64,
#[validate(length(min = 1), custom(function = "v"))]
pub name: String,
}
},
);
let generated = output.to_string();
let read_start = generated
.find("struct User")
.expect("read model struct must be generated");
let read_end = read_start
+ generated[read_start..]
.find('}')
.expect("read model struct must close");
let read_section = &generated[read_start..read_end];
assert!(
read_section.contains("length") && read_section.contains("custom"),
"read model must keep the FULL validator set (incl. `custom`, which the \
Patch<T> update fields drop) so the merged model can enforce it: {read_section}"
);
let derive_count = generated.matches("validator :: Validate").count();
assert_eq!(
derive_count, 3,
"read model, NewModel, and UpdateModel must each derive `validator::Validate`: {generated}"
);
assert!(
generated.contains("from_patch") && generated.contains("autumn_maybe_validate"),
"from_patch must validate the merged model via autumn_maybe_validate: {generated}"
);
}
#[test]
fn read_model_without_validation_does_not_derive_validate_or_validate_on_merge() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Plain {
#[id]
pub id: i64,
pub name: String,
}
},
);
let generated = output.to_string();
assert!(
!generated.contains("validator :: Validate"),
"an unvalidated model must not derive `validator::Validate`: {generated}"
);
assert!(
!generated.contains("autumn_maybe_validate"),
"an unvalidated model's from_patch must not emit a merged-model check: {generated}"
);
}
#[test]
fn update_model_drops_every_non_patch_validator_but_new_model_keeps_them() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Signup {
#[id]
pub id: i64,
#[validate(
length(min = 1),
must_match(other = "confirm"),
nested,
credit_card,
non_control_character
)]
pub password: String,
pub confirm: String,
}
},
);
let generated = output.to_string();
let new_start = generated
.find("struct NewSignup")
.expect("NewSignup struct must be generated");
let new_end = new_start
+ generated[new_start..]
.find('}')
.expect("NewSignup struct must close");
let new_section = &generated[new_start..new_end];
for kept in [
"length",
"must_match",
"nested",
"credit_card",
"non_control_character",
] {
assert!(
new_section.contains(kept),
"NewSignup must keep the `{kept}` validator (enforced on create): {new_section}"
);
}
let upd_start = generated
.find("struct UpdateSignup")
.expect("UpdateSignup struct must be generated");
let upd_end = upd_start
+ generated[upd_start..]
.find('}')
.expect("UpdateSignup struct must close");
let upd_section = &generated[upd_start..upd_end];
assert!(
upd_section.contains("length"),
"UpdateSignup must keep the declarative `length` validator: {upd_section}"
);
for dropped in [
"must_match",
"nested",
"credit_card",
"non_control_character",
] {
assert!(
!upd_section.contains(dropped),
"UpdateSignup must NOT carry the non-Patch `{dropped}` validator \
(would break UpdateModel compilation / has no Patch<T> impl): {upd_section}"
);
}
}
#[test]
fn update_model_drops_does_not_contain_but_new_model_keeps_it() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Doc {
#[id]
pub id: i64,
#[validate(length(min = 1), contains(pattern = "ok"), does_not_contain(pattern = "bad"))]
pub name: String,
}
},
);
let generated = output.to_string();
let new_start = generated
.find("struct NewDoc")
.expect("NewDoc struct must be generated");
let new_end = new_start
+ generated[new_start..]
.find('}')
.expect("NewDoc struct must close");
let new_section = &generated[new_start..new_end];
assert!(
new_section.contains("does_not_contain"),
"NewDoc must keep the `does_not_contain` validator: {new_section}"
);
assert!(
new_section.contains("contains"),
"NewDoc must keep the `contains` validator: {new_section}"
);
assert!(
new_section.contains("length"),
"NewDoc must keep the `length` validator: {new_section}"
);
let upd_start = generated
.find("struct UpdateDoc")
.expect("UpdateDoc struct must be generated");
let upd_end = upd_start
+ generated[upd_start..]
.find('}')
.expect("UpdateDoc struct must close");
let upd_section = &generated[upd_start..upd_end];
assert!(
!upd_section.contains("does_not_contain"),
"UpdateDoc must NOT carry `does_not_contain` (inverts the Patch skip \
value into a spurious 422 for omitted fields): {upd_section}"
);
assert!(
upd_section.contains("contains"),
"UpdateDoc must keep the declarative `contains` validator: {upd_section}"
);
assert!(
upd_section.contains("length"),
"UpdateDoc must keep the declarative `length` validator: {upd_section}"
);
}
#[test]
fn update_model_retains_required_on_patch_fields() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct User {
#[id]
pub id: i64,
#[validate(required)]
pub nickname: Option<String>,
}
},
);
let generated = output.to_string();
let new_start = generated
.find("struct NewUser")
.expect("NewUser struct must be generated");
let new_end = new_start
+ generated[new_start..]
.find('}')
.expect("NewUser struct must close");
let new_section = &generated[new_start..new_end];
assert!(
new_section.contains("required"),
"NewUser must keep the `required` validator: {new_section}"
);
let upd_start = generated
.find("struct UpdateUser")
.expect("UpdateUser struct must be generated");
let upd_end = upd_start
+ generated[upd_start..]
.find('}')
.expect("UpdateUser struct must close");
let upd_section = &generated[upd_start..upd_end];
assert!(
upd_section.contains("required"),
"UpdateUser must RETAIN the `required` validator so a PATCH sending \
`null` (Patch::Clear) is rejected with 422: {upd_section}"
);
}
#[test]
fn update_model_drops_ip_only_on_option_fields_new_model_keeps_all() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Server {
#[id]
pub id: i64,
#[validate(ip, length(min = 1))]
pub ip: Option<String>,
#[validate(ip)]
pub ip2: String,
#[validate(length(min = 1))]
pub name: Option<String>,
}
},
);
let generated = output.to_string();
let new_start = generated
.find("struct NewServer")
.expect("NewServer struct must be generated");
let new_end = new_start
+ generated[new_start..]
.find('}')
.expect("NewServer struct must close");
let new_section = &generated[new_start..new_end];
assert!(
new_section.contains("validate (ip , length"),
"NewServer must keep the `ip` (and `length`) validator on the \
Option<String> field: {new_section}"
);
assert!(
new_section.contains("validate (ip)"),
"NewServer must keep the `ip` validator on the non-Option field: {new_section}"
);
let upd_start = generated
.find("struct UpdateServer")
.expect("UpdateServer struct must be generated");
let upd_end = upd_start
+ generated[upd_start..]
.find('}')
.expect("UpdateServer struct must close");
let upd_section = &generated[upd_start..upd_end];
assert_eq!(
upd_section.matches("validate (ip").count(),
1,
"UpdateServer must retain `ip` on ONLY the non-Option `ip2` field \
(the Option<String> `ip` field must drop it — no `impl ValidateIp \
for Option<T>`): {upd_section}"
);
assert!(
upd_section.contains("validate (ip)"),
"UpdateServer's non-Option `ip2` field must RETAIN the `ip` \
validator (Patch<String>: ValidateIp holds via the ToString \
blanket): {upd_section}"
);
assert!(
!upd_section.contains("validate (ip ,"),
"UpdateServer's Option<String> `ip` field must DROP the `ip` \
validator: {upd_section}"
);
assert_eq!(
upd_section.matches("length (min = 1)").count(),
2,
"UpdateServer must KEEP `length` on both Option fields (`ip` \
leftover after dropping ip, and `name`): {upd_section}"
);
}
#[test]
fn normalize_runs_in_from_patch_update_path() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct User {
#[id]
pub id: i64,
#[normalize(trim, downcase)]
pub email: String,
}
},
);
let generated = output.to_string();
let fp = generated
.find("fn from_patch")
.expect("from_patch must be generated");
let section = &generated[fp..fp + 1200];
assert!(
section.contains("normalize"),
"from_patch (update path) must normalize the draft: {section}"
);
}
#[test]
fn model_without_normalize_still_impls_normalized_model() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Widget {
#[id]
pub id: i64,
pub name: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("impl :: autumn_web :: normalize :: NormalizedModel for Widget"),
"every model must impl NormalizedModel: {generated}"
);
}
#[test]
fn normalize_lookup_keys_on_rust_field_name_not_serde_rename() {
let output = model_macro(
TokenStream::new(),
quote! {
#[serde(rename_all = "camelCase")]
pub struct User {
#[id]
pub id: i64,
#[normalize(trim, downcase)]
pub display_name: String,
}
},
);
let generated = output.to_string();
let fp = generated
.find("fn normalize_lookup")
.expect("normalize_lookup must be generated");
let end = (fp + 600).min(generated.len());
let section = &generated[fp..end];
assert!(
section.contains("\"display_name\""),
"normalize_lookup arm must key on the Rust field name `display_name`: {section}"
);
assert!(
!section.contains("\"displayName\""),
"normalize_lookup arm must NOT key on the serde-renamed name `displayName`: {section}"
);
}
#[test]
fn lock_version_filtered_from_user_attrs() {
let field: syn::Field = syn::parse_quote! {
#[lock_version]
pub version: i32
};
let attrs = user_attrs(&field);
assert!(attrs.is_empty());
}
#[test]
fn model_managed_arg_accepted_by_parse_attr_args() {
let args = parse_attr_args(quote! { managed }).expect("`managed` must parse");
assert!(
args.managed,
"`#[model(managed)]` must set the managed flag"
);
assert!(args.table.is_none());
}
#[test]
fn model_managed_and_table_accepted_together() {
let args =
parse_attr_args(quote! { table = "accounts", managed }).expect("both args must parse");
assert!(args.managed);
assert_eq!(args.table.as_deref(), Some("accounts"));
}
#[test]
fn model_managed_with_value_rejected() {
let err = parse_attr_args(quote! { managed = true })
.expect_err("`managed = ...` must be rejected");
assert!(
err.to_string().contains("`managed` takes no value"),
"unexpected message: {err}"
);
}
#[test]
fn model_unknown_arg_still_rejected() {
let err =
parse_attr_args(quote! { bogus }).expect_err("an unknown `#[model]` arg must error");
assert!(
err.to_string()
.contains("unsupported `#[model(...)]` argument"),
"unexpected message: {err}"
);
}
#[test]
fn empty_model_args_default() {
let args = parse_attr_args(TokenStream::new()).expect("empty args must parse");
assert!(!args.managed);
assert!(args.table.is_none());
}
#[test]
fn unique_and_references_filtered_from_user_attrs() {
let field: syn::Field = syn::parse_quote! {
#[unique]
#[references(table = "accounts")]
pub account_id: i64
};
let attrs = user_attrs(&field);
assert!(
attrs.is_empty(),
"`#[unique]` / `#[references]` must be stripped: {attrs:?}",
attrs = attrs
.iter()
.map(|a| quote!(#a).to_string())
.collect::<Vec<_>>()
);
}
#[test]
fn bare_unique_and_references_pass_validation() {
let field: syn::Field = syn::parse_quote! {
#[unique]
#[references]
pub account_id: i64
};
validate_field_schema_markers(&field).expect("bare markers must validate");
let explicit: syn::Field = syn::parse_quote! {
#[references(table = "accounts")]
pub account_id: i64
};
validate_field_schema_markers(&explicit).expect("explicit references must validate");
}
#[test]
fn unique_with_args_rejected() {
let field: syn::Field = syn::parse_quote! {
#[unique(x)]
pub account_id: i64
};
let err =
validate_field_schema_markers(&field).expect_err("`#[unique(...)]` must be rejected");
assert!(
err.to_string().contains("`#[unique]` takes no arguments"),
"unexpected message: {err}"
);
}
#[test]
fn references_with_bad_key_rejected() {
let field: syn::Field = syn::parse_quote! {
#[references(bogus = "x")]
pub account_id: i64
};
let err = validate_field_schema_markers(&field)
.expect_err("`#[references(bogus = ...)]` must be rejected");
assert!(
err.to_string()
.contains("unsupported `#[references(...)]` argument"),
"unexpected message: {err}"
);
}
#[test]
fn model_managed_with_paren_args_rejected() {
let err =
parse_attr_args(quote! { managed(foo) }).expect_err("`managed(...)` must be rejected");
assert!(
err.to_string().contains("`managed` takes no value"),
"unexpected message: {err}"
);
}
#[test]
fn references_namevalue_rejected() {
let field: syn::Field = syn::parse_quote! {
#[references = "accounts"]
pub account_id: i64
};
let err = validate_field_schema_markers(&field)
.expect_err("`#[references = \"...\"]` must be rejected");
assert!(
err.to_string()
.contains("`#[references]` must be a bare attribute"),
"unexpected message: {err}"
);
}
#[test]
fn unique_namevalue_rejected() {
let field: syn::Field = syn::parse_quote! {
#[unique = "x"]
pub account_id: i64
};
let err = validate_field_schema_markers(&field)
.expect_err("`#[unique = \"x\"]` must be rejected");
assert!(
err.to_string().contains("`#[unique]` takes no arguments"),
"unexpected message: {err}"
);
}
#[test]
fn schema_markers_do_not_change_codegen() {
let with_markers = model_macro(
quote! { managed },
quote! {
pub struct Membership {
#[id]
pub id: i64,
#[unique]
#[references(table = "accounts")]
pub account_id: i64,
}
},
)
.to_string();
let without_markers = model_macro(
TokenStream::new(),
quote! {
pub struct Membership {
#[id]
pub id: i64,
pub account_id: i64,
}
},
)
.to_string();
assert_eq!(
with_markers, without_markers,
"schema markers must not alter generated code"
);
}
#[test]
fn model_commit_hook_codec_includes_serde_skipped_fields() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Account {
#[id]
pub id: i64,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
#[serde(skip)]
pub reset_token: Option<String>,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("__autumn_commit_hook_to_value")
&& generated.contains("__autumn_commit_hook_from_value"),
"models must implement the full-fidelity commit hook codec: {generated}"
);
assert!(
generated.contains("\"password_hash\""),
"commit hook codec must serialize skip_serializing fields: {generated}"
);
assert!(
generated.contains("\"reset_token\""),
"commit hook codec must serialize skip fields instead of defaulting them: {generated}"
);
}
#[test]
fn model_commit_hook_codec_preserves_serde_adapters() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct LedgerEntry {
#[id]
pub id: i64,
#[serde(with = "cents_adapter")]
pub amount_cents: i64,
#[serde(
serialize_with = "token_adapter::serialize",
deserialize_with = "token_adapter::deserialize"
)]
pub external_token: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("__AutumnCommitHookSerializeField"),
"commit hook codec must serialize adapted fields through serde field helpers: {generated}"
);
assert!(
generated.contains("__AutumnCommitHookDeserializeField"),
"commit hook codec must deserialize adapted fields through serde field helpers: {generated}"
);
assert!(
generated.contains("with = \"cents_adapter\""),
"commit hook codec must preserve serde with adapters: {generated}"
);
assert!(
generated.contains("serialize_with = \"token_adapter::serialize\""),
"commit hook codec must preserve serialize_with adapters: {generated}"
);
assert!(
generated.contains("deserialize_with = \"token_adapter::deserialize\""),
"commit hook codec must preserve deserialize_with adapters: {generated}"
);
}
#[test]
fn model_commit_hook_codec_defaults_missing_compatible_fields() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Account {
#[id]
pub id: i64,
pub reset_token: Option<String>,
#[serde(default = "default_reset_token")]
pub special_token: Option<String>,
#[serde(default)]
pub display_name: String,
#[serde(default = "default_status")]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains(":: core :: option :: Option :: None"),
"missing Option fields in old durable payloads should default to None: {generated}"
);
assert!(
generated.contains(":: core :: default :: Default :: default ()"),
"missing #[serde(default)] fields in old durable payloads should use Default::default(): {generated}"
);
assert!(
generated.contains("default_status ()"),
"missing #[serde(default = \"...\")] fields in old durable payloads should call the configured default function: {generated}"
);
assert!(
generated.contains("default_reset_token ()"),
"explicit serde defaults should beat the generic Option::None fallback: {generated}"
);
}
#[test]
fn pascal_to_snake_simple() {
assert_eq!(pascal_to_snake("User"), "user");
}
#[test]
fn pascal_to_snake_multi_word() {
assert_eq!(pascal_to_snake("BlogPost"), "blog_post");
}
#[test]
fn pascal_to_snake_three_words() {
assert_eq!(
pascal_to_snake("UserProfileSettings"),
"user_profile_settings"
);
}
#[test]
fn pascal_case_simple() {
assert_eq!(pascal_case("title"), "Title");
}
#[test]
fn pascal_case_multi_word() {
assert_eq!(pascal_case("approved_at"), "ApprovedAt");
}
#[test]
fn pascal_case_single_char() {
assert_eq!(pascal_case("x"), "X");
}
#[test]
fn infer_table_name_simple() {
let ident = syn::Ident::new("User", proc_macro2::Span::call_site());
assert_eq!(infer_table_name(&ident), "users");
}
#[test]
fn infer_table_name_multi_word() {
let ident = syn::Ident::new("BlogPost", proc_macro2::Span::call_site());
assert_eq!(infer_table_name(&ident), "blog_posts");
}
#[test]
fn infer_table_name_irregular_plurals() {
let cases = [
("Category", "categories"),
("Company", "companies"),
("City", "cities"),
("Story", "stories"),
("Box", "boxes"),
("Buzz", "buzzes"),
("Class", "classes"),
("Watch", "watches"),
("Dish", "dishes"),
("Person", "people"),
("Child", "children"),
("Post", "posts"),
("Node", "nodes"),
("Comment", "comments"),
("BlogPost", "blog_posts"),
("Day", "days"),
];
for (input, expected) in cases {
let ident = syn::Ident::new(input, proc_macro2::Span::call_site());
assert_eq!(infer_table_name(&ident), expected, "input: {input}");
}
}
#[test]
fn pluralize_word_matches_canonical_rules() {
assert_eq!(pluralize_word(""), "");
assert_eq!(pluralize_word("category"), "categories");
assert_eq!(pluralize_word("day"), "days");
assert_eq!(pluralize_word("box"), "boxes");
assert_eq!(pluralize_word("buzz"), "buzzes");
assert_eq!(pluralize_word("class"), "classes");
assert_eq!(pluralize_word("watch"), "watches");
assert_eq!(pluralize_word("dish"), "dishes");
assert_eq!(pluralize_word("person"), "people");
assert_eq!(pluralize_word("goose"), "geese");
assert_eq!(pluralize_word("post"), "posts");
}
#[test]
fn lock_version_model_emits_etag_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Post {
#[id]
pub id: i64,
pub title: String,
#[lock_version]
pub lock_version: i64,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("pub fn etag"),
"model with #[lock_version] must emit `pub fn etag`: {generated}"
);
}
#[test]
fn model_without_lock_version_does_not_emit_etag_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
},
);
let generated = output.to_string();
assert!(
!generated.contains("pub fn etag"),
"model without #[lock_version] must NOT emit `pub fn etag`: {generated}"
);
}
#[test]
fn etag_method_calls_into_etag_on_lock_version_field() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Post {
#[id]
pub id: i64,
pub title: String,
#[lock_version]
pub lock_version: i64,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("IntoETag") || generated.contains("into_etag"),
"etag() must call IntoETag::into_etag on the lock_version field: {generated}"
);
assert!(
generated.contains("lock_version"),
"etag() method body must reference the lock_version field: {generated}"
);
}
#[test]
fn state_machine_emits_can_transition_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
processing -> shipped,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("can_transition_status_to"),
"#[state_machine] must emit `can_transition_status_to`: {generated}"
);
}
#[test]
fn state_machine_emits_transition_to_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("transition_status_to"),
"#[state_machine] must emit `transition_status_to`: {generated}"
);
}
#[test]
fn state_machine_emits_transitions_constant() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
processing -> shipped,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("__AUTUMN_SM_STATUS_TRANSITIONS"),
"#[state_machine] must emit `__AUTUMN_SM_STATUS_TRANSITIONS` constant: {generated}"
);
}
#[test]
fn state_machine_transition_table_contains_from_to_pairs() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
processing -> shipped,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("\"pending\"") && generated.contains("\"processing\""),
"transition table must contain the from/to state strings: {generated}"
);
assert!(
generated.contains("\"shipped\""),
"transition table must contain all destination states: {generated}"
);
}
#[test]
fn list_helpers_allowlist_is_typed_and_injection_safe() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub views: i64,
pub published: bool,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("fn __autumn_list_apply_order"),
"model must generate the ordering helper: {generated}"
);
assert!(
generated.contains("Some (\"title\")")
&& generated.contains("Some (\"views\")")
&& generated.contains("Some (\"published\")"),
"each real column must be an allowlisted sort arm: {generated}"
);
assert!(
generated.contains("__q . order (posts :: id . desc ())"),
"unknown sort must fall back to the primary-key default order: {generated}"
);
assert!(
generated.contains("fn __autumn_list_apply_filters"),
"model must generate the filter helper: {generated}"
);
assert!(
generated.contains("posts :: title . eq")
&& generated.contains("posts :: views . eq")
&& generated.contains("posts :: published . eq"),
"filters must be typed column equality on allowlisted columns: {generated}"
);
let order_start = generated
.find("fn __autumn_list_apply_order")
.expect("order helper present");
let filter_start = generated
.find("fn __autumn_list_apply_filters")
.expect("filter helper present");
let helpers_region = {
let lo = order_start.min(filter_start);
&generated[lo..(lo + 4000).min(generated.len())]
};
assert!(
!helpers_region.contains("sql_query") && !helpers_region.contains("ORDER BY"),
"sort/filter must never build raw SQL from request input: {helpers_region}"
);
assert!(
!helpers_region.contains("format !"),
"sort/filter must never string-format a column into a query: {helpers_region}"
);
}
#[test]
fn state_machine_with_guard_calls_guard_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
processing -> shipped: "can_ship",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("can_ship"),
"guarded transition must call the guard method `can_ship`: {generated}"
);
}
#[test]
fn state_machine_guard_stored_in_transition_table() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
processing -> shipped: "can_ship",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("Some (\"can_ship\")") || generated.contains("Some(\"can_ship\")"),
"guarded transition must store the guard name in the transition table: {generated}"
);
}
#[test]
fn state_machine_unguarded_transition_table_entry_has_none() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("None"),
"unguarded transition must store None in the transition table: {generated}"
);
}
#[test]
fn state_machine_transition_method_returns_autumn_result() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("AutumnResult"),
"transition_*_to must return AutumnResult: {generated}"
);
}
#[test]
fn state_machine_attribute_not_leaked_to_diesel_struct() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
))]
pub status: String,
}
},
);
let generated = output.to_string();
let struct_block = generated
.find("pub struct Order")
.map_or("", |i| &generated[i..i + 500]);
assert!(
!struct_block.contains("# [state_machine]")
&& !struct_block.contains("#[state_machine]"),
"`state_machine` attribute must not appear on the generated Diesel struct field: {struct_block}"
);
}
#[test]
fn state_machine_on_non_string_field_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(pending -> processing))]
pub amount: i64,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("only supported on `String` fields"),
"#[state_machine] on a non-String field must emit a compile error: {generated}"
);
}
#[test]
fn state_machine_duplicate_attribute_on_same_field_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(pending -> processing))]
#[state_machine(transitions(processing -> shipped))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("multiple `#[state_machine]` attributes are not allowed"),
"duplicate #[state_machine] on same field must emit a compile error: {generated}"
);
}
#[test]
fn state_machine_invalid_guard_identifier_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing: "can-ship",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("not a valid Rust identifier"),
"invalid guard identifier must emit a compile error: {generated}"
);
}
#[test]
fn state_machine_raw_identifier_field_generates_clean_names() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
))]
pub r#type: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("can_transition_type_to"),
"raw identifier field must strip r# prefix for generated method name: {generated}"
);
assert!(
generated.contains("__AUTUMN_SM_TYPE_TRANSITIONS"),
"raw identifier field must strip r# prefix for generated const name: {generated}"
);
}
#[test]
fn state_machine_without_on_commit_does_not_emit_on_conn_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
processing -> shipped: "can_ship",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
!generated.contains("transition_status_to_on_conn"),
"no `on_commit` edge must not emit the `_on_conn` method: {generated}"
);
assert!(
!generated.contains("TransitionEffect") && !generated.contains("enqueue_on_conn"),
"no `on_commit` edge must not emit any effect codegen: {generated}"
);
}
#[test]
fn state_machine_on_commit_emits_on_conn_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
processing -> shipped: on_commit = SendShippedEmailJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("transition_status_to_on_conn"),
"an `on_commit` edge must emit the connection-taking method: {generated}"
);
assert!(
generated.contains("AsyncPgConnection"),
"the `_on_conn` method must take a connection: {generated}"
);
assert!(
generated.contains("enqueue_on_conn")
&& generated.contains("< SendShippedEmailJob > :: NAME"),
"the effect must enqueue the named job on the connection: {generated}"
);
}
#[test]
fn state_machine_on_commit_enqueues_only_on_the_named_edge() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
processing -> shipped: on_commit = SendShippedEmailJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert_eq!(
generated.matches("enqueue_on_conn").count(),
1,
"exactly the one `on_commit` edge must enqueue: {generated}"
);
assert!(
generated.contains("(\"processing\" , \"shipped\")"),
"the effect arm must match the fired edge: {generated}"
);
}
#[test]
fn state_machine_on_commit_derives_idempotency_key_from_edge_context() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
processing -> shipped: on_commit = SendShippedEmailJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("idempotency_key"),
"the effect must carry a derived idempotency key: {generated}"
);
assert!(
generated.contains("\"{}:{}:{}:{}:{}\""),
"the idempotency key must combine model/field/record_id/from/to: {generated}"
);
assert!(
generated.contains("\"Order\"") && generated.contains("\"status\""),
"the key must embed the model and field names: {generated}"
);
assert!(
generated.contains("self . id"),
"the record id must come from the primary key: {generated}"
);
}
#[test]
fn state_machine_guard_and_on_commit_compose_on_one_edge() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Article {
#[id]
pub id: i64,
#[state_machine(transitions(
draft -> published,
published -> archived: guard = "can_archive", on_commit = AnnounceArchiveJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("Some (\"can_archive\")"),
"guard must remain in the transition table when composed with on_commit: {generated}"
);
assert!(
generated.contains("self . can_archive ()"),
"guarded+effect edge must still call the guard in `can_transition_*`: {generated}"
);
assert!(
generated.contains("transition_status_to_on_conn")
&& generated.contains("< AnnounceArchiveJob > :: NAME"),
"the composed edge must also enqueue its on_commit job: {generated}"
);
}
#[test]
fn state_machine_on_commit_unknown_meta_key_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
processing -> shipped: bogus = "x",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("compile_error") && generated.contains("on_commit = <Job>"),
"an unknown per-edge meta key must emit a compile error: {generated}"
);
assert!(
generated.contains("`on = "),
"the unknown per-edge meta key error must mention `on = \"...\"`: {generated}"
);
}
#[test]
fn state_machine_on_sync_effect_emits_on_conn_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
pending -> processing,
processing -> shipped: on = "record_audit",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("transition_status_to_on_conn"),
"an `on` edge must emit the connection-taking method: {generated}"
);
assert!(
generated.contains("AsyncPgConnection"),
"the `_on_conn` method must take a connection: {generated}"
);
assert!(
generated.contains("self . record_audit (conn) . await ?"),
"the `on` edge must call its handler in-transaction: {generated}"
);
}
#[test]
fn state_machine_on_sync_effect_only_does_not_enqueue() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
processing -> shipped: on = "record_audit",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("transition_status_to_on_conn"),
"an `on` edge must still emit the connection-taking method: {generated}"
);
assert!(
!generated.contains("enqueue_on_conn") && !generated.contains("TransitionEffect"),
"an `on`-only machine must not emit after-commit effect codegen: {generated}"
);
}
#[test]
fn state_machine_on_and_on_commit_compose_on_one_edge() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
processing -> shipped: on = "record_audit", on_commit = SendShippedEmailJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
let sync_at = generated
.find("self . record_audit (conn) . await ?")
.expect("sync handler call must be emitted");
let commit_at = generated
.find("enqueue_on_conn")
.expect("on_commit enqueue must be emitted");
assert!(
sync_at < commit_at,
"the sync `on` handler must run before the `on_commit` enqueue: {generated}"
);
assert!(
generated.contains("< SendShippedEmailJob > :: NAME"),
"the composed edge must enqueue its on_commit job: {generated}"
);
}
#[test]
fn state_machine_guard_and_on_and_on_commit_compose() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Article {
#[id]
pub id: i64,
#[state_machine(transitions(
draft -> published,
published -> archived: guard = "can_archive", on = "audit", on_commit = AnnounceArchiveJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("Some (\"can_archive\")"),
"guard must remain in the transition table when composed: {generated}"
);
assert!(
generated.contains("self . can_archive ()"),
"guarded edge must still call the guard in `can_transition_*`: {generated}"
);
assert!(
generated.contains("self . audit (conn) . await ?"),
"the composed edge must run its sync `on` handler: {generated}"
);
assert!(
generated.contains("< AnnounceArchiveJob > :: NAME"),
"the composed edge must enqueue its on_commit job: {generated}"
);
}
#[test]
fn state_machine_duplicate_on_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(transitions(
processing -> shipped: on = "a", on = "b",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("compile_error") && generated.contains("duplicate `on`"),
"a duplicate `on` on one edge must emit a compile error: {generated}"
);
}
#[test]
fn state_machine_lifecycle_const_aliases_trait_table() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState)]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("__AUTUMN_SM_STATUS_TRANSITIONS"),
"lifecycle SM must emit the transitions const: {generated}"
);
assert!(
generated.contains(
"< OrderState as :: autumn_web :: Lifecycle > :: STATE_MACHINE_TRANSITIONS"
),
"lifecycle SM const must alias the enum's Lifecycle trait table: {generated}"
);
assert!(
!generated.contains("(\"pending\""),
"lifecycle SM must not inline literal edge strings: {generated}"
);
}
#[test]
fn state_machine_lifecycle_emits_predicate_and_transition_methods() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState)]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("can_transition_status_to"),
"lifecycle SM must emit `can_transition_status_to`: {generated}"
);
assert!(
generated.contains("transition_status_to"),
"lifecycle SM must emit `transition_status_to`: {generated}"
);
assert!(
generated.contains("AutumnResult"),
"lifecycle SM `transition_*_to` must return AutumnResult: {generated}"
);
assert!(
generated.contains(". iter ()") && generated.contains(". any"),
"lifecycle SM predicate must iterate the derived table: {generated}"
);
}
#[test]
fn state_machine_lifecycle_accepts_path_reference() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = crate::states::OrderState)]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains(
"< crate :: states :: OrderState as :: autumn_web :: Lifecycle > :: STATE_MACHINE_TRANSITIONS"
),
"lifecycle SM must accept a qualified path to the enum: {generated}"
);
}
#[test]
fn state_machine_lifecycle_on_non_string_field_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState)]
pub amount: i64,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("only supported on `String` fields"),
"lifecycle SM on a non-String field must be rejected: {generated}"
);
}
#[test]
fn state_machine_lifecycle_with_effects_emits_on_conn_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState, effects(
processing -> shipped: on_commit = SendShippedEmailJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("transition_status_to_on_conn"),
"a lifecycle `effects` edge must emit the connection-taking method: {generated}"
);
assert!(
generated.contains(
"< OrderState as :: autumn_web :: Lifecycle > :: STATE_MACHINE_TRANSITIONS"
),
"lifecycle `effects` must not replace the enum-derived transition table: {generated}"
);
assert!(
generated.contains("enqueue_on_conn")
&& generated.contains("< SendShippedEmailJob > :: NAME"),
"the effect must enqueue the named job on the connection: {generated}"
);
assert!(
generated.contains("(\"processing\" , \"shipped\")"),
"the effect arm must match the fired edge: {generated}"
);
}
#[test]
fn state_machine_lifecycle_without_effects_emits_no_on_conn_method() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState)]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
!generated.contains("transition_status_to_on_conn"),
"a lifecycle SM with no `effects` must not emit the `_on_conn` method: {generated}"
);
assert!(
!generated.contains("enqueue_on_conn"),
"a lifecycle SM with no `effects` must not emit after-commit effect codegen: {generated}"
);
}
#[test]
fn state_machine_lifecycle_effects_sync_on_emits_handler_call() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState, effects(
shipped -> archived: on = "audit",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("transition_status_to_on_conn"),
"a lifecycle `on` effect edge must emit the connection-taking method: {generated}"
);
assert!(
generated.contains("self . audit (conn) . await ?"),
"the `on` effect must call its handler in-transaction: {generated}"
);
assert!(
!generated.contains("enqueue_on_conn"),
"an `on`-only lifecycle effect must not emit after-commit codegen: {generated}"
);
}
#[test]
fn state_machine_lifecycle_effects_guard_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState, effects(
processing -> shipped: guard = "can_ship",
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("compile_error"),
"a guard on a lifecycle `effects` edge must be rejected: {generated}"
);
assert!(
generated.contains("guards are not supported"),
"the rejection must mention guards not being supported: {generated}"
);
}
#[test]
fn state_machine_lifecycle_effects_empty_edge_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState, effects(
processing -> shipped,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("compile_error"),
"an effect-less `effects` edge must be rejected: {generated}"
);
assert!(
generated.contains("must declare `on"),
"the rejection must require an `on`/`on_commit` effect: {generated}"
);
}
#[test]
fn state_machine_lifecycle_effects_derive_idempotency_key() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState, effects(
processing -> shipped: on_commit = SendShippedEmailJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("idempotency_key"),
"the effect must carry a derived idempotency key: {generated}"
);
assert!(
generated.contains("\"{}:{}:{}:{}:{}\""),
"the idempotency key must combine model/field/record_id/from/to: {generated}"
);
assert!(
generated.contains("\"Order\"") && generated.contains("\"status\""),
"the key must embed the model and field names: {generated}"
);
assert!(
generated.contains("self . id"),
"the record id must come from the primary key: {generated}"
);
}
#[test]
fn state_machine_lifecycle_effects_emit_edge_validation_asserts() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState, effects(
processing -> shipped: on_commit = SendShippedEmailJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("__transition_edge_declared"),
"each effect edge must be validated against the lifecycle table: {generated}"
);
assert!(
generated.contains("const _ : () ="),
"the edge validation must be a compile-time const assertion: {generated}"
);
assert!(
generated.contains("STATE_MACHINE_TRANSITIONS"),
"the assertion must check the referenced enum's transition table: {generated}"
);
}
#[test]
fn state_machine_lifecycle_without_effects_emits_no_edge_validation() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState)]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
!generated.contains("__transition_edge_declared"),
"a no-effects lifecycle model must emit no edge validation: {generated}"
);
}
#[test]
fn state_machine_lifecycle_effects_normalize_raw_identifier_edges() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(lifecycle = OrderState, effects(
ready -> r#type: on_commit = SendTypedJob,
))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("\"type\""),
"normalized effect edge must use the stripped `type` state: {generated}"
);
assert!(
!generated.contains("\"r#type\""),
"the raw `r#type` form must be stripped before codegen: {generated}"
);
assert!(
generated.contains("__transition_edge_declared"),
"the normalized edge must still be validated at compile time: {generated}"
);
}
#[test]
fn state_machine_unknown_argument_is_rejected() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Order {
#[id]
pub id: i64,
#[state_machine(bogus(pending -> processing))]
pub status: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("expected `transitions(...)` or `lifecycle = <Enum>`"),
"an unknown #[state_machine] argument must be rejected: {generated}"
);
}
#[test]
fn state_machine_multiple_fields_emit_separate_methods() {
let output = model_macro(
TokenStream::new(),
quote! {
pub struct Ticket {
#[id]
pub id: i64,
#[state_machine(transitions(open -> in_progress, in_progress -> closed))]
pub status: String,
#[state_machine(transitions(low -> medium, medium -> high))]
pub priority: String,
}
},
);
let generated = output.to_string();
assert!(
generated.contains("can_transition_status_to"),
"multi-sm model must emit `can_transition_status_to`: {generated}"
);
assert!(
generated.contains("can_transition_priority_to"),
"multi-sm model must emit `can_transition_priority_to`: {generated}"
);
assert!(
generated.contains("transition_status_to"),
"multi-sm model must emit `transition_status_to`: {generated}"
);
assert!(
generated.contains("transition_priority_to"),
"multi-sm model must emit `transition_priority_to`: {generated}"
);
}
#[test]
fn form_control_tokens_gates_datetime_picker_on_zone_param() {
let control = |ty: syn::Type| form_control_tokens(&ty, false).to_string();
let utc = control(syn::parse_quote!(chrono::DateTime<chrono::Utc>));
assert!(utc.contains("FieldControl :: DateTime"), "{utc}");
let local = control(syn::parse_quote!(chrono::DateTime<chrono::Local>));
assert!(local.contains("FieldControl :: DateTime"), "{local}");
let fixed = control(syn::parse_quote!(chrono::DateTime<chrono::FixedOffset>));
assert!(fixed.contains("FieldControl :: Text"), "{fixed}");
let tz = control(syn::parse_quote!(chrono::DateTime<chrono_tz::Tz>));
assert!(tz.contains("FieldControl :: Text"), "{tz}");
let bare = control(syn::parse_quote!(DateTime));
assert!(bare.contains("FieldControl :: Text"), "{bare}");
}
#[test]
fn datetime_local_serde_attr_matches_zone_param() {
let attr = |ty: syn::Type| datetime_local_serde_attr(&ty).map(|t| t.to_string());
let utc = attr(syn::parse_quote!(chrono::DateTime<chrono::Utc>)).unwrap();
assert!(utc.contains("deserialize_datetime_local_utc"), "{utc}");
let local = attr(syn::parse_quote!(chrono::DateTime<chrono::Local>)).unwrap();
assert!(
local.contains("deserialize_datetime_local_local"),
"{local}"
);
assert!(!local.contains("_option"), "{local}");
let nullable = attr(syn::parse_quote!(Option<chrono::DateTime<chrono::Local>>)).unwrap();
assert!(
nullable.contains("deserialize_datetime_local_local_option"),
"{nullable}"
);
assert!(nullable.contains("default"), "{nullable}");
assert_eq!(
attr(syn::parse_quote!(chrono::DateTime<chrono::FixedOffset>)),
None
);
assert_eq!(attr(syn::parse_quote!(DateTime)), None);
}
}