hydracache-macros 0.48.0

Procedural macros for HydraCache.
Documentation
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::spanned::Spanned;
use syn::{Attribute, Data, DeriveInput, Field, Fields, 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> {
        let inferred_id = inferred_id_type(input)?;

        match (&self.id, inferred_id) {
            (Some(explicit_id), None) => Ok(explicit_id.clone()),
            (None, Some(inferred_id)) => Ok(inferred_id),
            (Some(_), Some(_)) => Err(syn::Error::new(
                input.ident.span(),
                "conflicting hydracache id metadata; use either #[hydracache(id = Type)] on the struct or one #[hydracache(id)] field",
            )),
            (None, None) => Err(syn::Error::new(
                input.ident.span(),
                "missing #[hydracache(id = Type)] or #[hydracache(id)] field",
            )),
        }
    }

    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 inferred_id_type(input: &DeriveInput) -> syn::Result<Option<Type>> {
    let fields = match &input.data {
        Data::Struct(data) => &data.fields,
        _ => return Ok(None),
    };

    match fields {
        Fields::Named(fields) => {
            let mut id_type = None;
            for field in &fields.named {
                if field_id_marker_span(field)?.is_some() {
                    if id_type.is_some() {
                        return Err(syn::Error::new(
                            field.span(),
                            "duplicate hydracache id field marker",
                        ));
                    }
                    id_type = Some(field.ty.clone());
                }
            }
            Ok(id_type)
        }
        Fields::Unnamed(fields) => {
            for field in &fields.unnamed {
                if field_id_marker_span(field)?.is_some() {
                    return Err(syn::Error::new(
                        field.span(),
                        "field #[hydracache(id)] requires a named struct field",
                    ));
                }
            }
            Ok(None)
        }
        Fields::Unit => Ok(None),
    }
}

fn field_id_marker_span(field: &Field) -> syn::Result<Option<proc_macro2::Span>> {
    let mut id_marker = None;

    for attr in field
        .attrs
        .iter()
        .filter(|attr| attr.path().is_ident("hydracache"))
    {
        attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("id") {
                if !meta.input.is_empty() {
                    return Err(meta.error(
                        "field #[hydracache(id)] does not accept a value; put id = Type on the struct or use #[hydracache(id)] on one named field",
                    ));
                }

                if id_marker.is_some() {
                    return Err(meta.error("duplicate hydracache id field marker"));
                }

                id_marker = Some(meta.path.span());
                Ok(())
            } else {
                Err(meta.error("unsupported hydracache field option; expected id"))
            }
        })?;
    }

    Ok(id_marker)
}

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 infers_id_from_named_field_marker() {
        let input: DeriveInput = parse_quote! {
            #[hydracache(entity = "user", collection = "users")]
            struct User {
                #[hydracache(id)]
                id: i64,
                name: String,
            }
        };

        let config = parse_config(input.clone()).unwrap();
        let id = config.required_id(&input).unwrap();

        assert_eq!(quote!(#id).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"));
    }

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

        let config = parse_config(input.clone()).unwrap();
        let error = match config.required_id(&input) {
            Ok(_) => panic!("conflicting id metadata should fail"),
            Err(error) => error,
        };

        assert!(error
            .to_string()
            .contains("conflicting hydracache id metadata"));
    }

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

        let config = parse_config(input.clone()).unwrap();
        let error = match config.required_id(&input) {
            Ok(_) => panic!("duplicate field id markers should fail"),
            Err(error) => error,
        };

        assert!(error
            .to_string()
            .contains("duplicate hydracache id field marker"));
    }

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

        let config = parse_config(input.clone()).unwrap();
        let error = match config.required_id(&input) {
            Ok(_) => panic!("field id marker with value should fail"),
            Err(error) => error,
        };

        assert!(error
            .to_string()
            .contains("field #[hydracache(id)] does not accept a value"));
    }
}