Skip to main content

reef_macros/
lib.rs

1//! Proc-macro impls for the `reef` crate.
2//!
3//! `#[reef::table]` is an outer attribute macro applied to a struct. It does
4//! two jobs:
5//!
6//! 1. **Strips our marker sub-attributes** before re-emitting the struct, so
7//!    the compiler doesn't choke on attributes it doesn't know about. The
8//!    sub-attributes are: `#[column(...)]` on fields, and `#[index(...)]`,
9//!    `#[primary_key(...)]`, `#[foreign_key(...)]`, `#[check(...)]` on the
10//!    struct.
11//! 2. **Validates** that those sub-attributes (and the macro's own args) use
12//!    known keys, failing fast with a clear error pointing at the bad span.
13//!
14//! It does NOT generate any code today. The struct is re-emitted essentially
15//! unchanged. `cargo reef db:push` parses the original `schema.rs` source
16//! file with `syn` separately and reads the (still-present) attributes there.
17//!
18//! Users don't depend on this crate directly — it's re-exported by `reef`.
19
20use proc_macro::TokenStream;
21use quote::quote;
22use syn::{parse_macro_input, spanned::Spanned, Attribute, Fields, ItemStruct};
23
24/// Mark a struct as a SQL table.
25///
26/// # Example
27///
28/// ```ignore
29/// #[reef::table(strict)]
30/// #[index(name = "users_email_idx", columns = ["email"])]
31/// pub struct User {
32///     #[column(primary_key, auto_increment)]
33///     pub id: i64,
34///     #[column(unique)]
35///     pub email: String,
36///     pub name: String,
37/// }
38///
39/// #[reef::table]
40/// #[primary_key(columns = ["user_id", "post_id"])]
41/// pub struct PostLike {
42///     #[column(references = "users(id)", on_delete = "cascade")]
43///     pub user_id: i64,
44///     #[column(references = "posts(id)", on_delete = "cascade")]
45///     pub post_id: i64,
46/// }
47/// ```
48///
49/// # Macro arguments — `#[reef::table(...)]`
50///
51/// - `name = "<sql_table_name>"` — override the default snake_case derivation
52/// - `strict` — emit as a SQLite STRICT table (3.37+)
53/// - `without_rowid` — emit as a WITHOUT ROWID table (perf optimization for
54///   tables with non-INTEGER primary keys)
55///
56/// # Field-level — `#[column(...)]`
57///
58/// - `primary_key` — single-column PK (use `#[primary_key(columns = [...])]`
59///   at the struct level for composite PKs)
60/// - `auto_increment` — emit `AUTOINCREMENT` (only valid with INTEGER PK)
61/// - `unique`
62/// - `default = <expr>` — Rust literal. String literals get SQL-quoted
63///   (`default = "active"` → `DEFAULT 'active'`); numerics/bools emit raw.
64/// - `default_sql = "<sql>"` — verbatim SQL passthrough for function calls
65///   like `default_sql = "datetime('now')"` (use this when you need
66///   `DEFAULT (datetime('now'))` rather than a quoted string literal).
67/// - `check = <expr>` — SQL CHECK constraint scoped to this column
68/// - `references = "<table>(<column>)"` — single-column FK target
69/// - `on_delete = "cascade" | "restrict" | "set_null" | "set_default" | "no_action"`
70/// - `on_update = "..."` — same options as `on_delete`
71/// - `generated = "<sql_expr>"` — `GENERATED ALWAYS AS (<expr>)`
72/// - `generated_kind = "stored" | "virtual"` — defaults to "virtual"
73///
74/// # Struct-level — helper attributes
75///
76/// - `#[index(name = "...", columns = [...], unique)]` — single or multi-column index
77/// - `#[primary_key(columns = [...])]` — composite primary key
78/// - `#[foreign_key(columns = [...], references = "<table>(<col>, <col>, ...)", on_delete = "...", on_update = "...")]`
79///   — composite foreign key
80/// - `#[check(name = "...", expr = "...")]` — named table-level CHECK
81#[proc_macro_attribute]
82pub fn table(attr: TokenStream, item: TokenStream) -> TokenStream {
83    // Validate macro args (#[reef::table(strict, name = "...")]).
84    let attr2: proc_macro2::TokenStream = attr.into();
85    if !attr2.is_empty() {
86        if let Err(e) = parse_table_args(attr2) {
87            return e.to_compile_error().into();
88        }
89    }
90
91    let mut item_struct = parse_macro_input!(item as ItemStruct);
92
93    // Validate + strip struct-level helper attrs.
94    for (marker, allowed) in STRUCT_HELPERS {
95        if let Err(e) = validate_attrs(&item_struct.attrs, marker, allowed) {
96            return e.to_compile_error().into();
97        }
98    }
99    item_struct
100        .attrs
101        .retain(|a| !STRUCT_HELPERS.iter().any(|(m, _)| is_marker(a, m)));
102
103    // Validate + strip field-level #[column(...)].
104    if let Fields::Named(fields) = &mut item_struct.fields {
105        for field in &mut fields.named {
106            if let Err(e) = validate_attrs(&field.attrs, "column", COLUMN_KEYS) {
107                return e.to_compile_error().into();
108            }
109            field.attrs.retain(|a| !is_marker(a, "column"));
110        }
111    }
112
113    quote! { #item_struct }.into()
114}
115
116const COLUMN_KEYS: &[&str] = &[
117    "primary_key",
118    "auto_increment",
119    "unique",
120    "default",
121    "default_sql",
122    "check",
123    "references",
124    "on_delete",
125    "on_update",
126    "generated",
127    "generated_kind",
128];
129
130const INDEX_KEYS: &[&str] = &["name", "columns", "unique"];
131const PRIMARY_KEY_KEYS: &[&str] = &["columns"];
132const FOREIGN_KEY_KEYS: &[&str] = &["columns", "references", "on_delete", "on_update"];
133const CHECK_KEYS: &[&str] = &["name", "expr"];
134
135const STRUCT_HELPERS: &[(&str, &[&str])] = &[
136    ("index", INDEX_KEYS),
137    ("primary_key", PRIMARY_KEY_KEYS),
138    ("foreign_key", FOREIGN_KEY_KEYS),
139    ("check", CHECK_KEYS),
140];
141
142const TABLE_ARG_KEYS: &[&str] = &["name", "strict", "without_rowid"];
143
144fn is_marker(attr: &Attribute, name: &str) -> bool {
145    attr.path().is_ident(name)
146}
147
148/// Walk attrs named `marker_name`, parse their meta items, and ensure each
149/// key is in `allowed`. Errors point at the offending span.
150fn validate_attrs(attrs: &[Attribute], marker_name: &str, allowed: &[&str]) -> syn::Result<()> {
151    for attr in attrs.iter().filter(|a| is_marker(a, marker_name)) {
152        attr.parse_nested_meta(|meta| {
153            let key = meta
154                .path
155                .get_ident()
156                .map(|i| i.to_string())
157                .unwrap_or_default();
158            if !allowed.contains(&key.as_str()) {
159                return Err(syn::Error::new(
160                    meta.path.span(),
161                    format!(
162                        "unknown `#[{marker_name}]` key `{key}`. Allowed: {}",
163                        allowed.join(", ")
164                    ),
165                ));
166            }
167            // Consume associated value (`= expr` or list) if present.
168            if meta.input.peek(syn::Token![=]) {
169                let _: syn::Expr = meta.value()?.parse()?;
170            }
171            Ok(())
172        })?;
173    }
174    Ok(())
175}
176
177/// Validate `#[reef::table(strict, name = "users")]` style args.
178fn parse_table_args(tokens: proc_macro2::TokenStream) -> syn::Result<()> {
179    let parser = syn::meta::parser(|meta| {
180        let key = meta
181            .path
182            .get_ident()
183            .map(|i| i.to_string())
184            .unwrap_or_default();
185        if !TABLE_ARG_KEYS.contains(&key.as_str()) {
186            return Err(syn::Error::new(
187                meta.path.span(),
188                format!(
189                    "unknown `#[reef::table]` arg `{key}`. Allowed: {}",
190                    TABLE_ARG_KEYS.join(", ")
191                ),
192            ));
193        }
194        if meta.input.peek(syn::Token![=]) {
195            let _: syn::Expr = meta.value()?.parse()?;
196        }
197        Ok(())
198    });
199    syn::parse::Parser::parse2(parser, tokens)
200}