rydantic-derive 0.4.0

Derive macros for Rydantic
Documentation
use std::collections::HashSet;
use syn::{spanned::Spanned, Attribute, Expr, Lit, LitInt, LitStr, Path};

#[derive(Default)]
pub struct Container {
    pub ignore_extra: bool,
    pub allow_extra: bool,
    pub untagged: bool,
    pub async_validate: Option<Path>,
    pub title: Option<String>,
    pub validate: Option<Path>,
    pub tag: Option<String>,
    pub capnp: Option<Path>,
    pub capnp_enum: Option<Path>,
}

#[derive(Default)]
pub struct Field {
    pub rename: Option<String>,
    pub validation_alias: Option<String>,
    pub serialization_alias: Option<String>,
    pub description: Option<String>,
    pub trim: bool,
    pub lowercase: bool,
    pub uppercase: bool,
    pub coerce: bool,
    pub email: bool,
    pub required: bool,
    pub exclude: bool,
    pub min_length: Option<usize>,
    pub max_length: Option<usize>,
    pub ge: Option<String>,
    pub gt: Option<String>,
    pub le: Option<String>,
    pub lt: Option<String>,
    pub pattern: Option<String>,
    pub default_trait: bool,
    pub default: Option<Expr>,
    pub default_factory: Option<Path>,
    pub validate: Option<Path>,
    pub before: Option<Path>,
    pub async_validate: Option<Path>,
    pub flatten: bool,
    pub extra: bool,
    pub capnp_name: Option<String>,
    pub capnp_ordinal: Option<u16>,
    pub capnp_as: Option<syn::Type>,
    pub capnp_data: bool,
    pub capnp_text: bool,
    pub capnp_enum: bool,
    pub capnp_skip: bool,
}

fn key(meta: &syn::meta::ParseNestedMeta<'_>, seen: &mut HashSet<String>) -> syn::Result<String> {
    let key = meta
        .path
        .get_ident()
        .ok_or_else(|| meta.error("expected a Rydantic attribute name"))?
        .to_string();
    if !seen.insert(key.clone()) {
        return Err(meta.error(format!("duplicate Rydantic attribute `{key}`")));
    }
    Ok(key)
}

pub fn container(attrs: &[Attribute]) -> syn::Result<Container> {
    let mut result = Container::default();
    let mut seen = HashSet::new();
    for attr in attrs.iter().filter(|attr| attr.path().is_ident("model")) {
        attr.parse_nested_meta(|meta| {
            match key(&meta, &mut seen)?.as_str() {
                "extra" => {
                    let value: LitStr = meta.value()?.parse()?;
                    result.ignore_extra = match value.value().as_str() {
                        "ignore" => true,
                        "forbid" => false,
                        "allow" => {
                            result.allow_extra = true;
                            false
                        }
                        _ => {
                            return Err(syn::Error::new(
                                value.span(),
                                "extra must be \"forbid\", \"ignore\", or \"allow\"",
                            ))
                        }
                    };
                }
                "title" => result.title = Some(meta.value()?.parse::<LitStr>()?.value()),
                "validate" => result.validate = Some(meta.value()?.parse()?),
                "tag" => result.tag = Some(meta.value()?.parse::<LitStr>()?.value()),
                "capnp" => result.capnp = Some(meta.value()?.parse::<LitStr>()?.parse()?),
                "capnp_enum" => result.capnp_enum = Some(meta.value()?.parse::<LitStr>()?.parse()?),
                "untagged" => result.untagged = true,
                "async_validate" => result.async_validate = Some(meta.value()?.parse()?),
                _ => {
                    return Err(meta.error(
                        "unsupported model attribute; expected extra, title, validate, or tag",
                    ))
                }
            }
            Ok(())
        })?;
    }
    if result.untagged && result.tag.is_some() {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "untagged and tag cannot be combined",
        ));
    }
    Ok(result)
}

fn number(expr: Expr) -> syn::Result<String> {
    fn literal(expr: &Expr) -> Option<String> {
        match expr {
            Expr::Lit(value) => match &value.lit {
                Lit::Int(value) => Some(value.base10_digits().to_owned()),
                Lit::Float(value) => Some(value.base10_digits().to_owned()),
                Lit::Str(value) => Some(value.value()),
                _ => None,
            },
            Expr::Unary(value) if matches!(value.op, syn::UnOp::Neg(_)) => {
                literal(&value.expr).map(|value| format!("-{value}"))
            }
            _ => None,
        }
    }
    let value = literal(&expr).ok_or_else(|| {
        syn::Error::new(
            expr.span(),
            "numeric constraint must be a finite numeric literal",
        )
    })?;
    value.parse::<serde_json::Number>().map_err(|_| {
        syn::Error::new(
            expr.span(),
            "numeric constraint must be representable in JSON",
        )
    })?;
    Ok(value)
}

