use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, Lit, Meta, Type, parse2};
use crate::crate_path;
pub fn expand_aro_entity(input: TokenStream) -> TokenStream {
match try_expand(input) {
Ok(tokens) => tokens,
Err(err) => err.to_compile_error(),
}
}
struct FieldInfo {
ident: syn::Ident,
ty: Type,
is_id: bool,
skip: bool,
rename: Option<String>,
is_option: bool,
}
struct EntityOpts {
table_name: Option<String>,
}
#[expect(
clippy::too_many_lines,
reason = "proc-macro expansion is inherently sequential; splitting would reduce readability"
)]
fn try_expand(input: TokenStream) -> syn::Result<TokenStream> {
let input: DeriveInput = parse2(input)?;
let struct_name = &input.ident;
let fields = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => &named.named,
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"AroEntity can only be derived for structs with named fields",
));
}
},
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"AroEntity can only be derived for structs",
));
}
};
let opts = parse_struct_attrs(&input)?;
let mut field_infos: Vec<FieldInfo> = Vec::new();
for field in fields {
#[expect(
clippy::unwrap_used,
reason = "named fields are guaranteed to have idents by the match guard above"
)]
let ident = field.ident.clone().unwrap();
let ty = field.ty.clone();
let mut is_id = false;
let mut skip = false;
let mut rename = None;
for attr in &field.attrs {
if !attr.path().is_ident("aro") {
continue;
}
let nested = attr.parse_args_with(
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
)?;
for meta in &nested {
match meta {
Meta::Path(p) if p.is_ident("id") => is_id = true,
Meta::Path(p) if p.is_ident("skip") => skip = true,
Meta::NameValue(nv) if nv.path.is_ident("rename") => {
if let syn::Expr::Lit(lit) = &nv.value {
if let Lit::Str(s) = &lit.lit {
rename = Some(s.value());
}
}
}
_ => {}
}
}
}
let is_option = is_option_type(&ty);
field_infos.push(FieldInfo {
ident,
ty,
is_id,
skip,
rename,
is_option,
});
}
let Some(id_field) = field_infos.iter().find(|f| f.is_id) else {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"AroEntity requires exactly one field marked with #[aro(id)]",
));
};
let id_ident = &id_field.ident;
let id_type = &id_field.ty;
let table_name = opts.table_name.unwrap_or_else(|| {
let name = struct_name.to_string();
let snake = to_snake_case(&name);
simple_pluralize(&snake)
});
let id_col_name = id_field
.rename
.clone()
.unwrap_or_else(|| id_field.ident.to_string());
let persistent_fields: Vec<&FieldInfo> = field_infos.iter().filter(|f| !f.skip).collect();
let core_path = crate_path::aro_core_path();
let fletch_orm = crate_path::fletch_path();
let column_defs: Vec<TokenStream> = persistent_fields
.iter()
.map(|f| {
let col_name = f.rename.clone().unwrap_or_else(|| f.ident.to_string());
let col_type = rust_type_to_column_type(&f.ty, f.is_option, &fletch_orm);
quote! {
#fletch_orm::Column::new(#col_name, #col_type)
}
})
.collect();
let num_cols = column_defs.len();
let value_exprs: Vec<TokenStream> = persistent_fields
.iter()
.map(|f| {
let ident = &f.ident;
quote! { #fletch_orm::Value::from(self.#ident.clone()) }
})
.collect();
let from_row_fields: Vec<TokenStream> = field_infos
.iter()
.map(|f| {
let ident = &f.ident;
if f.skip {
quote! { #ident: ::core::default::Default::default() }
} else {
let col_name = f.rename.clone().unwrap_or_else(|| f.ident.to_string());
quote! { #ident: row.try_get(#col_name)? }
}
})
.collect();
let decode_bounds: Vec<TokenStream> = persistent_fields
.iter()
.map(|f| {
let ty = &f.ty;
quote! { #ty: #fletch_orm::sqlx::Decode<'r, R::Database> + #fletch_orm::sqlx::Type<R::Database> }
})
.collect();
let core_entity_impl = quote! {
impl #core_path::entity::Entity for #struct_name {
type Id = #id_type;
fn id(&self) -> Self::Id {
self.#id_ident.clone()
}
}
};
let fletch_entity_impl = quote! {
impl #fletch_orm::Entity for #struct_name {
type Id = #id_type;
fn table_name() -> &'static str {
#table_name
}
fn id_column() -> &'static str {
#id_col_name
}
fn columns() -> &'static [#fletch_orm::Column] {
static COLS: [#fletch_orm::Column; #num_cols] = [
#(#column_defs),*
];
&COLS
}
fn id(&self) -> Self::Id {
self.#id_ident.clone()
}
fn values(&self) -> Vec<#fletch_orm::Value> {
vec![#(#value_exprs),*]
}
}
};
let from_row_impl = quote! {
impl<'r, R: #fletch_orm::sqlx::Row> #fletch_orm::sqlx::FromRow<'r, R> for #struct_name
where
&'r str: #fletch_orm::sqlx::ColumnIndex<R>,
#(#decode_bounds,)*
{
fn from_row(row: &'r R) -> #fletch_orm::sqlx::Result<Self> {
Ok(Self {
#(#from_row_fields,)*
})
}
}
};
Ok(quote! {
#core_entity_impl
#fletch_entity_impl
#from_row_impl
})
}
fn parse_struct_attrs(input: &DeriveInput) -> syn::Result<EntityOpts> {
let mut table_name = None;
for attr in &input.attrs {
if !attr.path().is_ident("aro") {
continue;
}
let nested = attr.parse_args_with(
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
)?;
for meta in &nested {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("table") {
if let syn::Expr::Lit(lit) = &nv.value {
if let Lit::Str(s) = &lit.lit {
table_name = Some(s.value());
}
}
}
}
}
}
Ok(EntityOpts { table_name })
}
fn is_option_type(ty: &Type) -> bool {
if let Type::Path(tp) = ty {
if let Some(seg) = tp.path.segments.last() {
return seg.ident == "Option";
}
}
false
}
fn rust_type_to_column_type(ty: &Type, is_option: bool, fletch_orm: &TokenStream) -> TokenStream {
let inner_ty = if is_option {
extract_option_inner(ty)
} else {
ty.clone()
};
let type_str = type_to_string(&inner_ty);
match type_str.as_str() {
"String" | "&str" => quote! { #fletch_orm::ColumnType::Text },
"i32" | "i64" => quote! { #fletch_orm::ColumnType::Integer },
"f32" | "f64" => quote! { #fletch_orm::ColumnType::Float },
"bool" => quote! { #fletch_orm::ColumnType::Boolean },
"Vec<u8>" => quote! { #fletch_orm::ColumnType::Blob },
_ => match last_segment(&inner_ty).as_str() {
"Uuid" => quote! { #fletch_orm::ColumnType::Uuid },
"DateTime" | "NaiveDateTime" => quote! { #fletch_orm::ColumnType::Timestamp },
"Value" | "JsonValue" => quote! { #fletch_orm::ColumnType::Json },
_ => quote! { #fletch_orm::ColumnType::Text },
},
}
}
fn extract_option_inner(ty: &Type) -> Type {
if let Type::Path(tp) = ty {
if let Some(seg) = tp.path.segments.last() {
if seg.ident == "Option" {
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
return inner.clone();
}
}
}
}
}
ty.clone()
}
fn type_to_string(ty: &Type) -> String {
quote!(#ty).to_string().replace(' ', "")
}
fn last_segment(ty: &Type) -> String {
if let Type::Path(tp) = ty {
if let Some(seg) = tp.path.segments.last() {
return seg.ident.to_string();
}
}
String::new()
}
#[expect(
clippy::unwrap_used,
reason = "char::to_lowercase always yields at least one character"
)]
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(ch.to_lowercase().next().unwrap());
} else {
result.push(ch);
}
}
result
}
fn simple_pluralize(s: &str) -> String {
if s.ends_with('s') || s.ends_with("sh") || s.ends_with("ch") || s.ends_with('x') {
format!("{s}es")
} else if s.ends_with('y') && !s.ends_with("ey") && !s.ends_with("ay") && !s.ends_with("oy") {
format!("{}ies", &s[..s.len() - 1])
} else {
format!("{s}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_struct_generates_impls() {
let input: TokenStream = quote! {
#[aro(table = "users")]
pub struct User {
#[aro(id)]
pub id: i64,
pub name: String,
pub email: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("entity :: Entity for User"));
assert!(output_str.contains("Entity for User"));
assert!(output_str.contains("fn table_name"));
assert!(output_str.contains("fn id_column"));
assert!(output_str.contains("fn columns"));
assert!(output_str.contains("fn values"));
}
#[test]
fn missing_id_field_produces_error() {
let input: TokenStream = quote! {
pub struct User {
pub id: i64,
pub name: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("compile_error"));
assert!(output_str.contains("#[aro(id)]"));
}
#[test]
fn enum_produces_error() {
let input: TokenStream = quote! {
pub enum NotAStruct {
A,
B,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("compile_error"));
}
#[test]
fn generated_entity_impl_uses_correct_id_type() {
let input: TokenStream = quote! {
pub struct Post {
#[aro(id)]
pub id: i64,
pub title: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("type Id = i64"));
assert!(output_str.contains("self . id . clone ()"));
}
#[test]
fn generated_code_uses_all_fields() {
let input: TokenStream = quote! {
#[aro(table = "users")]
pub struct User {
#[aro(id)]
pub id: i64,
pub name: String,
pub email: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("\"name\""));
assert!(output_str.contains("\"email\""));
}
#[test]
fn default_table_name_is_pluralized_snake_case() {
let input: TokenStream = quote! {
pub struct BlogPost {
#[aro(id)]
pub id: i64,
pub title: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("\"blog_posts\""));
}
#[test]
fn table_name_override() {
let input: TokenStream = quote! {
#[aro(table = "my_posts")]
pub struct Post {
#[aro(id)]
pub id: i64,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("\"my_posts\""));
}
#[test]
fn skip_attribute_excludes_field() {
let input: TokenStream = quote! {
pub struct User {
#[aro(id)]
pub id: i64,
pub name: String,
#[aro(skip)]
pub cached: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(!output_str.contains("\"cached\""));
}
#[test]
fn rename_attribute_overrides_column_name() {
let input: TokenStream = quote! {
pub struct User {
#[aro(id, rename = "user_id")]
pub id: i64,
#[aro(rename = "full_name")]
pub name: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("\"user_id\""));
assert!(output_str.contains("\"full_name\""));
}
#[test]
fn from_row_impl_is_generated() {
let input: TokenStream = quote! {
pub struct User {
#[aro(id)]
pub id: i64,
pub name: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("FromRow"));
assert!(output_str.contains("try_get"));
assert!(output_str.contains("from_row"));
}
#[test]
fn from_row_uses_renamed_columns() {
let input: TokenStream = quote! {
pub struct User {
#[aro(id, rename = "user_id")]
pub id: i64,
#[aro(rename = "full_name")]
pub name: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("try_get (\"user_id\")"));
assert!(output_str.contains("try_get (\"full_name\")"));
}
#[test]
fn from_row_skip_fields_use_default() {
let input: TokenStream = quote! {
pub struct User {
#[aro(id)]
pub id: i64,
pub name: String,
#[aro(skip)]
pub cached: String,
}
};
let output = expand_aro_entity(input);
let output_str = output.to_string();
assert!(output_str.contains("Default :: default ()"));
assert!(!output_str.contains("try_get (\"cached\")"));
}
}