finance_query_derive/
lib.rs

1//! # finance-query-derive
2//!
3//! Procedural macros for the `finance-query` library.
4//!
5//! This crate provides derive macros that automatically generate code for working with
6//! financial data structures, particularly for integration with the Polars DataFrame library.
7//!
8//! ## Features
9//!
10//! - **`ToDataFrame`**: Automatically implement DataFrame conversion for structs
11//!
12//! ## Usage
13//!
14//! This crate is automatically included when you enable the `dataframe` feature in `finance-query`:
15//!
16//! ```toml
17//! [dependencies]
18//! finance-query = { version = "2.0", features = ["dataframe"] }
19//! ```
20//!
21//! ## Example
22//!
23//! ```ignore
24//! use finance_query::ToDataFrame;
25//! use polars::prelude::*;
26//!
27//! #[derive(ToDataFrame)]
28//! struct Quote {
29//!     symbol: String,
30//!     price: Option<f64>,
31//!     volume: Option<i64>,
32//! }
33//!
34//! // Automatically generates:
35//! // - to_dataframe(&self) -> PolarsResult<DataFrame>
36//! // - vec_to_dataframe(&[Self]) -> PolarsResult<DataFrame>
37//!
38//! let quote = Quote {
39//!     symbol: "AAPL".to_string(),
40//!     price: Some(150.0),
41//!     volume: Some(1000000),
42//! };
43//!
44//! let df = quote.to_dataframe()?;
45//! ```
46//!
47//! ## Supported Types
48//!
49//! The `ToDataFrame` derive macro supports the following field types:
50//!
51//! - **Primitives**: `i32`, `i64`, `u32`, `u64`, `f64`, `bool`
52//! - **Strings**: `String`, `Option<String>`
53//! - **Optional primitives**: `Option<i32>`, `Option<f64>`, etc.
54//! - **FormattedValue**: `Option<FormattedValue<f64>>`, `Option<FormattedValue<i64>>`
55//!   (automatically extracts the `.raw` field)
56//!
57//! Complex types like nested structs and vectors are automatically skipped and won't
58//! appear in the generated DataFrame.
59//!
60//! ## Generated Methods
61//!
62//! For each struct with `#[derive(ToDataFrame)]`, two methods are generated:
63//!
64//! ### `to_dataframe(&self)`
65//!
66//! Converts a single instance to a one-row DataFrame:
67//!
68//! ```ignore
69//! let quote = Quote { /* ... */ };
70//! let df: DataFrame = quote.to_dataframe()?;
71//! ```
72//!
73//! ### `vec_to_dataframe(items: &[Self])`
74//!
75//! Converts a slice of instances to a multi-row DataFrame:
76//!
77//! ```ignore
78//! let quotes = vec![quote1, quote2, quote3];
79//! let df: DataFrame = Quote::vec_to_dataframe(&quotes)?;
80//! ```
81
82#![warn(missing_docs)]
83#![warn(rustdoc::missing_crate_level_docs)]
84
85use proc_macro::TokenStream;
86use proc_macro2::TokenStream as TokenStream2;
87use quote::quote;
88use syn::{
89    Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, TypePath, parse_macro_input,
90};
91
92/// Derive macro for automatic DataFrame conversion.
93///
94/// Generates a `to_dataframe(&self) -> PolarsResult<DataFrame>` method
95/// that converts all struct fields to DataFrame columns.
96///
97/// # Supported Types
98///
99/// - `String` → String column
100/// - `Option<String>` → nullable String column
101/// - `Option<FormattedValue<f64>>` → extracts `.raw` as `Option<f64>`
102/// - `Option<FormattedValue<i64>>` → extracts `.raw` as `Option<i64>`
103/// - `i32`, `i64`, `f64`, `bool` → direct columns
104/// - `Option<T>` for primitives → nullable columns
105/// - Nested structs/Vec → skipped (complex types not suitable for flat DataFrame)
106///
107/// # Example
108///
109/// ```ignore
110/// #[derive(ToDataFrame)]
111/// pub struct Quote {
112///     pub symbol: String,
113///     pub price: Option<FormattedValue<f64>>,
114/// }
115///
116/// // Generates:
117/// impl Quote {
118///     pub fn to_dataframe(&self) -> PolarsResult<DataFrame> {
119///         df![
120///             "symbol" => [self.symbol.as_str()],
121///             "price" => [self.price.as_ref().and_then(|v| v.raw)],
122///         ]
123///     }
124/// }
125/// ```
126#[proc_macro_derive(ToDataFrame)]
127pub fn derive_to_dataframe(input: TokenStream) -> TokenStream {
128    let input = parse_macro_input!(input as DeriveInput);
129    let name = &input.ident;
130
131    let fields = match &input.data {
132        Data::Struct(data) => match &data.fields {
133            Fields::Named(fields) => &fields.named,
134            _ => {
135                return syn::Error::new_spanned(
136                    &input,
137                    "ToDataFrame only supports structs with named fields",
138                )
139                .to_compile_error()
140                .into();
141            }
142        },
143        _ => {
144            return syn::Error::new_spanned(&input, "ToDataFrame only supports structs")
145                .to_compile_error()
146                .into();
147        }
148    };
149
150    let mut column_names: Vec<String> = Vec::new();
151    let mut column_values: Vec<TokenStream2> = Vec::new();
152
153    for field in fields.iter() {
154        let field_name = field.ident.as_ref().unwrap();
155        let field_name_str = to_snake_case(&field_name.to_string());
156        let field_type = &field.ty;
157
158        if let Some(value_expr) = generate_column_value(field_name, field_type) {
159            column_names.push(field_name_str);
160            column_values.push(value_expr);
161        }
162        // Skip fields that return None (complex nested types)
163    }
164
165    // Generate vec column value expressions (for vec_to_dataframe)
166    let mut vec_column_values: Vec<TokenStream2> = Vec::new();
167    for field in fields.iter() {
168        let field_name = field.ident.as_ref().unwrap();
169        let field_type = &field.ty;
170
171        if let Some(value_expr) = generate_vec_column_value(field_name, field_type) {
172            vec_column_values.push(value_expr);
173        }
174    }
175
176    let expanded = quote! {
177        #[cfg(feature = "dataframe")]
178        impl #name {
179            /// Converts this struct to a single-row polars DataFrame.
180            ///
181            /// All scalar fields are included as columns. Nested objects
182            /// and complex types are excluded.
183            ///
184            /// This method is auto-generated by the `ToDataFrame` derive macro.
185            pub fn to_dataframe(&self) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
186                use ::polars::prelude::*;
187                df![
188                    #( #column_names => #column_values ),*
189                ]
190            }
191
192            /// Converts a slice of structs to a multi-row polars DataFrame.
193            ///
194            /// All scalar fields are included as columns. Nested objects
195            /// and complex types are excluded.
196            ///
197            /// This method is auto-generated by the `ToDataFrame` derive macro.
198            pub fn vec_to_dataframe(items: &[Self]) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
199                use ::polars::prelude::*;
200                df![
201                    #( #column_names => #vec_column_values ),*
202                ]
203            }
204        }
205    };
206
207    TokenStream::from(expanded)
208}
209
210/// Converts a field name to snake_case for DataFrame column names.
211fn to_snake_case(s: &str) -> String {
212    s.to_string()
213}
214
215/// Generates the value expression for a DataFrame column based on field type.
216///
217/// Returns `None` for complex types that should be skipped.
218fn generate_column_value(field_name: &syn::Ident, field_type: &Type) -> Option<TokenStream2> {
219    match field_type {
220        // Direct String
221        Type::Path(type_path) if is_string(type_path) => {
222            Some(quote! { [self.#field_name.as_str()] })
223        }
224
225        // Direct FormattedValue<T> - extract .raw
226        Type::Path(type_path) if is_formatted_value(type_path) => {
227            Some(quote! { [self.#field_name.raw] })
228        }
229
230        // Option<T>
231        Type::Path(type_path) if is_option(type_path) => {
232            let inner_type = get_option_inner_type(type_path)?;
233            generate_option_value(field_name, inner_type)
234        }
235
236        // Direct primitives: i32, i64, f64, bool
237        Type::Path(type_path) if is_primitive(type_path) => Some(quote! { [self.#field_name] }),
238
239        // Skip all other types (Vec, nested structs, etc.)
240        _ => None,
241    }
242}
243
244/// Generates the value expression for a DataFrame column when iterating over a Vec.
245///
246/// Returns `None` for complex types that should be skipped.
247fn generate_vec_column_value(field_name: &syn::Ident, field_type: &Type) -> Option<TokenStream2> {
248    match field_type {
249        // Direct String
250        Type::Path(type_path) if is_string(type_path) => {
251            Some(quote! { items.iter().map(|item| item.#field_name.as_str()).collect::<Vec<_>>() })
252        }
253
254        // Direct FormattedValue<T> - extract .raw
255        Type::Path(type_path) if is_formatted_value(type_path) => {
256            Some(quote! { items.iter().map(|item| item.#field_name.raw).collect::<Vec<_>>() })
257        }
258
259        // Option<T>
260        Type::Path(type_path) if is_option(type_path) => {
261            let inner_type = get_option_inner_type(type_path)?;
262            generate_vec_option_value(field_name, inner_type)
263        }
264
265        // Direct primitives: i32, i64, f64, bool
266        Type::Path(type_path) if is_primitive(type_path) => {
267            Some(quote! { items.iter().map(|item| item.#field_name).collect::<Vec<_>>() })
268        }
269
270        // Skip all other types (Vec, nested structs, etc.)
271        _ => None,
272    }
273}
274
275/// Generates value expression for Option<T> fields when iterating over a Vec.
276fn generate_vec_option_value(field_name: &syn::Ident, inner_type: &Type) -> Option<TokenStream2> {
277    match inner_type {
278        // Option<String>
279        Type::Path(type_path) if is_string(type_path) => Some(
280            quote! { items.iter().map(|item| item.#field_name.as_deref()).collect::<Vec<_>>() },
281        ),
282
283        // Option<FormattedValue<T>> - extract .raw
284        Type::Path(type_path) if is_formatted_value(type_path) => Some(
285            quote! { items.iter().map(|item| item.#field_name.as_ref().and_then(|v| v.raw)).collect::<Vec<_>>() },
286        ),
287
288        // Option<primitive>
289        Type::Path(type_path) if is_primitive(type_path) => {
290            Some(quote! { items.iter().map(|item| item.#field_name).collect::<Vec<_>>() })
291        }
292
293        // Skip complex Option<T> types
294        _ => None,
295    }
296}
297
298/// Generates value expression for Option<T> fields.
299fn generate_option_value(field_name: &syn::Ident, inner_type: &Type) -> Option<TokenStream2> {
300    match inner_type {
301        // Option<String>
302        Type::Path(type_path) if is_string(type_path) => {
303            Some(quote! { [self.#field_name.as_deref()] })
304        }
305
306        // Option<FormattedValue<T>> - extract .raw
307        Type::Path(type_path) if is_formatted_value(type_path) => {
308            Some(quote! { [self.#field_name.as_ref().and_then(|v| v.raw)] })
309        }
310
311        // Option<primitive>
312        Type::Path(type_path) if is_primitive(type_path) => Some(quote! { [self.#field_name] }),
313
314        // Skip complex Option<T> types
315        _ => None,
316    }
317}
318
319/// Checks if a type path is `String`.
320fn is_string(type_path: &TypePath) -> bool {
321    type_path
322        .path
323        .segments
324        .last()
325        .map(|seg| seg.ident == "String")
326        .unwrap_or(false)
327}
328
329/// Checks if a type path is `Option<T>`.
330fn is_option(type_path: &TypePath) -> bool {
331    type_path
332        .path
333        .segments
334        .last()
335        .map(|seg| seg.ident == "Option")
336        .unwrap_or(false)
337}
338
339/// Checks if a type path is `FormattedValue<T>`.
340fn is_formatted_value(type_path: &TypePath) -> bool {
341    type_path
342        .path
343        .segments
344        .last()
345        .map(|seg| seg.ident == "FormattedValue")
346        .unwrap_or(false)
347}
348
349/// Checks if a type path is a primitive type (i32, i64, f64, bool).
350fn is_primitive(type_path: &TypePath) -> bool {
351    type_path
352        .path
353        .segments
354        .last()
355        .map(|seg| {
356            let name = seg.ident.to_string();
357            matches!(
358                name.as_str(),
359                "i32" | "i64" | "f64" | "bool" | "u32" | "u64"
360            )
361        })
362        .unwrap_or(false)
363}
364
365/// Extracts the inner type from Option<T>.
366fn get_option_inner_type(type_path: &TypePath) -> Option<&Type> {
367    let segment = type_path.path.segments.last()?;
368    if segment.ident != "Option" {
369        return None;
370    }
371
372    match &segment.arguments {
373        PathArguments::AngleBracketed(args) => args.args.first().and_then(|arg| {
374            if let GenericArgument::Type(ty) = arg {
375                Some(ty)
376            } else {
377                None
378            }
379        }),
380        _ => None,
381    }
382}