pub fn field(attrs: &[Attribute]) -> syn::Result<Field> {
    let mut result = Field::default();
    let mut seen = HashSet::new();
    for attr in attrs.iter().filter(|attr| attr.path().is_ident("model")) {
        attr.parse_nested_meta(|meta| {
            match key(&meta, &mut seen)?.as_str() {
                "rename" => result.rename = Some(meta.value()?.parse::<LitStr>()?.value()),
                "validation_alias" => {
                    result.validation_alias = Some(meta.value()?.parse::<LitStr>()?.value())
                }
                "serialization_alias" => {
                    result.serialization_alias = Some(meta.value()?.parse::<LitStr>()?.value())
                }
                "description" => {
                    result.description = Some(meta.value()?.parse::<LitStr>()?.value())
                }
                "trim" => result.trim = true,
                "lowercase" => result.lowercase = true,
                "uppercase" => result.uppercase = true,
                "coerce" => result.coerce = true,
                "email" => result.email = true,
                "required" => result.required = true,
                "exclude" => result.exclude = true,
                "min_length" => {
                    result.min_length = Some(meta.value()?.parse::<LitInt>()?.base10_parse()?)
                }
                "max_length" => {
                    result.max_length = Some(meta.value()?.parse::<LitInt>()?.base10_parse()?)
                }
                "ge" => result.ge = Some(number(meta.value()?.parse()?)?),
                "gt" => result.gt = Some(number(meta.value()?.parse()?)?),
                "le" => result.le = Some(number(meta.value()?.parse()?)?),
                "lt" => result.lt = Some(number(meta.value()?.parse()?)?),
                "pattern" => {
                    let pattern: LitStr = meta.value()?.parse()?;
                    regex::Regex::new(&pattern.value()).map_err(|error| {
                        syn::Error::new(
                            pattern.span(),
                            format!("invalid regular expression: {error}"),
                        )
                    })?;
                    result.pattern = Some(pattern.value());
                }
                "default" => {
                    if meta.input.peek(syn::Token![=]) {
                        result.default = Some(meta.value()?.parse()?);
                    } else {
                        result.default_trait = true;
                    }
                }
                "default_factory" => result.default_factory = Some(meta.value()?.parse()?),
                "validate" => result.validate = Some(meta.value()?.parse()?),
                "before" => result.before = Some(meta.value()?.parse()?),
                "async_validate" => result.async_validate = Some(meta.value()?.parse()?),
                "capnp_name" => result.capnp_name = Some(meta.value()?.parse::<LitStr>()?.value()),
                "capnp_ordinal" => {
                    result.capnp_ordinal = Some(meta.value()?.parse::<LitInt>()?.base10_parse()?)
                }
                "capnp_as" => result.capnp_as = Some(meta.value()?.parse::<LitStr>()?.parse()?),
                "capnp_data" => result.capnp_data = true,
                "capnp_text" => result.capnp_text = true,
                "capnp_enum" => result.capnp_enum = true,
                "capnp_skip" => result.capnp_skip = true,
                "flatten" => result.flatten = true,
                "extra" => result.extra = true,
                _ => return Err(meta.error("unsupported Rydantic field attribute")),
            }
            Ok(())
        })?;
    }
    if result.flatten && result.extra {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "flatten and extra cannot be combined",
        ));
    }
    if result.lowercase && result.uppercase {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "lowercase and uppercase cannot be combined",
        ));
    }
    if result
        .min_length
        .zip(result.max_length)
        .is_some_and(|(min, max)| min > max)
    {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "min_length must be <= max_length",
        ));
    }
    if result.has_default() && result.required {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "required cannot be combined with a default",
        ));
    }
    if (result.default_trait || result.default.is_some()) && result.default_factory.is_some() {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "default and default_factory cannot be combined",
        ));
    }
    Ok(result)
}

impl Field {
    pub fn has_rules(&self) -> bool {
        self.trim
            || self.lowercase
            || self.uppercase
            || self.email
            || self.pattern.is_some()
            || self.min_length.is_some()
            || self.max_length.is_some()
            || self.ge.is_some()
            || self.gt.is_some()
            || self.le.is_some()
            || self.lt.is_some()
    }
    pub fn has_default(&self) -> bool {
        self.default_trait || self.default.is_some() || self.default_factory.is_some()
    }
}

pub fn variant_name(attrs: &[Attribute], fallback: &str) -> syn::Result<String> {
    variant_names(attrs, fallback).map(|(json, _)| json)
}

pub fn native_variant_name(attrs: &[Attribute], fallback: &str) -> syn::Result<String> {
    variant_names(attrs, fallback).map(|(json, native)| native.unwrap_or(json))
}

fn variant_names(attrs: &[Attribute], fallback: &str) -> syn::Result<(String, Option<String>)> {
    let mut name = fallback.to_owned();
    let mut native = None;
    let mut seen = HashSet::new();
    for attr in attrs.iter().filter(|attr| attr.path().is_ident("model")) {
        attr.parse_nested_meta(|meta| {
            match key(&meta, &mut seen)?.as_str() {
                "rename" => name = meta.value()?.parse::<LitStr>()?.value(),
                "capnp_name" => native = Some(meta.value()?.parse::<LitStr>()?.value()),
                "capnp_ordinal" => {
                    let _: u16 = meta.value()?.parse::<LitInt>()?.base10_parse()?;
                }
                _ => {
                    return Err(
                        meta.error("enum variants support rename, capnp_name and capnp_ordinal")
                    )
                }
            }
            Ok(())
        })?;
    }
    Ok((name, native))
}