mod column;
mod example;
mod expose;
mod filter;
mod storage;
mod validation;
pub use column::{ColumnConfig, IndexType, ReferentialAction};
pub use example::ExampleValue;
pub use expose::ExposeConfig;
pub use filter::{FilterConfig, FilterType};
pub use storage::StorageConfig;
use syn::{Attribute, Field, Ident, Type};
pub use validation::ValidationConfig;
use crate::utils::docs::extract_doc_comments;
fn parse_belongs_to(attr: &Attribute) -> (Option<Ident>, Option<ReferentialAction>) {
if let Ok(ident) = attr.parse_args::<Ident>() {
return (Some(ident), None);
}
let mut entity = None;
let mut on_delete = None;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("on_delete") {
let _: syn::Token![=] = meta.input.parse()?;
let value: syn::LitStr = meta.input.parse()?;
on_delete = ReferentialAction::from_str(&value.value());
} else if let Some(ident) = meta.path.get_ident() {
entity = Some(ident.clone());
}
Ok(())
});
(entity, on_delete)
}
#[derive(Debug)]
pub struct FieldDef {
pub ident: Ident,
pub ty: Type,
pub expose: ExposeConfig,
pub storage: StorageConfig,
pub filter: FilterConfig,
pub column: ColumnConfig,
#[allow(dead_code)] pub doc: Option<String>,
#[allow(dead_code)] pub validation: ValidationConfig,
#[allow(dead_code)] pub example: Option<ExampleValue>
}
impl FieldDef {
pub fn from_field(field: &Field) -> darling::Result<Self> {
let ident = field.ident.clone().ok_or_else(|| {
darling::Error::custom("Entity fields must be named").with_span(field)
})?;
let ty = field.ty.clone();
let doc = extract_doc_comments(&field.attrs);
let validation = validation::parse_validation_attrs(&field.attrs);
let example = example::parse_example_attr(&field.attrs);
let mut expose = ExposeConfig::default();
let mut storage = StorageConfig::default();
let mut filter = FilterConfig::default();
let mut column = ColumnConfig::default();
for attr in &field.attrs {
if attr.path().is_ident("id") {
storage.is_id = true;
} else if attr.path().is_ident("auto") {
storage.is_auto = true;
} else if attr.path().is_ident("field") {
expose = ExposeConfig::from_attr(attr);
} else if attr.path().is_ident("belongs_to") {
let (entity, on_del) = parse_belongs_to(attr);
storage.belongs_to = entity;
storage.on_delete = on_del;
} else if attr.path().is_ident("filter") {
filter = FilterConfig::from_attr(attr);
} else if attr.path().is_ident("column") {
column = ColumnConfig::from_attr(attr);
}
}
Ok(Self {
ident,
ty,
expose,
storage,
filter,
column,
doc,
validation,
example
})
}
#[must_use]
pub fn name(&self) -> &Ident {
&self.ident
}
#[must_use]
pub fn name_str(&self) -> String {
self.ident.to_string()
}
#[must_use]
pub fn ty(&self) -> &Type {
&self.ty
}
#[must_use]
pub fn is_option(&self) -> bool {
if let Type::Path(type_path) = &self.ty
&& let Some(segment) = type_path.path.segments.last()
{
return segment.ident == "Option";
}
false
}
#[must_use]
pub fn is_id(&self) -> bool {
self.storage.is_id
}
#[must_use]
pub fn is_auto(&self) -> bool {
self.storage.is_auto
}
#[must_use]
pub fn in_create(&self) -> bool {
self.expose.in_create()
}
#[must_use]
pub fn in_update(&self) -> bool {
self.expose.in_update()
}
#[must_use]
pub fn in_response(&self) -> bool {
!self.expose.skip && (self.expose.response || self.storage.is_id)
}
#[must_use]
pub fn belongs_to(&self) -> Option<&Ident> {
self.storage.belongs_to.as_ref()
}
#[must_use]
pub fn is_relation(&self) -> bool {
self.storage.is_relation()
}
#[must_use]
pub fn has_filter(&self) -> bool {
self.filter.has_filter()
}
#[must_use]
pub fn filter(&self) -> &FilterConfig {
&self.filter
}
#[must_use]
#[allow(dead_code)] pub fn doc(&self) -> Option<&str> {
self.doc.as_deref()
}
#[must_use]
#[allow(dead_code)] pub fn validation(&self) -> &ValidationConfig {
&self.validation
}
#[must_use]
#[allow(dead_code)] pub fn has_validation(&self) -> bool {
self.validation.has_validation()
}
#[must_use]
#[allow(dead_code)] pub fn example(&self) -> Option<&ExampleValue> {
self.example.as_ref()
}
#[must_use]
#[allow(dead_code)] pub fn has_example(&self) -> bool {
self.example.is_some()
}
#[must_use]
pub fn column(&self) -> &ColumnConfig {
&self.column
}
#[must_use]
pub fn is_unique(&self) -> bool {
self.column.unique
}
#[must_use]
#[allow(dead_code)] pub fn has_index(&self) -> bool {
self.column.has_index()
}
#[must_use]
pub fn column_name(&self) -> String {
self.column.column_name(&self.name_str()).to_string()
}
}
#[cfg(test)]
mod tests {
use syn::parse_quote;
use super::*;
fn parse_field(tokens: proc_macro2::TokenStream) -> FieldDef {
let field: Field = parse_quote!(#tokens);
FieldDef::from_field(&field).unwrap()
}
#[test]
fn field_basic_parsing() {
let field = parse_field(quote::quote! { pub name: String });
assert_eq!(field.name_str(), "name");
assert!(!field.is_id());
assert!(!field.is_auto());
}
#[test]
fn field_id_attribute() {
let field = parse_field(quote::quote! {
#[id]
pub id: uuid::Uuid
});
assert!(field.is_id());
assert!(field.in_response());
}
#[test]
fn field_auto_attribute() {
let field = parse_field(quote::quote! {
#[auto]
pub created_at: chrono::DateTime<chrono::Utc>
});
assert!(field.is_auto());
}
#[test]
fn field_expose_config() {
let field = parse_field(quote::quote! {
#[field(create, update, response)]
pub name: String
});
assert!(field.in_create());
assert!(field.in_update());
assert!(field.in_response());
}
#[test]
fn field_expose_skip() {
let field = parse_field(quote::quote! {
#[field(skip)]
pub password: String
});
assert!(!field.in_create());
assert!(!field.in_update());
assert!(!field.in_response());
}
#[test]
fn field_belongs_to() {
let field = parse_field(quote::quote! {
#[belongs_to(User)]
pub user_id: uuid::Uuid
});
assert!(field.is_relation());
assert!(field.belongs_to().is_some());
assert_eq!(field.belongs_to().unwrap().to_string(), "User");
assert!(field.storage.on_delete.is_none());
}
#[test]
fn field_belongs_to_with_on_delete() {
let field = parse_field(quote::quote! {
#[belongs_to(User, on_delete = "cascade")]
pub user_id: uuid::Uuid
});
assert!(field.is_relation());
assert_eq!(field.belongs_to().unwrap().to_string(), "User");
assert_eq!(field.storage.on_delete, Some(ReferentialAction::Cascade));
}
#[test]
fn field_belongs_to_with_on_delete_set_null() {
let field = parse_field(quote::quote! {
#[belongs_to(Organization, on_delete = "set null")]
pub org_id: uuid::Uuid
});
assert!(field.is_relation());
assert_eq!(field.belongs_to().unwrap().to_string(), "Organization");
assert_eq!(field.storage.on_delete, Some(ReferentialAction::SetNull));
}
#[test]
fn field_filter_attribute() {
let field = parse_field(quote::quote! {
#[filter]
pub status: String
});
assert!(field.has_filter());
}
#[test]
fn field_is_option() {
let field = parse_field(quote::quote! { pub avatar: Option<String> });
assert!(field.is_option());
let field2 = parse_field(quote::quote! { pub name: String });
assert!(!field2.is_option());
}
#[test]
fn field_ty_accessor() {
let field = parse_field(quote::quote! { pub count: i32 });
let ty = field.ty();
let ty_str = quote::quote!(#ty).to_string();
assert!(ty_str.contains("i32"));
}
#[test]
fn field_doc_comment() {
let field = parse_field(quote::quote! {
pub name: String
});
assert!(field.doc().is_some());
assert!(field.doc().unwrap().contains("display name"));
}
#[test]
fn field_no_doc_comment() {
let field = parse_field(quote::quote! { pub name: String });
assert!(field.doc().is_none());
}
#[test]
fn field_validation_accessor() {
let field = parse_field(quote::quote! { pub name: String });
let _validation = field.validation();
assert!(!field.has_validation());
}
#[test]
fn field_example_accessor() {
let field = parse_field(quote::quote! { pub name: String });
assert!(field.example().is_none());
assert!(!field.has_example());
}
#[test]
fn field_filter_accessor() {
let field = parse_field(quote::quote! {
#[filter(like)]
pub name: String
});
let filter = field.filter();
assert!(filter.has_filter());
}
#[test]
fn field_name_accessor() {
let field = parse_field(quote::quote! { pub email: String });
assert_eq!(field.name().to_string(), "email");
}
#[test]
fn field_column_unique() {
let field = parse_field(quote::quote! {
#[column(unique)]
pub email: String
});
assert!(field.is_unique());
}
#[test]
fn field_column_index() {
let field = parse_field(quote::quote! {
#[column(index)]
pub status: String
});
assert!(field.has_index());
assert_eq!(field.column().index, Some(IndexType::BTree));
}
#[test]
fn field_column_index_gin() {
let field = parse_field(quote::quote! {
#[column(index = "gin")]
pub tags: Vec<String>
});
assert!(field.has_index());
assert_eq!(field.column().index, Some(IndexType::Gin));
}
#[test]
fn field_column_default() {
let field = parse_field(quote::quote! {
#[column(default = "true")]
pub is_active: bool
});
assert_eq!(field.column().default, Some("true".to_string()));
}
#[test]
fn field_column_check() {
let field = parse_field(quote::quote! {
#[column(check = "age >= 0")]
pub age: i32
});
assert_eq!(field.column().check, Some("age >= 0".to_string()));
}
#[test]
fn field_column_varchar() {
let field = parse_field(quote::quote! {
#[column(varchar = 100)]
pub name: String
});
assert_eq!(field.column().varchar, Some(100));
}
#[test]
fn field_column_custom_name() {
let field = parse_field(quote::quote! {
#[column(name = "user_email")]
pub email: String
});
assert_eq!(field.column_name(), "user_email");
}
#[test]
fn field_column_default_name() {
let field = parse_field(quote::quote! { pub email: String });
assert_eq!(field.column_name(), "email");
}
#[test]
fn field_column_multiple_attrs() {
let field = parse_field(quote::quote! {
#[column(unique, index, default = "NOW()")]
pub created_at: DateTime<Utc>
});
assert!(field.is_unique());
assert!(field.has_index());
assert_eq!(field.column().default, Some("NOW()".to_string()));
}
}