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;
#[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)
}
#[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}",
);
}
}