Skip to main content

hyperdb_api_derive/
lib.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Procedural macros for `hyperdb-api`.
5//!
6//! Currently exposes `#[derive(FromRow)]`, which generates an
7//! [`hyperdb_api::FromRow`] impl for a struct by mapping each field
8//! to a column with the matching name.
9//!
10//! Re-exported by `hyperdb-api` so callers don't need to add this
11//! crate as a direct dependency. Use it as `use hyperdb_api::FromRow;`
12//! and `#[derive(FromRow)]` on a struct.
13//!
14//! # Example
15//!
16//! ```ignore
17//! use hyperdb_api::FromRow;
18//!
19//! #[derive(FromRow)]
20//! struct User {
21//!     id: i32,
22//!     name: String,
23//!     // Map to a different column name with `rename`:
24//!     #[hyperdb(rename = "email_address")]
25//!     email: Option<String>,
26//! }
27//! ```
28//!
29//! # Attributes
30//!
31//! - `#[hyperdb(rename = "...")]` on a field uses the given column
32//!   name instead of the field name.
33//! - `#[hyperdb(index = N)]` on a field uses positional access
34//!   ([`RowAccessor::position`] / [`RowAccessor::position_opt`]) at
35//!   column index `N` instead of name-based lookup. Mutually exclusive
36//!   with `rename`.
37//! - Field types of `Option<T>` use [`RowAccessor::get_opt`] /
38//!   [`RowAccessor::position_opt`] (NULL → `None`); other field types
39//!   use [`RowAccessor::get`] / [`RowAccessor::position`] (NULL →
40//!   error).
41//!
42//! [`hyperdb_api::FromRow`]: https://docs.rs/hyperdb-api
43//! [`RowAccessor::get_opt`]: https://docs.rs/hyperdb-api
44//! [`RowAccessor::get`]: https://docs.rs/hyperdb-api
45//! [`RowAccessor::position`]: https://docs.rs/hyperdb-api
46//! [`RowAccessor::position_opt`]: https://docs.rs/hyperdb-api
47
48use proc_macro::TokenStream;
49use proc_macro2::TokenStream as TokenStream2;
50use quote::quote;
51use syn::{
52    parse_macro_input, spanned::Spanned, Data, DataStruct, DeriveInput, Field, Fields,
53    GenericArgument, LitInt, LitStr, PathArguments, Type, TypePath,
54};
55
56/// How a field maps to a column. Either by name (the default or
57/// `#[hyperdb(rename = "...")]`) or by ordinal position
58/// (`#[hyperdb(index = N)]`).
59enum FieldSource {
60    Name(String),
61    Index(usize),
62}
63
64/// Derives `hyperdb_api::FromRow` for a struct.
65///
66/// See the crate-level documentation for the full feature list.
67#[proc_macro_derive(FromRow, attributes(hyperdb))]
68pub fn from_row_derive(input: TokenStream) -> TokenStream {
69    let input = parse_macro_input!(input as DeriveInput);
70    match expand(&input) {
71        Ok(ts) => ts.into(),
72        Err(e) => e.to_compile_error().into(),
73    }
74}
75
76fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
77    let name = &input.ident;
78    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
79
80    let fields = match &input.data {
81        Data::Struct(DataStruct {
82            fields: Fields::Named(named),
83            ..
84        }) => &named.named,
85        Data::Struct(_) => {
86            return Err(syn::Error::new_spanned(
87                &input.ident,
88                "FromRow can only be derived on structs with named fields",
89            ));
90        }
91        Data::Enum(_) => {
92            return Err(syn::Error::new_spanned(
93                &input.ident,
94                "FromRow cannot be derived on enums",
95            ));
96        }
97        Data::Union(_) => {
98            return Err(syn::Error::new_spanned(
99                &input.ident,
100                "FromRow cannot be derived on unions",
101            ));
102        }
103    };
104
105    let assignments = fields
106        .iter()
107        .map(field_assignment)
108        .collect::<syn::Result<Vec<_>>>()?;
109
110    Ok(quote! {
111        #[automatically_derived]
112        impl #impl_generics ::hyperdb_api::FromRow for #name #ty_generics #where_clause {
113            fn from_row(
114                row: ::hyperdb_api::RowAccessor<'_>,
115            ) -> ::hyperdb_api::Result<Self> {
116                Ok(Self {
117                    #(#assignments),*
118                })
119            }
120        }
121    })
122}
123
124/// Generates `field_name: row.get("col")?` (or `get_opt`/`position`/`position_opt`
125/// for `Option<T>` fields and/or `#[hyperdb(index = N)]`).
126fn field_assignment(field: &Field) -> syn::Result<TokenStream2> {
127    let ident = field
128        .ident
129        .as_ref()
130        .ok_or_else(|| syn::Error::new_spanned(field, "tuple-struct fields are not supported"))?;
131    let source = field_source_for(field, ident)?;
132    let is_opt = is_option_type(&field.ty);
133
134    let getter = match (source, is_opt) {
135        (FieldSource::Name(name), true) => {
136            let lit = LitStr::new(&name, ident.span());
137            quote!(row.get_opt(#lit)?)
138        }
139        (FieldSource::Name(name), false) => {
140            let lit = LitStr::new(&name, ident.span());
141            quote!(row.get(#lit)?)
142        }
143        (FieldSource::Index(idx), true) => quote!(row.position_opt(#idx)?),
144        (FieldSource::Index(idx), false) => quote!(row.position(#idx)?),
145    };
146
147    Ok(quote! { #ident: #getter })
148}
149
150/// Reads `#[hyperdb(rename = "...")]` or `#[hyperdb(index = N)]` from a field's
151/// attributes. Falls back to a name-based source using the field's identifier.
152/// `rename` and `index` are mutually exclusive.
153fn field_source_for(field: &Field, default: &syn::Ident) -> syn::Result<FieldSource> {
154    let mut rename: Option<(String, proc_macro2::Span)> = None;
155    let mut index: Option<(usize, proc_macro2::Span)> = None;
156
157    for attr in &field.attrs {
158        if !attr.path().is_ident("hyperdb") {
159            continue;
160        }
161        attr.parse_nested_meta(|meta| {
162            if meta.path.is_ident("rename") {
163                let s: LitStr = meta.value()?.parse()?;
164                rename = Some((s.value(), meta.path.span()));
165                Ok(())
166            } else if meta.path.is_ident("index") {
167                let n: LitInt = meta.value()?.parse()?;
168                let parsed: usize = n.base10_parse()?;
169                index = Some((parsed, meta.path.span()));
170                Ok(())
171            } else {
172                Err(meta.error(format!(
173                    "unrecognized hyperdb attribute `{}`; supported attributes: rename, index",
174                    meta.path
175                        .get_ident()
176                        .map_or_else(|| "?".to_string(), ToString::to_string)
177                )))
178            }
179        })?;
180    }
181
182    match (rename, index) {
183        (Some(_), Some((_, idx_span))) => Err(syn::Error::new(
184            idx_span,
185            "`#[hyperdb(rename = ...)]` and `#[hyperdb(index = N)]` are mutually exclusive",
186        )),
187        (Some((name, _)), None) => Ok(FieldSource::Name(name)),
188        (None, Some((idx, _))) => Ok(FieldSource::Index(idx)),
189        (None, None) => Ok(FieldSource::Name(default.to_string())),
190    }
191}
192
193/// Detects `Option<T>` (any path ending in `Option<T>`).
194fn is_option_type(ty: &Type) -> bool {
195    let Type::Path(TypePath { path, qself: None }) = ty else {
196        return false;
197    };
198    let Some(last) = path.segments.last() else {
199        return false;
200    };
201    if last.ident != "Option" {
202        return false;
203    }
204    matches!(
205        last.arguments,
206        PathArguments::AngleBracketed(ref args)
207            if matches!(args.args.first(), Some(GenericArgument::Type(_)))
208    )
209}