rullst-orm-macros 13.0.0-alpha.2

Procedural macros and derive attributes for the rullst-orm Active Record pattern.
Documentation
use proc_macro2::TokenStream;
use quote::quote;
use std::collections::BTreeSet;
use syn::{Data, DeriveInput, Fields, LitStr};

mod codecs;

const MAX_VARIANTS: usize = 64;
const MAX_LABEL_BYTES: usize = 63;

#[derive(Clone, Copy)]
enum RenameAll {
    Lowercase,
    SnakeCase,
}

#[derive(Default)]
struct EnumAttributes {
    type_name: Option<String>,
    rename_all: Option<RenameAll>,
}

pub fn derive_enum_impl(input: TokenStream) -> TokenStream {
    match expand_enum(input) {
        Ok(expanded) => expanded,
        Err(error) => error.to_compile_error(),
    }
}

fn expand_enum(input: TokenStream) -> syn::Result<TokenStream> {
    let input = syn::parse2::<DeriveInput>(input)?;
    let name = &input.ident;
    if !input.generics.params.is_empty() {
        return Err(syn::Error::new_spanned(
            &input.generics,
            "Enum macro does not support generic database enums",
        ));
    }
    let variants = match &input.data {
        Data::Enum(data_enum) => &data_enum.variants,
        _ => {
            return Err(syn::Error::new_spanned(
                name,
                "Enum macro can only be used on enums",
            ));
        }
    };
    if variants.is_empty() || variants.len() > MAX_VARIANTS {
        return Err(syn::Error::new_spanned(
            variants,
            format!("Enum macro requires 1-{MAX_VARIANTS} unit variants"),
        ));
    }

    let attributes = parse_enum_attributes(&input)?;
    let type_name = attributes
        .type_name
        .unwrap_or_else(|| to_snake_case(&name.to_string()));
    validate_type_name(&type_name, name)?;

    let mut variant_idents = Vec::with_capacity(variants.len());
    let mut labels = Vec::with_capacity(variants.len());
    let mut seen = BTreeSet::new();
    for variant in variants {
        if !matches!(variant.fields, Fields::Unit) {
            return Err(syn::Error::new_spanned(
                variant,
                "Enum macro supports only unit variants",
            ));
        }
        let rename = parse_variant_rename(variant)?;
        let original = variant.ident.to_string();
        let label = rename.unwrap_or_else(|| match attributes.rename_all {
            Some(RenameAll::Lowercase) => original.to_ascii_lowercase(),
            Some(RenameAll::SnakeCase) => to_snake_case(&original),
            None => original,
        });
        validate_label(&label, variant)?;
        if !seen.insert(label.clone()) {
            return Err(syn::Error::new_spanned(
                variant,
                format!("duplicate database enum label `{label}`"),
            ));
        }
        variant_idents.push(&variant.ident);
        labels.push(label);
    }

    let display_arms = variant_idents
        .iter()
        .zip(labels.iter())
        .map(|(variant, label)| quote! { #name::#variant => #label })
        .collect::<Vec<_>>();
    let parse_arms = variant_idents
        .iter()
        .zip(labels.iter())
        .map(|(variant, label)| quote! { #label => Ok(#name::#variant) })
        .collect::<Vec<_>>();
    let decode_arms = variant_idents
        .iter()
        .zip(labels.iter())
        .map(|(variant, label)| quote! { #label => Ok(#name::#variant) })
        .collect::<Vec<_>>();

    let type_codecs = codecs::type_codecs(name, &type_name);
    Ok(quote! {
        impl rullst_orm::DatabaseEnum for #name {
            const TYPE_NAME: &'static str = #type_name;
            const VARIANTS: &'static [&'static str] = &[#(#labels),*];
        }

        impl ::std::fmt::Display for #name {
            fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
                let value = match self {
                    #(#display_arms,)*
                };
                formatter.write_str(value)
            }
        }

        impl ::std::str::FromStr for #name {
            type Err = ::std::string::String;

            fn from_str(value: &str) -> ::std::result::Result<Self, Self::Err> {
                match value {
                    #(#parse_arms,)*
                    _ => Err(::std::format!(
                        "Invalid value for enum {}: {}",
                        stringify!(#name),
                        value
                    )),
                }
            }
        }

        impl From<#name> for rullst_orm::RullstValue {
            fn from(value: #name) -> Self {
                rullst_orm::RullstValue::String(value.to_string())
            }
        }

        impl TryFrom<rullst_orm::RullstValue> for #name {
            type Error = rullst_orm::Error;

            fn try_from(value: rullst_orm::RullstValue) -> Result<Self, Self::Error> {
                let value: String = value.try_into().map_err(|_| {
                    rullst_orm::Error::Internal("Enum value must be a string".to_string())
                })?;
                value.parse().map_err(|error: String| rullst_orm::Error::Internal(error))
            }
        }

        impl rullst_orm::_serde::Serialize for #name {
            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
            where
                S: rullst_orm::_serde::Serializer,
            {
                serializer.serialize_str(&self.to_string())
            }
        }

        impl<'de> rullst_orm::_serde::Deserialize<'de> for #name {
            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
            where
                D: rullst_orm::_serde::Deserializer<'de>,
            {
                let value = <String as rullst_orm::_serde::Deserialize<'de>>::deserialize(deserializer)?;
                value.parse().map_err(rullst_orm::_serde::de::Error::custom)
            }
        }

        impl<'query, DB> rullst_orm::_sqlx::Encode<'query, DB> for #name
        where
            DB: rullst_orm::_sqlx::Database,
            &'query str: rullst_orm::_sqlx::Encode<'query, DB>,
        {
            fn encode_by_ref(
                &self,
                buffer: &mut <DB as rullst_orm::_sqlx::Database>::ArgumentBuffer,
            ) -> Result<rullst_orm::_sqlx::encode::IsNull, rullst_orm::_sqlx::error::BoxDynError> {
                let value = match self {
                    #(#display_arms,)*
                };
                <&str as rullst_orm::_sqlx::Encode<'query, DB>>::encode(value, buffer)
            }

            fn size_hint(&self) -> usize {
                let value = match self {
                    #(#display_arms,)*
                };
                <&str as rullst_orm::_sqlx::Encode<'query, DB>>::size_hint(&value)
            }
        }

        impl<'row, DB> rullst_orm::_sqlx::Decode<'row, DB> for #name
        where
            DB: rullst_orm::_sqlx::Database,
            &'row str: rullst_orm::_sqlx::Decode<'row, DB>,
        {
            fn decode(
                value: <DB as rullst_orm::_sqlx::Database>::ValueRef<'row>,
            ) -> Result<Self, rullst_orm::_sqlx::error::BoxDynError> {
                let value = <&'row str as rullst_orm::_sqlx::Decode<'row, DB>>::decode(value)?;
                match value {
                    #(#decode_arms,)*
                    _ => Err(::std::format!(
                        "invalid value {:?} for enum {}",
                        value,
                        stringify!(#name)
                    ).into()),
                }
            }
        }

        impl rullst_orm::_sqlx::Type<rullst_orm::_sqlx::Any> for #name {
            fn type_info() -> rullst_orm::_sqlx::any::AnyTypeInfo {
                <str as rullst_orm::_sqlx::Type<rullst_orm::_sqlx::Any>>::type_info()
            }
        }

        #type_codecs
    })
}

