sift-query-derive 0.1.0

Derive macros for SiftQuery entities
Documentation
use std::collections::BTreeSet;

use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::spanned::Spanned;
use syn::{Data, DeriveInput, Fields, LitStr, Path};

use crate::attrs::{has_attribute, has_sqlx_skip_or_default, sqlx_rename};
use crate::naming::to_screaming_snake;

pub(crate) fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
    if !input.generics.params.is_empty() {
        return Err(syn::Error::new(
            input.generics.span(),
            "SearchProjection does not support generic structs in M2",
        ));
    }
    let projection_ident = input.ident;
    let entity = parse_entity(&input.attrs)?;
    let fields_ident = fields_type(&entity)?;
    let fields = match input.data {
        Data::Struct(data) => match data.fields {
            Fields::Named(fields) => fields.named,
            _ => {
                return Err(syn::Error::new(
                    data.struct_token.span,
                    "SearchProjection requires a struct with named fields",
                ));
            }
        },
        _ => {
            return Err(syn::Error::new(
                projection_ident.span(),
                "SearchProjection can only be derived for structs",
            ));
        }
    };

    let mut mappings = Vec::new();
    let mut cursor_arms = Vec::new();
    let mut sources = BTreeSet::new();
    let mut aliases = BTreeSet::new();
    for field in fields {
        if has_attribute(&field.attrs, "search_ignore") {
            if !has_sqlx_skip_or_default(&field.attrs)? {
                return Err(syn::Error::new(
                    field.span(),
                    "search_ignore requires #[sqlx(skip)] or #[sqlx(default)]",
                ));
            }
            continue;
        }
        let ident = field.ident.as_ref().expect("named field");
        let rust_name = ident.to_string().trim_start_matches("r#").to_owned();
        let source = parse_source(&field.attrs)?.unwrap_or_else(|| rust_name.clone());
        let alias = sqlx_rename(&field.attrs)?.unwrap_or(rust_name);
        if alias.len() > 63 {
            return Err(syn::Error::new(
                ident.span(),
                "PostgreSQL projection aliases must be at most 63 bytes",
            ));
        }
        if !sources.insert(source.clone()) {
            return Err(syn::Error::new(
                ident.span(),
                format!("duplicate projection source {source}"),
            ));
        }
        if !aliases.insert(alias.clone()) {
            return Err(syn::Error::new(
                ident.span(),
                format!("duplicate projection alias {alias}"),
            ));
        }
        let source_ident = format_ident!("{}", to_screaming_snake(&source));
        let alias = LitStr::new(&alias, ident.span());
        mappings.push(quote! {
            ::sift_query::__private::ProjectionField {
                source: #fields_ident::#source_ident.id(),
                result_alias: #alias,
            }
        });
        cursor_arms.push(quote! {
            value if value == #fields_ident::#source_ident.id() => Some(
                ::sift_query::IntoCursorValue::to_cursor_value(&self.#ident)
            )
        });
    }
    if mappings.is_empty() {
        return Err(syn::Error::new(
            projection_ident.span(),
            "SearchProjection requires at least one mapped field",
        ));
    }

    Ok(quote! {
        impl ::sift_query::__private::SearchProjection<#entity> for #projection_ident {
            const FIELDS: &'static [::sift_query::__private::ProjectionField] =
                &[#(#mappings),*];

            fn cursor_value(
                &self,
                source: ::sift_query::__private::FieldId,
            ) -> Option<::sift_query::CursorValue> {
                match source {
                    #(#cursor_arms,)*
                    _ => None,
                }
            }
        }
    })
}

fn parse_entity(attrs: &[syn::Attribute]) -> syn::Result<Path> {
    let mut entity = None;
    for attribute in attrs
        .iter()
        .filter(|attribute| attribute.path().is_ident("search_projection"))
    {
        attribute.parse_nested_meta(|meta| {
            if meta.path.is_ident("entity") {
                let value = meta.value()?;
                entity = Some(value.parse::<Path>()?);
                Ok(())
            } else {
                Err(meta.error("unknown search_projection option"))
            }
        })?;
    }
    entity.ok_or_else(|| {
        syn::Error::new(
            proc_macro2::Span::call_site(),
            "SearchProjection requires #[search_projection(entity = EntityType)]",
        )
    })
}

fn fields_type(entity: &Path) -> syn::Result<Path> {
    let mut fields = entity.clone();
    let Some(last) = fields.segments.last_mut() else {
        return Err(syn::Error::new(entity.span(), "invalid entity path"));
    };
    if !matches!(last.arguments, syn::PathArguments::None) {
        return Err(syn::Error::new(
            last.arguments.span(),
            "generic projection entities are not supported",
        ));
    }
    last.ident = format_ident!("{}Fields", last.ident);
    Ok(fields)
}

fn parse_source(attrs: &[syn::Attribute]) -> syn::Result<Option<String>> {
    let mut source = None;
    for attribute in attrs
        .iter()
        .filter(|attribute| attribute.path().is_ident("search_field"))
    {
        attribute.parse_nested_meta(|meta| {
            if meta.path.is_ident("source") {
                let value = meta.value()?.parse::<LitStr>()?.value();
                if source.replace(value).is_some() {
                    return Err(meta.error("duplicate projection source"));
                }
                Ok(())
            } else {
                Err(meta.error("projection fields only support source"))
            }
        })?;
    }
    Ok(source)
}