elastic_mapping_macro/
lib.rs

1//! Procedural macros for the `elastic-mapping` crate.
2//!
3//! This crate provides the `#[derive(Document)]` macro that automatically generates
4//! Elasticsearch mapping implementations for Rust structs and enums.
5//!
6//! You typically don't need to depend on this crate directly. Instead, use the
7//! main `elastic-mapping` crate which re-exports the `Document` derive macro.
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use elastic_mapping::Document;
13//!
14//! #[derive(Document)]
15//! struct MyDocument {
16//!     title: String,
17//!     count: i64,
18//! }
19//! ```
20
21mod codegen;
22mod enums;
23mod error;
24mod rename;
25mod structs;
26
27use darling::{FromDeriveInput, FromField, FromMeta, FromVariant, ast::Data};
28use proc_macro::TokenStream;
29use syn::{DeriveInput, Generics, Ident, parse_macro_input};
30
31use codegen::gen_mapping_impl;
32use enums::{classify_enum, gen_enum_impl};
33use error::GeneratorError;
34use structs::gen_struct;
35
36use crate::rename::{RenameCasing, apply_rename_casing};
37
38/// Elasticsearch mapping field type constants
39const TYPE_FIELD: &str = "type";
40const PROPERTIES_FIELD: &str = "properties";
41const FIELDS_FIELD: &str = "fields";
42const ANALYZER_FIELD: &str = "analyzer";
43const INDEX_FIELD: &str = "index";
44const IGNORE_ABOVE_FIELD: &str = "ignore_above";
45
46/// Elasticsearch data type constants
47const OBJECT_TYPE: &str = "object";
48const KEYWORD_TYPE: &str = "keyword";
49
50/// Derives Elasticsearch mapping generation for structs and enums.
51///
52/// This macro implements the `MappingType` and `MappingObject` traits for your type,
53/// allowing you to call `document_mapping()` to generate Elasticsearch mappings.
54///
55/// # Supported Attributes
56///
57/// ## Container-level (Struct/Enum) Attributes
58///
59/// - `#[serde(rename_all = "...")]` - Rename all fields/variants according to the given case convention
60///   - Supported cases: `camelCase`, `PascalCase`, `snake_case`, `SCREAMING_SNAKE_CASE`, `kebab-case`, etc.
61/// - `#[serde(tag = "...")]` - Use internally tagged enum representation
62/// - `#[serde(tag = "...", content = "...")]` - Use adjacently tagged enum representation
63///
64/// ## Field-level Attributes
65///
66/// - `#[document(analyzer = "analyzer_name")]` - Sets the analyzer for a text field
67/// - `#[document(keyword(index = bool))]` - Adds a keyword subfield with index configuration
68/// - `#[document(keyword(ignore_above = u32))]` - Adds a keyword subfield with ignore_above setting
69/// - `#[serde(rename = "name")]` - Rename this field/variant
70/// - `#[serde(flatten)]` - Flatten nested structure fields into the parent
71///
72/// # Examples
73///
74/// ## Basic Struct
75///
76/// ```ignore
77/// use elastic_mapping::Document;
78///
79/// #[derive(Document)]
80/// struct Product {
81///     name: String,
82///     price: f64,
83///     in_stock: bool,
84/// }
85/// ```
86///
87/// ## With Field Annotations
88///
89/// ```ignore
90/// use elastic_mapping::Document;
91///
92/// #[derive(Document)]
93/// struct Article {
94///     #[document(analyzer = "english")]
95///     title: String,
96///     
97///     #[document(keyword(index = false))]
98///     internal_id: String,
99/// }
100/// ```
101///
102/// ## With Serde Attributes
103///
104/// ```ignore
105/// use elastic_mapping::Document;
106/// use serde::Serialize;
107///
108/// #[derive(Serialize, Document)]
109/// #[serde(rename_all = "camelCase")]
110/// struct UserProfile {
111///     first_name: String,
112///     last_name: String,
113/// }
114/// ```
115///
116/// ## Enum Support
117///
118/// ```ignore
119/// use elastic_mapping::Document;
120/// use serde::Serialize;
121///
122/// // Adjacently tagged enum
123/// #[derive(Serialize, Document)]
124/// #[serde(tag = "type", content = "data")]
125/// enum Event {
126///     Created { id: String, timestamp: i64 },
127///     Updated { id: String, changes: String },
128///     Deleted { id: String },
129/// }
130/// ```
131#[proc_macro_derive(Document, attributes(document))]
132pub fn derive_response(input: TokenStream) -> TokenStream {
133    let args = parse_macro_input!(input as DeriveInput);
134
135    match generate(args) {
136        Ok(stream) => stream,
137        Err(err) => err.write_errors().into(),
138    }
139}
140
141fn generate(input: DeriveInput) -> Result<TokenStream, GeneratorError> {
142    let args = DocumentArgs::from_derive_input(&input)?;
143
144    let ident = &args.ident;
145    let generics = &args.generics;
146
147    let output = match &args.data {
148        Data::Enum(variants) => {
149            let representation = classify_enum(&args, variants)?;
150            gen_enum_impl(ident, generics, representation)?
151        }
152        Data::Struct(fields) => {
153            let (properties, iterable) = gen_struct(fields.fields.as_slice(), args.rename_all)?;
154            gen_mapping_impl(ident, generics, "object", properties, iterable)
155        }
156    };
157
158    Ok(output.into())
159}
160
161/// Parsed arguments from the Document derive macro
162#[derive(Debug, FromDeriveInput)]
163#[darling(attributes(document, serde), allow_unknown_fields)]
164struct DocumentArgs {
165    pub(crate) ident: Ident,
166    pub(crate) generics: Generics,
167    pub(crate) data: Data<DocumentVariant, DocumentField>,
168    /// Serde - Rename all the fields (if this is a struct) or variants (if this is an enum) according to the given case convention.
169    pub(crate) rename_all: Option<RenameCasing>,
170    /// Serde - On an enum: Use the internally tagged enum representation, with the given tag.
171    pub(crate) tag: Option<String>,
172    /// Serde - On an enum: Use the adjacently tagged enum representation, with the given content field.
173    pub(crate) content: Option<String>,
174}
175
176/// Parsed variant from an enum
177#[derive(Debug, FromVariant)]
178#[darling(attributes(document, serde), allow_unknown_fields)]
179struct DocumentVariant {
180    pub(crate) ident: Ident,
181    pub(crate) fields: darling::ast::Fields<DocumentField>,
182    /// Serde - Serialize and deserialize this variant with the given name instead of its Rust name.
183    pub(crate) rename: Option<String>,
184}
185
186impl DocumentVariant {
187    /// Resolves the final variant name, accounting for rename and rename_all
188    pub(crate) fn resolve_name(&self, rename_all: Option<RenameCasing>) -> String {
189        self.rename.clone().unwrap_or_else(|| {
190            let ident_str = self.ident.to_string();
191            apply_rename_casing(&ident_str, rename_all)
192        })
193    }
194}
195
196/// Parsed field from a struct or enum variant
197#[derive(Debug, FromField)]
198#[darling(attributes(document, serde), allow_unknown_fields)]
199struct DocumentField {
200    pub(crate) ident: Option<Ident>,
201    pub(crate) ty: syn::Type,
202    /// Sets an analyzer to be used in the mapping
203    pub(crate) analyzer: Option<String>,
204    /// Keyword field configuration
205    pub(crate) keyword: Option<DocumentFieldKeyword>,
206    /// Serde - Flatten the contents of this field into the container it is defined in.
207    #[darling(default)]
208    pub(crate) flatten: bool,
209    /// Serde - Serialize and deserialize this field with the given name instead of its Rust name.
210    pub(crate) rename: Option<String>,
211}
212
213/// Configuration for keyword field mapping
214#[derive(Debug, FromMeta)]
215struct DocumentFieldKeyword {
216    pub(crate) index: Option<bool>,
217    pub(crate) ignore_above: Option<u32>,
218}
219
220/// Represents a property in the generated mapping
221struct MappingProperty {
222    pub(crate) name: String,
223    pub(crate) mapping: proc_macro2::TokenStream,
224}
225
226impl DocumentField {
227    /// Resolves the final field name, accounting for rename and rename_all
228    pub(crate) fn resolve_name(&self, rename_all: Option<RenameCasing>) -> String {
229        self.rename.clone().unwrap_or_else(|| {
230            let ident_str = self
231                .ident
232                .as_ref()
233                .map(|i| i.to_string())
234                .unwrap_or_default();
235
236            apply_rename_casing(&ident_str, rename_all)
237        })
238    }
239
240    /// Generates the mapping for this field
241    pub(crate) fn gen_mapping(&self, rename_all: Option<RenameCasing>) -> MappingProperty {
242        let ty = &self.ty;
243        let name = self.resolve_name(rename_all);
244
245        let analyzer = self.analyzer.as_ref().map(|analyzer| {
246            quote::quote! {
247                m.insert(#ANALYZER_FIELD.into(), #analyzer.into());
248            }
249        });
250
251        let keyword = self.keyword.as_ref().map(|keyword| {
252            let ignore_above = keyword.ignore_above.map(|ignore_above| {
253                quote::quote! {
254                    field_m.insert(#IGNORE_ABOVE_FIELD.into(), #ignore_above.into());
255                }
256            });
257
258            let index = keyword.index.map(|index| {
259                quote::quote! {
260                    field_m.insert(#INDEX_FIELD.into(), #index.into());
261                }
262            });
263
264            quote::quote! {
265                m.insert(#FIELDS_FIELD.into(), {
266                    let mut km = elastic_mapping::serde_json::Map::default();
267                    km.insert(#KEYWORD_TYPE.into(), {
268                        let mut field_m = elastic_mapping::serde_json::Map::default();
269                        field_m.insert(#TYPE_FIELD.into(), #KEYWORD_TYPE.into());
270                        #ignore_above
271                        #index
272                        field_m.into()
273                    });
274                    km.into()
275                });
276            }
277        });
278
279        let mapping = quote::quote! {
280            {
281                let mut m = <#ty as elastic_mapping::MappingType>::mapping();
282                #analyzer
283                #keyword
284                m.into()
285            }
286        };
287
288        MappingProperty { name, mapping }
289    }
290}