Skip to main content

orbit_macros/
lib.rs

1//! The `OrbitModel` derive for `orbit-orm`. It is re-exported from `orbit-orm`; use that path.
2
3use proc_macro::TokenStream;
4use proc_macro2::{Span, TokenStream as TokenStream2};
5use quote::quote;
6use syn::ext::IdentExt;
7use syn::spanned::Spanned;
8use syn::{Data, DeriveInput, Error, Fields, Ident, LitStr, Result, Type, parse_macro_input};
9
10mod naming;
11
12/// Implements `FromRow` and `Model` for a struct with named fields.
13#[proc_macro_derive(OrbitModel, attributes(orbit))]
14pub fn derive_orbit_model(input: TokenStream) -> TokenStream {
15    let input = parse_macro_input!(input as DeriveInput);
16    expand(&input)
17        .unwrap_or_else(Error::into_compile_error)
18        .into()
19}
20
21/// The struct-level `#[orbit(...)]` keys.
22#[derive(Default)]
23struct Options {
24    table: Option<LitStr>,
25    primary_key: Option<LitStr>,
26}
27
28/// One named field and the column it maps to.
29struct Column {
30    ident: Ident,
31    ty: Type,
32    name: String,
33    skip: bool,
34    fillable: bool,
35}
36
37fn expand(input: &DeriveInput) -> Result<TokenStream2> {
38    if !input.generics.params.is_empty() {
39        return Err(Error::new_spanned(
40            &input.generics,
41            "OrbitModel does not support generic structs",
42        ));
43    }
44    let Data::Struct(data) = &input.data else {
45        return Err(Error::new_spanned(
46            &input.ident,
47            "OrbitModel can only be derived for a struct",
48        ));
49    };
50    let Fields::Named(named) = &data.fields else {
51        return Err(Error::new_spanned(
52            &input.ident,
53            "OrbitModel needs a struct with named fields",
54        ));
55    };
56
57    let options = struct_options(input)?;
58    let columns = named.named.iter().map(column).collect::<Result<Vec<_>>>()?;
59
60    // Without `table`, the name is Laravel's default: the plural of the struct name.
61    let table = match options.table {
62        Some(table) => {
63            if table.value().is_empty() {
64                return Err(Error::new_spanned(
65                    &table,
66                    "the table name must not be empty",
67                ));
68            }
69            table
70        }
71        None => LitStr::new(
72            &naming::table_name(&input.ident.unraw().to_string()),
73            input.ident.span(),
74        ),
75    };
76
77    let primary_key = match &options.primary_key {
78        Some(key) => {
79            let mapped = columns.iter().any(|c| !c.skip && c.name == key.value());
80            if !mapped {
81                return Err(Error::new_spanned(
82                    key,
83                    format!("no field maps to the primary key column `{}`", key.value()),
84                ));
85            }
86            key.value()
87        }
88        None => "id".to_owned(),
89    };
90
91    let name = &input.ident;
92    let fields = columns.iter().map(|c| {
93        let ident = &c.ident;
94        if c.skip {
95            quote!(#ident: ::core::default::Default::default())
96        } else {
97            let column = &c.name;
98            quote!(#ident: ::orbit_orm::__sqlx::Row::try_get(row, #column)?)
99        }
100    });
101    let fillable = columns.iter().filter(|c| c.fillable).map(|c| &c.name);
102    // Each mapped field must decode from the row's database, whichever one that is. This is the
103    // bound that sqlx's own `FromRow` derive emits, so one impl serves every enabled driver.
104    let decodes = columns.iter().filter(|c| !c.skip).map(|c| &c.ty);
105
106    Ok(quote! {
107        impl<'r, R> ::orbit_orm::__sqlx::FromRow<'r, R> for #name
108        where
109            R: ::orbit_orm::__sqlx::Row,
110            &'r ::core::primitive::str: ::orbit_orm::__sqlx::ColumnIndex<R>,
111            #(
112                #decodes: ::orbit_orm::__sqlx::Decode<'r, <R as ::orbit_orm::__sqlx::Row>::Database>
113                    + ::orbit_orm::__sqlx::Type<<R as ::orbit_orm::__sqlx::Row>::Database>,
114            )*
115        {
116            fn from_row(row: &'r R) -> ::core::result::Result<Self, ::orbit_orm::__sqlx::Error> {
117                ::core::result::Result::Ok(Self { #(#fields),* })
118            }
119        }
120
121        impl ::orbit_orm::Model for #name {
122            const TABLE: &'static str = #table;
123            const PRIMARY_KEY: &'static str = #primary_key;
124            const FILLABLE: &'static [&'static str] = &[#(#fillable),*];
125        }
126    })
127}
128
129/// Reads the struct-level `#[orbit(...)]` attributes.
130fn struct_options(input: &DeriveInput) -> Result<Options> {
131    let mut options = Options::default();
132    for attr in input.attrs.iter().filter(|a| a.path().is_ident("orbit")) {
133        attr.parse_nested_meta(|meta| {
134            if meta.path.is_ident("table") {
135                options.table = Some(meta.value()?.parse()?);
136            } else if meta.path.is_ident("primary_key") {
137                options.primary_key = Some(meta.value()?.parse()?);
138            } else {
139                return Err(meta.error("unknown key; a struct takes `table` and `primary_key`"));
140            }
141            Ok(())
142        })?;
143    }
144    Ok(options)
145}
146
147/// Reads one field's `#[orbit(...)]` attributes.
148fn column(field: &syn::Field) -> Result<Column> {
149    let ident = field.ident.clone().expect("named fields have idents");
150    let mut name: Option<LitStr> = None;
151    let mut skip: Option<Span> = None;
152    let mut fillable: Option<Span> = None;
153
154    for attr in field.attrs.iter().filter(|a| a.path().is_ident("orbit")) {
155        attr.parse_nested_meta(|meta| {
156            if meta.path.is_ident("column") {
157                name = Some(meta.value()?.parse()?);
158            } else if meta.path.is_ident("skip") {
159                skip = Some(meta.path.span());
160            } else if meta.path.is_ident("fillable") {
161                fillable = Some(meta.path.span());
162            } else {
163                return Err(
164                    meta.error("unknown key; a field takes `column`, `skip` and `fillable`")
165                );
166            }
167            Ok(())
168        })?;
169    }
170
171    if let Some(span) = skip {
172        if let Some(name) = &name {
173            return Err(Error::new_spanned(
174                name,
175                "`skip` cannot be combined with `column`",
176            ));
177        }
178        if fillable.is_some() {
179            return Err(Error::new(
180                span,
181                "`skip` cannot be combined with `fillable`",
182            ));
183        }
184    }
185    if let Some(name) = &name
186        && name.value().is_empty()
187    {
188        return Err(Error::new_spanned(
189            name,
190            "the column name must not be empty",
191        ));
192    }
193
194    let name = match &name {
195        Some(name) => name.value(),
196        None => ident.unraw().to_string(),
197    };
198    Ok(Column {
199        ident,
200        ty: field.ty.clone(),
201        name,
202        skip: skip.is_some(),
203        fillable: fillable.is_some(),
204    })
205}