Skip to main content

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, GenericParam, Ident, PathArguments, Type,
90    TypeParam, TypeParamBound, TypePath, parse_macro_input,
91};
92
93/// Derive macro for automatic DataFrame conversion.
94///
95/// Generates a `to_dataframe(&self) -> PolarsResult<DataFrame>` method
96/// that converts all struct fields to DataFrame columns.
97///
98/// # Supported Types
99///
100/// - `String` → String column
101/// - `Option<String>` → nullable String column
102/// - `Option<FormattedValue<f64>>` → extracts `.raw` as `Option<f64>`
103/// - `Option<FormattedValue<i64>>` → extracts `.raw` as `Option<i64>`
104/// - `i32`, `i64`, `f64`, `bool` → direct columns
105/// - `Option<T>` for primitives → nullable columns
106/// - Nested structs/Vec → skipped (complex types not suitable for flat DataFrame)
107///
108/// # Example
109///
110/// ```ignore
111/// #[derive(ToDataFrame)]
112/// pub struct Quote {
113///     pub symbol: String,
114///     pub price: Option<FormattedValue<f64>>,
115/// }
116///
117/// // Generates:
118/// impl Quote {
119///     pub fn to_dataframe(&self) -> PolarsResult<DataFrame> {
120///         df![
121///             "symbol" => [self.symbol.as_str()],
122///             "price" => [self.price.as_ref().and_then(|v| v.raw)],
123///         ]
124///     }
125/// }
126/// ```
127#[proc_macro_derive(ToDataFrame)]
128pub fn derive_to_dataframe(input: TokenStream) -> TokenStream {
129    let input = parse_macro_input!(input as DeriveInput);
130    let name = &input.ident;
131
132    let fields = match &input.data {
133        Data::Struct(data) => match &data.fields {
134            Fields::Named(fields) => &fields.named,
135            _ => {
136                return syn::Error::new_spanned(
137                    &input,
138                    "ToDataFrame only supports structs with named fields",
139                )
140                .to_compile_error()
141                .into();
142            }
143        },
144        _ => {
145            return syn::Error::new_spanned(&input, "ToDataFrame only supports structs")
146                .to_compile_error()
147                .into();
148        }
149    };
150
151    // If the struct is generic over F: Format, generate impl for <Both> specifically.
152    let has_format_param = input.generics.params.iter().any(|param| {
153        if let GenericParam::Type(TypeParam { bounds, .. }) = param {
154            bounds.iter().any(|b| {
155                if let TypeParamBound::Trait(tb) = b {
156                    tb.path
157                        .segments
158                        .last()
159                        .map(|s| s.ident == "Format")
160                        .unwrap_or(false)
161                } else {
162                    false
163                }
164            })
165        } else {
166            false
167        }
168    });
169
170    let impl_ty = if has_format_param {
171        quote! { #name<crate::format::Both> }
172    } else {
173        quote! { #name }
174    };
175
176    // Detect the format type parameter name (e.g. `F`) to scope is_format_assoc_value correctly.
177    let format_param_ident: Option<&Ident> = input.generics.params.iter().find_map(|param| {
178        if let GenericParam::Type(TypeParam { ident, bounds, .. }) = param {
179            let is_format = bounds.iter().any(|b| {
180                if let TypeParamBound::Trait(tb) = b {
181                    tb.path
182                        .segments
183                        .last()
184                        .map(|s| s.ident == "Format")
185                        .unwrap_or(false)
186                } else {
187                    false
188                }
189            });
190            if is_format { Some(ident) } else { None }
191        } else {
192            None
193        }
194    });
195
196    let mut column_names: Vec<String> = Vec::new();
197    let mut column_values: Vec<TokenStream2> = Vec::new();
198
199    for field in fields.iter() {
200        let field_name = field.ident.as_ref().unwrap();
201        let field_name_str = to_snake_case(&field_name.to_string());
202        let field_type = &field.ty;
203
204        if let Some(value_expr) = generate_column_value(field_name, field_type, format_param_ident)
205        {
206            column_names.push(field_name_str);
207            column_values.push(value_expr);
208        }
209    }
210
211    let mut vec_column_values: Vec<TokenStream2> = Vec::new();
212    for field in fields.iter() {
213        let field_name = field.ident.as_ref().unwrap();
214        let field_type = &field.ty;
215
216        if let Some(value_expr) =
217            generate_vec_column_value(field_name, field_type, format_param_ident)
218        {
219            vec_column_values.push(value_expr);
220        }
221    }
222
223    let expanded = quote! {
224        #[cfg(feature = "dataframe")]
225        impl #impl_ty {
226            /// Converts this struct to a single-row polars DataFrame.
227            ///
228            /// All scalar fields are included as columns. Nested objects
229            /// and complex types are excluded.
230            ///
231            /// This method is auto-generated by the `ToDataFrame` derive macro.
232            pub fn to_dataframe(&self) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
233                use ::polars::prelude::*;
234                df![
235                    #( #column_names => #column_values ),*
236                ]
237            }
238
239            /// Converts a slice of structs to a multi-row polars DataFrame.
240            ///
241            /// All scalar fields are included as columns. Nested objects
242            /// and complex types are excluded.
243            ///
244            /// This method is auto-generated by the `ToDataFrame` derive macro.
245            pub fn vec_to_dataframe(items: &[Self]) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
246                use ::polars::prelude::*;
247                df![
248                    #( #column_names => #vec_column_values ),*
249                ]
250            }
251        }
252    };
253
254    TokenStream::from(expanded)
255}
256
257/// Converts a field name to snake_case for DataFrame column names.
258fn to_snake_case(s: &str) -> String {
259    s.to_string()
260}
261
262/// Generates the value expression for a DataFrame column based on field type.
263///
264/// Returns `None` for complex types that should be skipped.
265fn generate_column_value(
266    field_name: &syn::Ident,
267    field_type: &Type,
268    fmt_param: Option<&Ident>,
269) -> Option<TokenStream2> {
270    match field_type {
271        Type::Path(type_path) if is_string(type_path) => {
272            Some(quote! { [self.#field_name.as_str()] })
273        }
274        Type::Path(type_path) if is_formatted_value(type_path) => {
275            Some(quote! { [self.#field_name.raw] })
276        }
277        Type::Path(type_path) if is_option(type_path) => {
278            let inner_type = get_option_inner_type(type_path)?;
279            generate_option_value(field_name, inner_type, fmt_param)
280        }
281        Type::Path(type_path) if is_primitive(type_path) => Some(quote! { [self.#field_name] }),
282        _ => None,
283    }
284}
285
286/// Generates the value expression for a DataFrame column when iterating over a Vec.
287///
288/// Returns `None` for complex types that should be skipped.
289fn generate_vec_column_value(
290    field_name: &syn::Ident,
291    field_type: &Type,
292    fmt_param: Option<&Ident>,
293) -> Option<TokenStream2> {
294    match field_type {
295        Type::Path(type_path) if is_string(type_path) => {
296            Some(quote! { items.iter().map(|item| item.#field_name.as_str()).collect::<Vec<_>>() })
297        }
298        Type::Path(type_path) if is_formatted_value(type_path) => {
299            Some(quote! { items.iter().map(|item| item.#field_name.raw).collect::<Vec<_>>() })
300        }
301        Type::Path(type_path) if is_option(type_path) => {
302            let inner_type = get_option_inner_type(type_path)?;
303            generate_vec_option_value(field_name, inner_type, fmt_param)
304        }
305        Type::Path(type_path) if is_primitive(type_path) => {
306            Some(quote! { items.iter().map(|item| item.#field_name).collect::<Vec<_>>() })
307        }
308        _ => None,
309    }
310}
311
312/// Generates value expression for Option<T> fields when iterating over a Vec.
313fn generate_vec_option_value(
314    field_name: &syn::Ident,
315    inner_type: &Type,
316    fmt_param: Option<&Ident>,
317) -> Option<TokenStream2> {
318    match inner_type {
319        Type::Path(type_path) if is_string(type_path) => Some(
320            quote! { items.iter().map(|item| item.#field_name.as_deref()).collect::<Vec<_>>() },
321        ),
322        // Option<FormattedValue<T>> or Option<F::Value<T>> — extract .raw
323        Type::Path(type_path)
324            if is_formatted_value(type_path) || is_format_assoc_value(type_path, fmt_param) =>
325        {
326            Some(
327                quote! { items.iter().map(|item| item.#field_name.as_ref().and_then(|v| v.raw)).collect::<Vec<_>>() },
328            )
329        }
330        Type::Path(type_path) if is_primitive(type_path) => {
331            Some(quote! { items.iter().map(|item| item.#field_name).collect::<Vec<_>>() })
332        }
333        _ => None,
334    }
335}
336
337/// Generates value expression for Option<T> fields.
338fn generate_option_value(
339    field_name: &syn::Ident,
340    inner_type: &Type,
341    fmt_param: Option<&Ident>,
342) -> Option<TokenStream2> {
343    match inner_type {
344        Type::Path(type_path) if is_string(type_path) => {
345            Some(quote! { [self.#field_name.as_deref()] })
346        }
347        // Option<FormattedValue<T>> or Option<F::Value<T>> — extract .raw
348        Type::Path(type_path)
349            if is_formatted_value(type_path) || is_format_assoc_value(type_path, fmt_param) =>
350        {
351            Some(quote! { [self.#field_name.as_ref().and_then(|v| v.raw)] })
352        }
353        Type::Path(type_path) if is_primitive(type_path) => Some(quote! { [self.#field_name] }),
354        _ => None,
355    }
356}
357
358/// Checks if a type path is `String`.
359fn is_string(type_path: &TypePath) -> bool {
360    type_path
361        .path
362        .segments
363        .last()
364        .map(|seg| seg.ident == "String")
365        .unwrap_or(false)
366}
367
368/// Checks if a type path is `Option<T>`.
369fn is_option(type_path: &TypePath) -> bool {
370    type_path
371        .path
372        .segments
373        .last()
374        .map(|seg| seg.ident == "Option")
375        .unwrap_or(false)
376}
377
378/// Checks if a type path is `FormattedValue<T>`.
379fn is_formatted_value(type_path: &TypePath) -> bool {
380    type_path
381        .path
382        .segments
383        .last()
384        .map(|seg| seg.ident == "FormattedValue")
385        .unwrap_or(false)
386}
387
388/// Checks if a type path is `PARAM::Value<T>` — the associated type form used by `F: Format`.
389///
390/// Only matches when `fmt_param` is known and the first segment equals it exactly
391/// (e.g. `F::Value<f64>` where `F` is the detected format type parameter).
392/// This prevents false-positive matches on unrelated 2-segment paths like `serde_json::Value`.
393fn is_format_assoc_value(type_path: &TypePath, fmt_param: Option<&Ident>) -> bool {
394    let Some(param) = fmt_param else { return false };
395    let segs = &type_path.path.segments;
396    segs.len() == 2 && segs[0].ident == *param && segs[1].ident == "Value"
397}
398
399/// Checks if a type path is a primitive type (i32, i64, f64, bool).
400fn is_primitive(type_path: &TypePath) -> bool {
401    type_path
402        .path
403        .segments
404        .last()
405        .map(|seg| {
406            let name = seg.ident.to_string();
407            matches!(
408                name.as_str(),
409                "i32" | "i64" | "f64" | "bool" | "u32" | "u64"
410            )
411        })
412        .unwrap_or(false)
413}
414
415/// Derive macro for format-typed struct conversions.
416///
417/// For structs generic over `F: Format` (e.g. `SummaryDetail<F: Format = Both>`), generates:
418///
419/// - `impl From<Struct<Both>> for Struct<Raw>` — extracts `.raw` from each `FormattedValue` field
420/// - `impl From<Struct<Both>> for Struct<Pretty>` — extracts `.fmt` (or `.long_fmt`) instead
421/// - Convenience methods on `Struct<Both>`: `into_raw()`, `as_raw()`, `into_pretty()`, `as_pretty()`
422///
423/// Field classification:
424/// - `Option<F::Value<T>>` (any 2-segment path ending in `::Value`) → formatted field
425/// - Everything else → plain field, copied as-is
426#[proc_macro_derive(FormatConvert)]
427pub fn derive_format_convert(input: TokenStream) -> TokenStream {
428    let input = parse_macro_input!(input as DeriveInput);
429    let name = &input.ident;
430
431    let fields = match &input.data {
432        Data::Struct(data) => match &data.fields {
433            Fields::Named(fields) => &fields.named,
434            _ => {
435                return syn::Error::new_spanned(
436                    &input,
437                    "FormatConvert only supports structs with named fields",
438                )
439                .to_compile_error()
440                .into();
441            }
442        },
443        _ => {
444            return syn::Error::new_spanned(&input, "FormatConvert only supports structs")
445                .to_compile_error()
446                .into();
447        }
448    };
449
450    // Find the format type param: a generic param with a bound path ending in "Format"
451    let format_param: Option<&Ident> = input.generics.params.iter().find_map(|param| {
452        if let GenericParam::Type(TypeParam { ident, bounds, .. }) = param {
453            let has_format = bounds.iter().any(|b| {
454                if let TypeParamBound::Trait(tb) = b {
455                    tb.path
456                        .segments
457                        .last()
458                        .map(|s| s.ident == "Format")
459                        .unwrap_or(false)
460                } else {
461                    false
462                }
463            });
464            if has_format { Some(ident) } else { None }
465        } else {
466            None
467        }
468    });
469
470    let Some(format_param) = format_param else {
471        return syn::Error::new_spanned(
472            &input,
473            "FormatConvert requires a generic param bounded by Format (e.g. <F: Format = Both>)",
474        )
475        .to_compile_error()
476        .into();
477    };
478
479    // Build field classification: (field_ident, is_formatted)
480    let classified: Vec<(&syn::Field, bool)> = fields
481        .iter()
482        .map(|f| (f, is_format_value_field(&f.ty, format_param)))
483        .collect();
484
485    // Generate field conversion expressions for Raw and Pretty
486    let raw_field_exprs: Vec<_> = classified
487        .iter()
488        .map(|(f, is_fmt)| {
489            let ident = f.ident.as_ref().unwrap();
490            if *is_fmt {
491                quote! { #ident: v.#ident.and_then(|fv| fv.raw) }
492            } else {
493                quote! { #ident: v.#ident }
494            }
495        })
496        .collect();
497
498    let pretty_field_exprs: Vec<_> = classified
499        .iter()
500        .map(|(f, is_fmt)| {
501            let ident = f.ident.as_ref().unwrap();
502            if *is_fmt {
503                quote! { #ident: v.#ident.and_then(|fv| fv.fmt.or(fv.long_fmt)) }
504            } else {
505                quote! { #ident: v.#ident }
506            }
507        })
508        .collect();
509
510    let expanded = quote! {
511        impl From<#name<crate::format::Both>> for #name<crate::format::Raw> {
512            fn from(v: #name<crate::format::Both>) -> Self {
513                #name {
514                    #(#raw_field_exprs,)*
515                }
516            }
517        }
518
519        impl From<#name<crate::format::Both>> for #name<crate::format::Pretty> {
520            fn from(v: #name<crate::format::Both>) -> Self {
521                #name {
522                    #(#pretty_field_exprs,)*
523                }
524            }
525        }
526
527        impl #name<crate::format::Both> {
528            /// Convert into a [`Raw`](crate::format::Raw) view, extracting `.raw` from each `FormattedValue` field.
529            pub fn into_raw(self) -> #name<crate::format::Raw> { self.into() }
530            /// Clone and convert into a [`Raw`](crate::format::Raw) view.
531            pub fn as_raw(&self) -> #name<crate::format::Raw> { self.clone().into() }
532            /// Convert into a [`Pretty`](crate::format::Pretty) view, extracting `.fmt` from each `FormattedValue` field.
533            pub fn into_pretty(self) -> #name<crate::format::Pretty> { self.into() }
534            /// Clone and convert into a [`Pretty`](crate::format::Pretty) view.
535            pub fn as_pretty(&self) -> #name<crate::format::Pretty> { self.clone().into() }
536        }
537    };
538
539    TokenStream::from(expanded)
540}
541
542/// Returns true if `ty` is `Option<PARAM::Value<T>>` for the given format param name.
543fn is_format_value_field(ty: &Type, format_param: &Ident) -> bool {
544    let inner = match get_option_inner(ty) {
545        Some(t) => t,
546        None => return false,
547    };
548    // Check for PARAM::Value<T>
549    if let Type::Path(tp) = inner {
550        let segs = &tp.path.segments;
551        if segs.len() == 2 {
552            return segs[0].ident == *format_param && segs[1].ident == "Value";
553        }
554    }
555    false
556}
557
558/// Extracts the inner type T from Option<T>, returning None if the type isn't Option<…>.
559fn get_option_inner(ty: &Type) -> Option<&Type> {
560    if let Type::Path(tp) = ty {
561        let seg = tp.path.segments.last()?;
562        if seg.ident != "Option" {
563            return None;
564        }
565        if let PathArguments::AngleBracketed(args) = &seg.arguments {
566            return args.args.first().and_then(|a| {
567                if let GenericArgument::Type(t) = a {
568                    Some(t)
569                } else {
570                    None
571                }
572            });
573        }
574    }
575    None
576}
577
578/// Extracts the inner type from Option<T>.
579fn get_option_inner_type(type_path: &TypePath) -> Option<&Type> {
580    let segment = type_path.path.segments.last()?;
581    if segment.ident != "Option" {
582        return None;
583    }
584
585    match &segment.arguments {
586        PathArguments::AngleBracketed(args) => args.args.first().and_then(|arg| {
587            if let GenericArgument::Type(ty) = arg {
588                Some(ty)
589            } else {
590                None
591            }
592        }),
593        _ => None,
594    }
595}