use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{Attribute, DeriveInput, Expr, Lit, ext::IdentExt, parse_macro_input};
use crate::common::{
effective_field_name, is_u64_type, named_fields, parse_container_serde_attrs,
parse_field_serde_attrs, resolve_field_type, validate_schema_field_name,
};
pub fn anda_db_schema_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
TokenStream::from(expand_anda_db_schema_derive(input))
}
pub(crate) fn expand_anda_db_schema_derive(input: DeriveInput) -> TokenStream2 {
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let fields = match named_fields(&input, "AndaDBSchema") {
Ok(fields) => fields,
Err(err) => return err.to_compile_error(),
};
let container = match parse_container_serde_attrs(&input.attrs) {
Ok(container) => container,
Err(err) => return err.to_compile_error(),
};
if container.transparent {
return syn::Error::new_spanned(
&input.ident,
"AndaDBSchema does not support #[serde(transparent)]: the struct serializes as its inner field, not as a map",
)
.to_compile_error();
}
let mut seen_names = std::collections::BTreeSet::new();
let mut field_entries = Vec::with_capacity(fields.len());
for field in fields {
let field_ident = field.ident.as_ref().unwrap();
let rust_name = field_ident.unraw().to_string();
let serde_attrs = parse_field_serde_attrs(&field.attrs);
if rust_name == "_id" {
if !is_u64_type(&field.ty) {
field_entries.push(
syn::Error::new_spanned(&field.ty, "The '_id' field must be of type u64")
.to_compile_error(),
);
} else if !serde_attrs.skip_serializing {
let schema_name =
effective_field_name(&rust_name, &serde_attrs, container.rename_all);
if schema_name != "_id" {
field_entries.push(
syn::Error::new_spanned(
field_ident,
format!(
"serde renames `_id` to {schema_name:?}, but the primary key must serialize as \"_id\"; add #[serde(rename = \"_id\")]"
),
)
.to_compile_error(),
);
}
}
continue;
}
if serde_attrs.skip_serializing {
continue;
}
if serde_attrs.flatten {
field_entries.push(
syn::Error::new_spanned(
field_ident,
"#[serde(flatten)] is not supported: flattened keys are inlined into the parent map and cannot be described by a single schema field",
)
.to_compile_error(),
);
continue;
}
let schema_name = effective_field_name(&rust_name, &serde_attrs, container.rename_all);
if let Err(reason) = validate_schema_field_name(&schema_name) {
field_entries.push(
syn::Error::new_spanned(
field_ident,
format!(
"schema field name {schema_name:?} is not a valid AndaDB field name ({reason}); \
field names must match [a-z0-9_]{{1,64}}. Adjust the field name or its #[serde(rename...)] attributes"
),
)
.to_compile_error(),
);
continue;
}
if schema_name == "_id" {
field_entries.push(
syn::Error::new_spanned(
field_ident,
format!(
"field {rust_name:?} serializes as \"_id\", which collides with the auto-generated primary key"
),
)
.to_compile_error(),
);
continue;
}
if !seen_names.insert(schema_name.clone()) {
field_entries.push(
syn::Error::new_spanned(
field_ident,
format!("duplicate schema field name {schema_name:?} (after serde renaming)"),
)
.to_compile_error(),
);
continue;
}
let field_type = match resolve_field_type(field) {
Ok(field_type) => field_type,
Err(err) => {
field_entries.push(err.to_compile_error());
continue;
}
};
let mut entry = quote! { FieldEntry::new(#schema_name.to_string(), #field_type)? };
let description = extract_doc_comments(&field.attrs);
if !description.is_empty() {
entry = quote! { #entry.with_description(#description.to_string()) };
}
if has_unique_attr(&field.attrs) {
entry = quote! { #entry.with_unique() };
}
field_entries.push(quote! {
builder.add_field(#entry)?;
});
}
let builder_binding = if field_entries.is_empty() {
quote! { let builder = Schema::builder(); }
} else {
quote! { let mut builder = Schema::builder(); }
};
quote! {
impl #impl_generics #name #ty_generics #where_clause {
#[doc = "Returns the AndaDB `Schema` derived from this struct's serialized fields.\n\nThe `_id` primary-key column is injected automatically by the schema builder.\nGenerated by `#[derive(AndaDBSchema)]`."]
pub fn schema() -> Result<Schema, SchemaError> {
#builder_binding
#(#field_entries)*
builder.build()
}
}
}
}
fn has_unique_attr(attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("unique"))
}
fn extract_doc_comments(attrs: &[Attribute]) -> String {
let mut doc_comments = Vec::new();
for attr in attrs {
if attr.path().is_ident("doc")
&& let Ok(meta_name_value) = attr.meta.require_name_value()
&& let Expr::Lit(expr_lit) = &meta_name_value.value
&& let Lit::Str(lit_str) = &expr_lit.lit
{
let comment = lit_str.value().trim().to_string();
if !comment.is_empty() {
doc_comments.push(comment);
}
}
}
doc_comments.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_quote;
fn tokens(input: TokenStream2) -> String {
input.to_string()
}
#[test]
fn expand_schema_generates_builder_for_supported_fields() {
let input: DeriveInput = parse_quote! {
struct User<T>
where
T: Clone
{
_id: u64,
#[serde(rename = "display_name")]
#[unique]
name: String,
#[field_type = "Option<Array<Text>>"]
tags: Vec<String>,
nested: T,
}
};
let expanded = tokens(expand_anda_db_schema_derive(input));
assert!(expanded.contains("impl < T > User < T > where T : Clone"));
assert!(expanded.contains("Schema :: builder"));
assert!(expanded.contains("\"display_name\""));
assert!(expanded.contains("with_description (\"Display name\""));
assert!(expanded.contains("with_unique"));
assert!(expanded.contains("FieldType :: Option"));
assert!(expanded.contains("< T > :: field_type ()"));
assert!(!expanded.contains("\"_id\" . to_string"));
assert!(!expanded.contains("compile_error"));
}
#[test]
fn expand_schema_honours_rename_all_and_skip() {
let input: DeriveInput = parse_quote! {
#[serde(rename_all = "snake_case")]
struct Payload {
_id: u64,
created_at: u64,
#[serde(rename = "explicit_name")]
some_field: String,
#[serde(skip)]
local_cache: String,
#[serde(skip_serializing)]
more_cache: String,
}
};
let expanded = tokens(expand_anda_db_schema_derive(input));
assert!(expanded.contains("\"created_at\""));
assert!(expanded.contains("\"explicit_name\""));
assert!(!expanded.contains("some_field"));
assert!(!expanded.contains("local_cache"));
assert!(!expanded.contains("more_cache"));
assert!(!expanded.contains("compile_error"));
}
#[test]
fn expand_schema_rejects_names_anda_db_cannot_store() {
let input: DeriveInput = parse_quote! {
#[serde(rename_all = "camelCase")]
struct Payload {
#[serde(rename = "_id")]
_id: u64,
created_at: u64,
}
};
let expanded = tokens(expand_anda_db_schema_derive(input));
assert!(expanded.contains("not a valid AndaDB field name"));
assert!(expanded.contains("createdAt"));
let input: DeriveInput = parse_quote! {
struct Payload {
#[serde(rename = "Bad-Name")]
value: u64,
}
};
assert!(
tokens(expand_anda_db_schema_derive(input)).contains("not a valid AndaDB field name")
);
}
#[test]
fn expand_schema_elides_mut_for_id_only_structs() {
let input: DeriveInput = parse_quote! {
struct OnlyId {
_id: u64,
}
};
let expanded = tokens(expand_anda_db_schema_derive(input));
assert!(expanded.contains("let builder"));
assert!(!expanded.contains("let mut builder"));
assert!(!expanded.contains("compile_error"));
}
#[test]
fn expand_schema_rejects_unsupported_shapes_and_bad_fields() {
let tuple_struct: DeriveInput = parse_quote!(
struct Tuple(u64);
);
assert!(
tokens(expand_anda_db_schema_derive(tuple_struct))
.contains("AndaDBSchema only supports structs with named fields")
);
let enum_input: DeriveInput = parse_quote!(
enum Choice {
A,
}
);
assert!(
tokens(expand_anda_db_schema_derive(enum_input))
.contains("AndaDBSchema only supports structs")
);
let bad_id: DeriveInput = parse_quote! {
struct BadId {
_id: String,
}
};
assert!(
tokens(expand_anda_db_schema_derive(bad_id))
.contains("The '_id' field must be of type u64")
);
let bad_attr: DeriveInput = parse_quote! {
struct BadAttr {
_id: u64,
#[field_type(Text)]
value: String,
}
};
assert!(tokens(expand_anda_db_schema_derive(bad_attr)).contains("field_type"));
let bad_type: DeriveInput = parse_quote! {
struct BadType {
_id: u64,
value: (u64, u64),
}
};
assert!(tokens(expand_anda_db_schema_derive(bad_type)).contains("Unsupported type"));
}
#[test]
fn expand_schema_guards_the_reserved_id_column() {
let input: DeriveInput = parse_quote! {
#[serde(rename_all = "camelCase")]
struct BadId {
_id: u64,
created_at: u64,
}
};
assert!(
tokens(expand_anda_db_schema_derive(input)).contains("must serialize as \\\"_id\\\"")
);
let input: DeriveInput = parse_quote! {
struct Collide {
#[serde(rename = "_id")]
key: u64,
}
};
assert!(tokens(expand_anda_db_schema_derive(input)).contains("collides"));
let input: DeriveInput = parse_quote! {
struct Duplicate {
#[serde(rename = "name")]
a: String,
name: String,
}
};
assert!(
tokens(expand_anda_db_schema_derive(input)).contains("duplicate schema field name")
);
let input: DeriveInput = parse_quote! {
struct WithFlatten {
_id: u64,
#[serde(flatten)]
extra: std::collections::HashMap<String, String>,
}
};
assert!(
tokens(expand_anda_db_schema_derive(input))
.contains("#[serde(flatten)] is not supported")
);
}
#[test]
fn extract_doc_comments_skips_empty_and_non_doc_attributes() {
let field: syn::Field = parse_quote! {
#[serde(rename = "ignored")]
value: String
};
assert_eq!(extract_doc_comments(&field.attrs), "First Second");
assert!(!has_unique_attr(&field.attrs));
}
}