fn parse_enum_attributes(input: &DeriveInput) -> syn::Result<EnumAttributes> {
    let mut parsed = EnumAttributes::default();
    for attribute in input
        .attrs
        .iter()
        .filter(|attribute| attribute.path().is_ident("rullst_enum"))
    {
        attribute.parse_nested_meta(|meta| {
            if meta.path.is_ident("type_name") {
                if parsed.type_name.is_some() {
                    return Err(meta.error("duplicate `type_name`"));
                }
                parsed.type_name = Some(meta.value()?.parse::<LitStr>()?.value());
                return Ok(());
            }
            if meta.path.is_ident("rename_all") {
                if parsed.rename_all.is_some() {
                    return Err(meta.error("duplicate `rename_all`"));
                }
                let value = meta.value()?.parse::<LitStr>()?.value();
                parsed.rename_all = Some(match value.as_str() {
                    "lowercase" => RenameAll::Lowercase,
                    "snake_case" => RenameAll::SnakeCase,
                    _ => return Err(meta.error("rename_all must be `lowercase` or `snake_case`")),
                });
                return Ok(());
            }
            Err(meta.error("unsupported rullst_enum attribute"))
        })?;
    }
    Ok(parsed)
}

fn parse_variant_rename(variant: &syn::Variant) -> syn::Result<Option<String>> {
    let mut rename = None;
    for attribute in variant
        .attrs
        .iter()
        .filter(|attribute| attribute.path().is_ident("rullst_enum"))
    {
        attribute.parse_nested_meta(|meta| {
            if !meta.path.is_ident("rename") {
                return Err(meta.error("enum variants support only `rename`"));
            }
            if rename.is_some() {
                return Err(meta.error("duplicate `rename`"));
            }
            rename = Some(meta.value()?.parse::<LitStr>()?.value());
            Ok(())
        })?;
    }
    Ok(rename)
}

