use proc_macro2::TokenStream;
use quote::quote;
use syn::{
parse::{Parse, ParseStream},
Attribute, Data, DeriveInput, Fields, Ident, Token,
};
#[derive(Debug, Clone, Default)]
pub struct SchemaConfig {
pub table_name: String,
pub migration_id: Option<String>,
pub after: Option<String>,
pub extends: Option<String>,
pub migrate: bool,
}
impl Parse for SchemaConfig {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut config = SchemaConfig { migrate: true, ..Default::default() };
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"table" => {
let value: syn::LitStr = input.parse()?;
config.table_name = value.value();
}
"migration" => {
let value: syn::LitStr = input.parse()?;
config.migration_id = Some(value.value());
}
"after" => {
let value: syn::LitStr = input.parse()?;
config.after = Some(value.value());
}
"extends" => {
let value: Ident = input.parse()?;
config.extends = Some(value.to_string());
}
"migrate" => {
let value: syn::LitBool = input.parse()?;
config.migrate = value.value();
}
_ => {
return Err(syn::Error::new(key.span(), format!("Unknown attribute: {key}")));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
if config.table_name.is_empty() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"Missing required 'table' attribute",
));
}
Ok(config)
}
}
pub fn impl_ormada_schema(attr: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
let config: SchemaConfig = syn::parse2(attr)?;
let mut input: DeriveInput = syn::parse2(item)?;
let struct_name = &input.ident;
let vis = &input.vis;
let fields = match &mut input.data {
Data::Struct(data) => match &mut data.fields {
Fields::Named(fields) => fields,
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"ormada_schema only supports structs with named fields",
));
}
},
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"ormada_schema only supports structs",
));
}
};
let ormada_attrs = [
"primary_key",
"foreign_key",
"one_to_one",
"many_to_many",
"index",
"unique",
"max_length",
"min_length",
"range",
"auto_now",
"auto_now_add",
"soft_delete",
"default",
"nullable",
"skip_serializing",
"skip_deserializing",
"rename",
"drop",
];
for field in fields.named.iter_mut() {
field.attrs.retain(|attr| {
let path = attr.path();
!ormada_attrs.iter().any(|name| path.is_ident(name))
});
}
let field_defs: Vec<_> = fields.named.iter().collect();
let table_name = &config.table_name;
let migration_id = config.migration_id.as_deref().unwrap_or("");
let after = config.after.as_deref().unwrap_or("");
let extends = config.extends.as_deref().unwrap_or("");
let expanded = quote! {
#[derive(Clone, Debug)]
#vis struct #struct_name {
#(#field_defs,)*
}
impl #struct_name {
#[doc(hidden)]
pub const __ORMADA_SCHEMA_TABLE: &'static str = #table_name;
#[doc(hidden)]
pub const __ORMADA_SCHEMA_MIGRATION: &'static str = #migration_id;
#[doc(hidden)]
pub const __ORMADA_SCHEMA_AFTER: &'static str = #after;
#[doc(hidden)]
pub const __ORMADA_SCHEMA_EXTENDS: &'static str = #extends;
}
};
Ok(expanded)
}
#[allow(dead_code)]
fn parse_schema_field_attrs(attrs: &[Attribute]) -> SchemaFieldConfig {
let mut config = SchemaFieldConfig::default();
for attr in attrs {
let path = attr.path();
let attr_name = path.get_ident().map(|i| i.to_string()).unwrap_or_default();
match attr_name.as_str() {
"primary_key" => config.primary_key = true,
"foreign_key" => config.foreign_key = true,
"index" => config.indexed = true,
"unique" => config.unique = true,
"nullable" => config.nullable = true,
"max_length" => config.has_max_length = true,
"default" => config.has_default = true,
"soft_delete" => config.soft_delete = true,
"rename" => config.is_rename = true,
"drop" => config.is_drop = true,
_ => {}
}
}
config
}
#[derive(Default)]
struct SchemaFieldConfig {
primary_key: bool,
foreign_key: bool,
indexed: bool,
unique: bool,
nullable: bool,
has_max_length: bool,
has_default: bool,
soft_delete: bool,
is_rename: bool,
is_drop: bool,
}
#[derive(Debug, Clone, Default)]
pub struct DataMigrationConfig {
pub migration_id: String,
pub after: Option<String>,
}
impl Parse for DataMigrationConfig {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut config = DataMigrationConfig::default();
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"migration" => {
let value: syn::LitStr = input.parse()?;
config.migration_id = value.value();
}
"after" => {
let value: syn::LitStr = input.parse()?;
config.after = Some(value.value());
}
_ => {
return Err(syn::Error::new(key.span(), format!("Unknown attribute: {key}")));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
if config.migration_id.is_empty() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"Missing required 'migration' attribute",
));
}
Ok(config)
}
}
pub fn impl_ormada_data_migration(
attr: TokenStream,
item: TokenStream,
) -> syn::Result<TokenStream> {
let config: DataMigrationConfig = syn::parse2(attr)?;
let input: syn::ItemFn = syn::parse2(item)?;
let fn_name = &input.sig.ident;
let fn_vis = &input.vis;
let fn_block = &input.block;
let fn_inputs = &input.sig.inputs;
let fn_output = &input.sig.output;
let fn_asyncness = &input.sig.asyncness;
let migration_id = &config.migration_id;
let after = config.after.as_deref().unwrap_or("");
let expanded = quote! {
#fn_vis #fn_asyncness fn #fn_name(#fn_inputs) #fn_output #fn_block
#[doc(hidden)]
pub mod __ormada_data_migration_meta {
pub const MIGRATION_ID: &str = #migration_id;
pub const AFTER: &str = #after;
pub const FN_NAME: &str = stringify!(#fn_name);
}
};
Ok(expanded)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_schema_config() {
let tokens: TokenStream = quote::quote! {
table = "books", migration = "001_initial"
};
let config: SchemaConfig = syn::parse2(tokens).unwrap();
assert_eq!(config.table_name, "books");
assert_eq!(config.migration_id, Some("001_initial".to_string()));
}
#[test]
fn test_parse_schema_config_with_extends() {
let tokens: TokenStream = quote::quote! {
table = "books", migration = "002", after = "001", extends = Book
};
let config: SchemaConfig = syn::parse2(tokens).unwrap();
assert_eq!(config.table_name, "books");
assert_eq!(config.migration_id, Some("002".to_string()));
assert_eq!(config.after, Some("001".to_string()));
assert_eq!(config.extends, Some("Book".to_string()));
}
}