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}