use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::{format_ident, quote};
use syn::{ItemStruct, Lit, Meta, parse_macro_input};
use crate::util::to_snake_case;
pub fn model(attr: TokenStream, item: TokenStream) -> TokenStream {
let table = match parse_model_attr(attr.into()) {
Ok(t) => t,
Err(e) => return e.to_compile_error().into(),
};
let item_struct = parse_macro_input!(item as ItemStruct);
match expand(&table, item_struct) {
Ok(tokens) => tokens.into(),
Err(e) => e.to_compile_error().into(),
}
}
fn expand(table: &str, mut item_struct: ItemStruct) -> syn::Result<proc_macro2::TokenStream> {
if !item_struct.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&item_struct.generics,
"#[model] does not support generic parameters: a SeaORM entity \
maps to exactly one table with one column set",
));
}
if !matches!(item_struct.fields, syn::Fields::Named(_)) {
return Err(syn::Error::new_spanned(
&item_struct.fields,
"#[model] requires a struct with named fields, one per column \
(e.g. `struct User { id: i64 }`)",
));
}
let alias = item_struct.ident.clone();
let vis = item_struct.vis.clone();
let module = format_ident!("__arcature_model_{}", to_snake_case(&alias.to_string()));
let entity = format_ident!("{alias}Entity");
let active = format_ident!("{alias}ActiveModel");
let column = format_ident!("{alias}Column");
let primary_key = format_ident!("{alias}PrimaryKey");
let relation = format_ident!("{alias}Relation");
let docs: Vec<syn::Attribute> = item_struct
.attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.cloned()
.collect();
item_struct.ident = syn::Ident::new("Model", alias.span());
item_struct.vis = syn::parse_quote!(pub);
let derive_attr: syn::Attribute = syn::parse_quote! {
#[derive(
::core::clone::Clone,
::core::fmt::Debug,
::core::cmp::PartialEq,
::arcature::sea_orm::DeriveEntityModel,
::arcature::Serialize,
::arcature::Deserialize
)]
};
let table_attr: syn::Attribute = syn::parse_quote! {
#[sea_orm(table_name = #table)]
};
item_struct.attrs.insert(0, table_attr);
item_struct.attrs.insert(0, derive_attr);
Ok(quote! {
#[doc(hidden)]
#vis mod #module {
use ::arcature::sea_orm::entity::prelude::*;
use super::*;
use ::core::option::Option::{self, None, Some};
use ::core::result::Result::{self, Err, Ok};
#item_struct
#[derive(
::core::marker::Copy,
::core::clone::Clone,
::core::fmt::Debug,
::arcature::sea_orm::EnumIter,
::arcature::sea_orm::DeriveRelation
)]
pub enum Relation {}
impl ::arcature::sea_orm::ActiveModelBehavior for ActiveModel {}
}
#( #docs )*
#vis use #module::Model as #alias;
#[doc = concat!("The SeaORM entity for [`", stringify!(#alias), "`].")]
#vis use #module::Entity as #entity;
#[doc = concat!("The insertable/updatable form of [`", stringify!(#alias), "`].")]
#vis use #module::ActiveModel as #active;
#[doc = concat!("The column set of [`", stringify!(#alias), "`].")]
#vis use #module::Column as #column;
#[doc = concat!("The primary key of [`", stringify!(#alias), "`].")]
#vis use #module::PrimaryKey as #primary_key;
#[doc = concat!("The (empty) relation set of [`", stringify!(#alias), "`].")]
#vis use #module::Relation as #relation;
})
}
fn parse_model_attr(attr: proc_macro2::TokenStream) -> syn::Result<String> {
if attr.is_empty() {
return Err(syn::Error::new(
Span::call_site(),
"#[model(table = \"...\")] requires a `table` argument",
));
}
let meta: Meta = syn::parse2(attr)?;
match meta {
Meta::NameValue(nv) if nv.path.is_ident("table") => {
if let syn::Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = nv.value
{
let table = s.value();
if table.is_empty() {
return Err(syn::Error::new(
s.span(),
"#[model(table = \"...\")] table name must not be empty",
));
}
Ok(table)
} else {
Err(syn::Error::new(
Span::call_site(),
"#[model(...)] expects `table = \"name\"`",
))
}
}
_ => Err(syn::Error::new(
Span::call_site(),
"#[model(...)] expects `table = \"name\"`",
)),
}
}
#[cfg(test)]
mod tests {
use super::expand;
use quote::quote;
fn render(table: &str, item: proc_macro2::TokenStream) -> String {
expand(table, syn::parse2(item).expect("parses"))
.unwrap()
.to_string()
}
fn user() -> proc_macro2::TokenStream {
quote! {
pub struct User {
#[sea_orm(primary_key)]
pub id: i64,
pub email: String,
}
}
}
#[test]
fn the_struct_is_renamed_to_model_so_seaorm_accepts_it() {
let s = render("users", user());
assert!(s.contains("pub struct Model"), "got: {s}");
assert!(s.contains("DeriveEntityModel"), "got: {s}");
}
#[test]
fn the_original_name_comes_back_out_as_a_re_export() {
let s = render("users", user());
assert!(
s.contains("use __arcature_model_user :: Model as User"),
"got: {s}"
);
}
#[test]
fn the_companion_types_are_re_exported_under_predictable_names() {
let s = render("users", user());
for expected in [
"Entity as UserEntity",
"ActiveModel as UserActiveModel",
"Column as UserColumn",
"PrimaryKey as UserPrimaryKey",
"Relation as UserRelation",
] {
assert!(s.contains(expected), "missing `{expected}` in: {s}");
}
}
#[test]
fn the_table_name_reaches_the_seaorm_attribute() {
let s = render("users", user());
assert!(s.contains("table_name = \"users\""), "got: {s}");
}
#[test]
fn field_attributes_survive_the_move_into_the_module() {
let s = render("users", user());
assert!(s.contains("primary_key"), "got: {s}");
}
#[test]
fn an_empty_relation_enum_and_behaviour_impl_are_supplied() {
let s = render("users", user());
assert!(s.contains("pub enum Relation { }"), "got: {s}");
assert!(
s.contains("ActiveModelBehavior for ActiveModel"),
"got: {s}"
);
}
#[test]
fn the_module_inherits_the_structs_visibility() {
let s = render("users", user());
assert!(s.contains("pub mod __arcature_model_user"), "got: {s}");
let private = render("notes", quote! { struct Note { pub id: i64 } });
assert!(
private.contains("mod __arcature_model_note"),
"got: {private}"
);
assert!(
!private.contains("pub mod __arcature_model_note"),
"got: {private}"
);
}
#[test]
fn a_multiword_name_gives_a_snake_case_module() {
let s = render("blog_posts", quote! { pub struct BlogPost { pub id: i64 } });
assert!(s.contains("__arcature_model_blog_post"), "got: {s}");
}
#[test]
fn the_doc_comment_follows_the_name_the_application_sees() {
let s = render(
"users",
quote! {
pub struct User { pub id: i64 }
},
);
assert!(s.matches("A row of users.").count() >= 2, "got: {s}");
}
#[test]
fn generics_are_rejected_with_one_clear_message() {
let err = expand(
"users",
syn::parse2(quote! { pub struct User<T> { pub id: T } }).unwrap(),
)
.unwrap_err();
assert!(err.to_string().contains("generic"), "got: {err}");
}
#[test]
fn a_tuple_struct_is_rejected() {
let err = expand(
"users",
syn::parse2(quote! { pub struct User(i64); }).unwrap(),
)
.unwrap_err();
assert!(err.to_string().contains("named fields"), "got: {err}");
}
#[test]
fn a_missing_table_argument_is_rejected() {
let err = super::parse_model_attr(proc_macro2::TokenStream::new()).unwrap_err();
assert!(err.to_string().contains("requires a `table`"), "got: {err}");
}
#[test]
fn an_empty_table_name_is_rejected() {
let err = super::parse_model_attr(quote! { table = "" }).unwrap_err();
assert!(err.to_string().contains("must not be empty"), "got: {err}");
}
#[test]
fn an_argument_that_is_not_table_is_rejected() {
let err = super::parse_model_attr(quote! { name = "users" }).unwrap_err();
assert!(err.to_string().contains("table = "), "got: {err}");
}
#[test]
fn a_non_string_table_name_is_rejected() {
let err = super::parse_model_attr(quote! { table = 42 }).unwrap_err();
assert!(err.to_string().contains("table = "), "got: {err}");
}
}