icydb-model-macros 0.215.4

Procedural macros for IcyDB application models
Documentation
//! Module: node::primary_key
//! Responsibility: derive-side node parsing.
//! Does not own: runtime schema semantics.
//! Boundary: macro metadata to node models.

use crate::{
    node::field_list_arg::{
        field_or_fields_duplicate_message, parse_field_list_arg, parse_scalar_field_arg,
    },
    prelude::*,
};
use darling::ast::NestedMeta;
use std::collections::HashSet;

const MAX_PRIMARY_KEY_FIELDS: usize = 4;

///
/// PrimaryKey
///

#[derive(Debug)]
pub struct PrimaryKey {
    pub(crate) fields: Vec<Ident>,
    pub(crate) source: PrimaryKeySource,
}

impl PrimaryKey {
    pub(crate) const fn fields(&self) -> &[Ident] {
        self.fields.as_slice()
    }

    pub(crate) fn scalar_field(&self) -> &Ident {
        self.fields
            .first()
            .expect("primary-key parsing must reject empty field lists")
    }
}

impl FromMeta for PrimaryKey {
    fn from_list(items: &[NestedMeta]) -> Result<Self, DarlingError> {
        let mut fields = None;
        let mut source = None;

        for item in items {
            let NestedMeta::Meta(syn::Meta::NameValue(name_value)) = item else {
                return Err(DarlingError::custom(
                    "pk(...) supports only field = \"...\", fields = [...], and source = \"...\"",
                ));
            };

            if name_value.path.is_ident("field") {
                let field = parse_scalar_field_arg("pk", &name_value.value)?;
                if fields.replace(vec![field]).is_some() {
                    return Err(
                        DarlingError::custom(field_or_fields_duplicate_message("pk"))
                            .with_span(&name_value.path),
                    );
                }
                continue;
            }

            if name_value.path.is_ident("fields") {
                if fields
                    .replace(parse_field_list_arg("pk", &name_value.value)?)
                    .is_some()
                {
                    return Err(
                        DarlingError::custom(field_or_fields_duplicate_message("pk"))
                            .with_span(&name_value.path),
                    );
                }
                continue;
            }

            if name_value.path.is_ident("source") {
                if source
                    .replace(parse_primary_key_source(&name_value.value)?)
                    .is_some()
                {
                    return Err(DarlingError::custom(
                        "pk(...) accepts only one source = \"...\" argument",
                    )
                    .with_span(&name_value.path));
                }
                continue;
            }

            return Err(DarlingError::custom(
                "pk(...) supports only field = \"...\", fields = [...], and source = \"...\"",
            )
            .with_span(&name_value.path));
        }

        let Some(fields) = fields else {
            return Err(DarlingError::custom(
                "pk(...) requires field = \"id\" or fields = [\"id\"]",
            ));
        };

        if fields.is_empty() {
            return Err(DarlingError::custom(
                "pk(fields = []) must contain at least one field",
            ));
        }
        if fields.len() > MAX_PRIMARY_KEY_FIELDS {
            return Err(DarlingError::custom(format!(
                "pk(fields = [...]) supports at most {MAX_PRIMARY_KEY_FIELDS} fields"
            ))
            .with_span(&fields[MAX_PRIMARY_KEY_FIELDS]));
        }

        let fields = fields
            .iter()
            .map(parse_primary_key_field)
            .collect::<Result<Vec<_>, _>>()?;
        reject_duplicate_primary_key_fields(&fields)?;
        let source = match (source, fields.len()) {
            (Some(PrimaryKeySource::Internal), len) if len > 1 => {
                return Err(DarlingError::custom(
                    "composite primary keys are external-only; remove source = \"internal\" or use source = \"external\"",
                )
                .with_span(&fields[1]));
            }
            (Some(source), _) => source,
            (None, len) if len > 1 => PrimaryKeySource::External,
            (None, _) => PrimaryKeySource::default(),
        };

        Ok(Self { fields, source })
    }
}

fn reject_duplicate_primary_key_fields(fields: &[Ident]) -> Result<(), DarlingError> {
    let mut seen = HashSet::new();
    for field in fields {
        let field_name = field.to_string();
        if !seen.insert(field_name.clone()) {
            return Err(DarlingError::custom(format!(
                "primary key field '{field_name}' is declared more than once"
            ))
            .with_span(field));
        }
    }

    Ok(())
}

impl HasSchemaPart for PrimaryKey {
    fn schema_part(&self) -> TokenStream {
        let fields = quote_slice(self.fields(), to_str_lit);
        let source = self.source.schema_part();

        quote! {
            ::icydb_model::node::PrimaryKey::new(#fields, #source)
        }
    }
}

fn parse_primary_key_field(literal: &LitStr) -> Result<Ident, DarlingError> {
    let value = literal.value();
    if value.is_empty() {
        return Err(DarlingError::custom("primary key field cannot be empty").with_span(literal));
    }

    syn::parse_str::<Ident>(value.as_str()).map_err(|_| {
        DarlingError::custom(format!(
            "primary key field '{value}' is not a valid Rust field identifier"
        ))
        .with_span(literal)
    })
}

fn parse_primary_key_source(expr: &syn::Expr) -> Result<PrimaryKeySource, DarlingError> {
    let syn::Expr::Lit(expr_lit) = expr else {
        return Err(
            DarlingError::custom("pk(source = ...) requires \"internal\" or \"external\"")
                .with_span(expr),
        );
    };

    PrimaryKeySource::from_value(&expr_lit.lit)
}

///
/// PrimaryKeySource
///

#[derive(Clone, Copy, Debug, Default, Eq, FromMeta, PartialEq)]
pub enum PrimaryKeySource {
    #[default]
    #[darling(rename = "internal")]
    Internal,