fn validate_type_name(value: &str, span: impl quote::ToTokens) -> syn::Result<()> {
    let bytes = value.as_bytes();
    if bytes.is_empty()
        || bytes.len() > MAX_LABEL_BYTES
        || !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_')
        || !bytes
            .iter()
            .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
    {
        return Err(syn::Error::new_spanned(
            span,
            "database enum type_name must be a 1-63 byte unqualified ASCII identifier",
        ));
    }
    Ok(())
}

fn validate_label(value: &str, span: impl quote::ToTokens) -> syn::Result<()> {
    if value.is_empty()
        || value.len() > MAX_LABEL_BYTES
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b' '))
    {
        return Err(syn::Error::new_spanned(
            span,
            "database enum labels must be 1-63 ASCII letters, digits, spaces, underscores or hyphens",
        ));
    }
    Ok(())
}

fn to_snake_case(value: &str) -> String {
    let characters = value.chars().collect::<Vec<_>>();
    let mut output = String::with_capacity(value.len() + 4);
    for (index, character) in characters.iter().copied().enumerate() {
        if character.is_ascii_uppercase() {
            let previous_is_lower_or_digit = index > 0
                && (characters[index - 1].is_ascii_lowercase()
                    || characters[index - 1].is_ascii_digit());
            let next_is_lower = characters
                .get(index + 1)
                .is_some_and(char::is_ascii_lowercase);
            if index > 0 && (previous_is_lower_or_digit || next_is_lower) {
                output.push('_');
            }
            output.push(character.to_ascii_lowercase());
        } else {
            output.push(character);
        }
    }
    output
}

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

    #[test]
    fn derives_closed_metadata_and_database_codecs() {
        let output = derive_enum_impl(quote! {
            #[rullst_enum(type_name = "account_status", rename_all = "snake_case")]
            pub enum Status {
                AwaitingReview,
                #[rullst_enum(rename = "live")]
                Active,
            }
        });
        let output = output.to_string();
        assert!(output.contains("DatabaseEnum for Status"));
        assert!(output.contains("account_status"));
        assert!(output.contains("awaiting_review"));
        if cfg!(feature = "runtime-driver-codecs") {
            assert!(output.contains("__rullst_enum_postgres ! (Status"));
            assert!(output.contains("__rullst_enum_mysql ! (Status"));
        } else {
            assert!(output.contains("PgHasArrayType for Status"));
            assert!(output.contains("Type < rullst_orm :: _sqlx :: MySql > for Status"));
        }
    }

    #[test]
    fn rejects_non_enum_and_data_variants() {
        let not_enum = derive_enum_impl(quote! { pub struct NotEnum { id: i32 } }).to_string();
        assert!(not_enum.contains("Enum macro can only be used on enums"));

        let data_variant = derive_enum_impl(quote! {
            pub enum Status { Active(String) }
        })
        .to_string();
        assert!(data_variant.contains("supports only unit variants"));
    }

    #[test]
    fn case_conversion_handles_words_and_acronyms() {
        assert_eq!(to_snake_case("AwaitingReview"), "awaiting_review");
        assert_eq!(to_snake_case("HTTPStatus"), "http_status");
    }
}