hydracache-macros 0.18.0

Procedural macros for HydraCache.
Documentation
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{Attribute, DeriveInput, LitStr, Type};

#[derive(Default)]
pub(crate) struct EntityConfig {
    entity: Option<LitStr>,
    collection: Option<LitStr>,
    id: Option<Type>,
}

impl EntityConfig {
    pub(crate) fn from_attrs(attrs: &[Attribute]) -> syn::Result<Self> {
        let mut config = Self::default();

        for attr in attrs
            .iter()
            .filter(|attr| attr.path().is_ident("hydracache"))
        {
            attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("entity") {
                    reject_duplicate(&config.entity, &meta, "entity")?;
                    config.entity = Some(meta.value()?.parse()?);
                    Ok(())
                } else if meta.path.is_ident("collection") {
                    reject_duplicate(&config.collection, &meta, "collection")?;
                    config.collection = Some(meta.value()?.parse()?);
                    Ok(())
                } else if meta.path.is_ident("id") {
                    reject_duplicate(&config.id, &meta, "id")?;
                    config.id = Some(meta.value()?.parse()?);
                    Ok(())
                } else {
                    Err(meta
                        .error("unsupported hydracache option; expected entity, collection, or id"))
                }
            })?;
        }

        Ok(config)
    }

    pub(crate) fn required_entity(&self, input: &DeriveInput) -> syn::Result<&LitStr> {
        self.entity.as_ref().ok_or_else(|| {
            syn::Error::new(
                input.ident.span(),
                "missing #[hydracache(entity = \"...\")]",
            )
        })
    }

    pub(crate) fn required_id(&self, input: &DeriveInput) -> syn::Result<&Type> {
        self.id
            .as_ref()
            .ok_or_else(|| syn::Error::new(input.ident.span(), "missing #[hydracache(id = Type)]"))
    }

    pub(crate) fn collection_tokens(&self) -> TokenStream2 {
        match &self.collection {
            Some(collection) => quote!(Some(#collection)),
            None => quote!(None),
        }
    }

    #[cfg(test)]
    fn entity_value(&self) -> Option<String> {
        self.entity.as_ref().map(LitStr::value)
    }

    #[cfg(test)]
    fn collection_value(&self) -> Option<String> {
        self.collection.as_ref().map(LitStr::value)
    }

    #[cfg(test)]
    fn id_tokens(&self) -> TokenStream2 {
        let id = self.id.as_ref().expect("id should be present");
        quote!(#id)
    }
}

fn reject_duplicate<T>(
    current: &Option<T>,
    meta: &syn::meta::ParseNestedMeta<'_>,
    name: &str,
) -> syn::Result<()> {
    if current.is_some() {
        Err(meta.error(format!("duplicate hydracache {name} option")))
    } else {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use syn::{parse_quote, DeriveInput};

    use super::*;

    fn parse_config(input: DeriveInput) -> syn::Result<EntityConfig> {
        EntityConfig::from_attrs(&input.attrs)
    }

    #[test]
    fn parses_entity_collection_and_id() {
        let input: DeriveInput = parse_quote! {
            #[hydracache(entity = "user", collection = "users", id = i64)]
            struct User;
        };

        let config = parse_config(input).unwrap();

        assert_eq!(config.entity_value().unwrap(), "user");
        assert_eq!(config.collection_value().unwrap(), "users");
        assert_eq!(config.id_tokens().to_string(), "i64");
    }

    #[test]
    fn collection_is_optional() {
        let input: DeriveInput = parse_quote! {
            #[hydracache(entity = "profile", id = u64)]
            struct Profile;
        };

        let config = parse_config(input).unwrap();

        assert_eq!(config.collection_tokens().to_string(), "None");
    }

    #[test]
    fn accepts_split_hydracache_attributes() {
        let input: DeriveInput = parse_quote! {
            #[hydracache(entity = "account:user")]
            #[hydracache(collection = "users:active", id = &'static str)]
            struct AccountUser;
        };

        let config = parse_config(input).unwrap();

        assert_eq!(config.entity_value().unwrap(), "account:user");
        assert_eq!(config.collection_value().unwrap(), "users:active");
        assert_eq!(config.id_tokens().to_string(), "& 'static str");
    }

    #[test]
    fn rejects_duplicate_options() {
        let input: DeriveInput = parse_quote! {
            #[hydracache(entity = "user", entity = "profile", id = i64)]
            struct User;
        };

        let result = parse_config(input);
        assert!(result.is_err());
        let error = result.err().unwrap();

        assert!(error
            .to_string()
            .contains("duplicate hydracache entity option"));
    }

    #[test]
    fn rejects_unknown_options() {
        let input: DeriveInput = parse_quote! {
            #[hydracache(entity = "user", id = i64, table = "users")]
            struct User;
        };

        let result = parse_config(input);
        assert!(result.is_err());
        let error = result.err().unwrap();

        assert!(error.to_string().contains("unsupported hydracache option"));
    }
}