    #[darling(rename = "external")]
    External,
}

impl HasSchemaPart for PrimaryKeySource {
    fn schema_part(&self) -> TokenStream {
        match self {
            Self::Internal => quote!(::icydb_model::node::PrimaryKeySource::Internal),
            Self::External => quote!(::icydb_model::node::PrimaryKeySource::External),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{PrimaryKey, PrimaryKeySource};
    use darling::{FromMeta, ast::NestedMeta};
    use quote::quote;

    fn parse_primary_key(tokens: proc_macro2::TokenStream) -> Result<PrimaryKey, darling::Error> {
        let args = NestedMeta::parse_meta_list(tokens).expect("test meta should parse");
        PrimaryKey::from_list(&args)
    }

    #[test]
    fn from_list_parses_scalar_fields_syntax() {
        let primary_key = parse_primary_key(quote!(fields = ["id"]))
            .expect("scalar primary-key fields syntax should parse");

        assert_eq!(primary_key.scalar_field().to_string(), "id");
        assert_eq!(primary_key.fields().len(), 1);
        assert_eq!(primary_key.source, PrimaryKeySource::Internal);
    }

    #[test]
    fn from_list_parses_explicit_external_source() {
        let primary_key = parse_primary_key(quote!(fields = ["pid"], source = "external"))
            .expect("explicit external primary-key source should parse");

        assert_eq!(primary_key.scalar_field().to_string(), "pid");
        assert_eq!(primary_key.fields().len(), 1);
        assert_eq!(primary_key.source, PrimaryKeySource::External);
    }

    #[test]
    fn from_list_parses_scalar_field_shorthand() {
        let primary_key = parse_primary_key(quote!(field = "id"))
            .expect("scalar primary-key field shorthand should parse");

        assert_eq!(primary_key.scalar_field().to_string(), "id");
        assert_eq!(primary_key.fields().len(), 1);
        assert_eq!(primary_key.source, PrimaryKeySource::Internal);
    }

    #[test]
    fn from_list_rejects_mixed_field_and_fields_syntax() {
        let err = parse_primary_key(quote!(field = "id", fields = ["tenant_id", "id"]))
            .expect_err("primary-key field and fields syntax should be mutually exclusive");

        assert!(
            err.to_string().contains(
                "pk(...) accepts either one field = \"...\" argument or one fields = [...] argument"
            ),
            "unexpected error: {err}",
        );
    }

    #[test]
    fn from_list_rejects_comma_string_fields() {
        let err = parse_primary_key(quote!(fields = "id, name"))
            .expect_err("primary-key fields should require array syntax");

        assert!(
            err.to_string().contains("not a comma-string"),
            "unexpected error: {err}",
        );
    }

    #[test]
    fn from_list_rejects_empty_fields() {
        let err = parse_primary_key(quote!(fields = []))
            .expect_err("empty primary-key fields should reject");

        assert!(
            err.to_string().contains("at least one field"),
            "unexpected error: {err}",
        );
    }

    #[test]
    fn from_list_rejects_duplicate_fields() {
        let err = parse_primary_key(quote!(fields = ["tenant_id", "tenant_id"]))
            .expect_err("duplicate primary-key fields should reject");

        assert!(
            err.to_string()
                .contains("primary key field 'tenant_id' is declared more than once"),
            "unexpected error: {err}",
        );
    }

    #[test]
    fn from_list_rejects_too_many_fields() {
        let err = parse_primary_key(quote!(fields = ["a", "b", "c", "d", "e"]))
            .expect_err("too many primary-key fields should reject");

        assert!(
            err.to_string().contains("supports at most 4 fields"),
            "unexpected error: {err}",
        );
    }

    #[test]
    fn from_list_parses_composite_with_external_default() {
        let primary_key = parse_primary_key(quote!(fields = ["tenant_id", "local_id"]))
            .expect("composite primary-key fields syntax should parse");

        assert_eq!(
            primary_key
                .fields()
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>(),
            ["tenant_id", "local_id"],
        );
        assert_eq!(primary_key.source, PrimaryKeySource::External);
    }

    #[test]
    fn from_list_parses_explicit_external_composite_source() {
        let primary_key = parse_primary_key(quote!(
            fields = ["tenant_id", "local_id"],
            source = "external"
        ))
        .expect("explicit external composite primary-key source should parse");

        assert_eq!(primary_key.fields().len(), 2);
        assert_eq!(primary_key.source, PrimaryKeySource::External);
    }

    #[test]
    fn from_list_rejects_explicit_internal_composite_source() {
        let err = parse_primary_key(quote!(
            fields = ["tenant_id", "local_id"],
            source = "internal"
        ))
        .expect_err("internal composite primary-key source should reject");

        assert!(
            err.to_string()
                .contains("composite primary keys are external-only"),
            "unexpected error: {err}",
        );
    }
}