gearbox-rs-macros 1.3.0

Procedural macros for Gearbox framework
Documentation
//! Attribute parsing for the Crud derive macro.

use crate::crud::utils::{pluralize, to_snake_case};
use syn::{Attribute, Data, DeriveInput, Error, Fields, Ident, LitStr, Type};

/// Information about a single field in the entity.
#[derive(Debug, Clone)]
pub struct CrudFieldInfo {
    pub ident: Ident,
    pub ty: Type,
    /// Field is a primary key.
    pub is_primary_key: bool,
    /// Field is auto-generated by the database (UUID, serial, etc.).
    pub auto_generated: bool,
    /// Field is read-only (only in responses, not in create/update).
    pub readonly: bool,
    /// Field is write-only (only in create/update, not in responses).
    pub writeonly: bool,
    /// Field should be skipped entirely from CRUD operations.
    pub skip: bool,
}

impl CrudFieldInfo {
    /// Check if field should be included in Create DTO.
    pub fn include_in_create(&self) -> bool {
        !self.auto_generated && !self.readonly && !self.skip
    }

    /// Check if field should be included in Update DTO.
    pub fn include_in_update(&self) -> bool {
        !self.auto_generated && !self.readonly && !self.skip && !self.is_primary_key
    }

    /// Check if field should be included in Response DTO.
    pub fn include_in_response(&self) -> bool {
        !self.writeonly && !self.skip
    }
}

/// Struct-level CRUD configuration.
#[derive(Debug, Clone)]
#[derive(Default)]
pub struct CrudConfig {
    /// Base path for REST endpoints (e.g., "/users").
    pub path: String,
    /// Whether this entity is read-only (no mutation endpoints).
    pub read_only: bool,
    /// Skip POST endpoint.
    pub skip_create: bool,
    /// Skip DELETE endpoint.
    pub skip_delete: bool,
}


/// Parsed entity information for CRUD generation.
#[derive(Debug)]
pub struct CrudEntityInfo {
    pub name: Ident,
    pub config: CrudConfig,
    pub fields: Vec<CrudFieldInfo>,
}

impl CrudEntityInfo {
    /// Get primary key fields.
    pub fn pk_fields(&self) -> Vec<&CrudFieldInfo> {
        self.fields.iter().filter(|f| f.is_primary_key).collect()
    }

    /// Get database fields (not skipped).
    pub fn db_fields(&self) -> Vec<&CrudFieldInfo> {
        self.fields.iter().filter(|f| !f.skip).collect()
    }

    /// Get fields for Create DTO.
    pub fn create_fields(&self) -> Vec<&CrudFieldInfo> {
        self.fields
            .iter()
            .filter(|f| f.include_in_create())
            .collect()
    }

    /// Get fields for Update DTO.
    pub fn update_fields(&self) -> Vec<&CrudFieldInfo> {
        self.fields
            .iter()
            .filter(|f| f.include_in_update())
            .collect()
    }

    /// Get fields for Response DTO.
    pub fn response_fields(&self) -> Vec<&CrudFieldInfo> {
        self.fields
            .iter()
            .filter(|f| f.include_in_response())
            .collect()
    }

    /// Get the entity name in snake_case for function naming.
    pub fn snake_case_name(&self) -> String {
        to_snake_case(&self.name.to_string())
    }

    /// Get default path if not specified.
    pub fn default_path(&self) -> String {
        format!("/{}", pluralize(&self.snake_case_name()))
    }

    /// Get the effective path (specified or default).
    pub fn effective_path(&self) -> String {
        if self.config.path.is_empty() {
            self.default_path()
        } else {
            self.config.path.clone()
        }
    }
}

fn parse_crud_config(attrs: &[Attribute]) -> Result<CrudConfig, Error> {
    let mut config = CrudConfig::default();

    for attr in attrs {
        if !attr.path().is_ident("crud") {
            continue;
        }
        attr.parse_nested_meta(|meta| {
            let path = &meta.path;
            if path.is_ident("path") {
                let crud_path = meta.value()?.parse::<LitStr>()?.value();
                config.path = crud_path;
            } else if path.is_ident("read_only") {
                config.read_only = true;
            } else if path.is_ident("skip_create") {
                config.skip_create = true;
            } else if path.is_ident("skip_delete") {
                config.skip_delete = true;
            };
            Ok(())
        })?;
    }

    Ok(config)
}

fn parse_table_name(attrs: &[Attribute]) -> Option<String> {
    for attr in attrs {
        if attr.path().is_ident("table")
            && let Ok(lit) = attr.parse_args::<syn::LitStr>() {
                return Some(lit.value());
            }
    }
    None
}

fn parse_field_info(field: &syn::Field) -> Result<CrudFieldInfo, Error> {
    let ident = field
        .ident
        .clone()
        .ok_or(syn::Error::new_spanned(field, "named field required"))?;
    let ty = field.ty.clone();

    Ok(CrudFieldInfo {
        ident,
        ty,
        is_primary_key: crate::utils::has_attr(field, "primary_key"),
        auto_generated: crate::utils::has_attr(field, "auto_generated"),
        readonly: crate::utils::has_attr(field, "readonly"),
        writeonly: crate::utils::has_attr(field, "writeonly"),
        skip: crate::utils::has_attr(field, "skip"),
    })
}

/// Parse the entire entity from DeriveInput.
pub fn parse_crud_entity(input: &DeriveInput) -> Result<CrudEntityInfo, Error> {
    let name = input.ident.clone();

    // Validate table name is present (required - comes from PgEntity)
    parse_table_name(&input.attrs).ok_or(Error::new_spanned(
        input,
        "#[table(\"name\")] attribute is required for Crud derive",
    ))?;

    // Parse crud config
    let config = parse_crud_config(&input.attrs)?;

    // Parse fields
    let fields = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(fields) => Ok(fields
                .named
                .iter()
                .map(parse_field_info)
                .collect::<Result<Vec<_>, _>>()?),
            _ => Err(Error::new_spanned(
                input,
                "Crud can only be derived for structs with named fields",
            )),
        },
        _ => Err(Error::new_spanned(
            input,
            "Crud can only be derived for structs",
        )),
    }?;

    Ok(CrudEntityInfo {
        name,
        config,
        fields,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_to_snake_case() {
        assert_eq!(to_snake_case("User"), "user");
        assert_eq!(to_snake_case("UserProfile"), "user_profile");
        assert_eq!(to_snake_case("HTMLParser"), "h_t_m_l_parser");
    }

    #[test]
    fn test_pluralize() {
        assert_eq!(pluralize("user"), "users");
        assert_eq!(pluralize("category"), "categories");
        assert_eq!(pluralize("box"), "boxes");
        assert_eq!(pluralize("key"), "keys");
    }
}