renox_macros/lib.rs
1//! Procedural macros for Renox. Use them through the `renox` crate:
2//! `#[derive(Model, FromRow, DbEnum, Validate)]`, `renox::migrations!()`,
3//! `renox::embedded!()` and `#[renox::test]`.
4#![warn(missing_docs)]
5
6mod db_enum;
7mod embedded;
8mod from_row;
9mod migrations;
10mod model;
11mod validate;
12
13use proc_macro::TokenStream;
14use syn::{DeriveInput, parse_macro_input};
15
16/// Implements `renox::db::Model` for a struct with named fields.
17///
18/// ```
19/// # use renox::prelude::*;
20/// # use serde::Serialize;
21/// #[derive(Model, Serialize, Default)]
22/// #[model(table = "products", soft_deletes)]
23/// struct Product {
24/// id: i64,
25/// name: String,
26/// #[model(skip)]
27/// label: String, // not a column; filled with Default when loading
28/// created_at: Option<DateTime>,
29/// updated_at: Option<DateTime>,
30/// deleted_at: Option<DateTime>,
31/// }
32/// ```
33///
34/// - `table` defaults to the struct name in snake_case (no pluralisation).
35/// - An `id` field is required; its type is the key (`i64`, `Ulid`, `Uuid` or
36/// `String`, see `renox::db::ModelKey`).
37/// - `created_at` / `updated_at` fields are filled on save.
38/// - `soft_deletes` needs a `deleted_at: Option<DateTime>` field.
39/// - `search = "title, body"` names the text columns full-text search looks
40/// in (`Model::search`, `renox::db::search`), most important first;
41/// `search_language = "simple"` changes the language from `english`.
42/// - `default_scope = "path::to::fn"` and `hooks`: see `renox::db::Model`.
43#[proc_macro_derive(Model, attributes(model))]
44pub fn derive_model(input: TokenStream) -> TokenStream {
45 let input = parse_macro_input!(input as DeriveInput);
46 model::expand(input)
47 .unwrap_or_else(|err| err.to_compile_error())
48 .into()
49}
50
51/// Implements `renox::Validate` from `#[validate(…)]` attributes on the
52/// fields, for forms that need only rules:
53///
54/// ```
55/// # use renox::prelude::*;
56/// #[derive(serde::Deserialize, Validate)]
57/// struct Signup {
58/// #[validate(required, max = 100, label = "Full name")]
59/// name: String,
60/// #[validate(required, email, unique("users", "email"))]
61/// email: String,
62/// #[validate(required, min = 8, confirmed(&self.password_confirmation))]
63/// password: String,
64/// password_confirmation: String,
65/// #[validate(max = 5, each(required, max = 20), distinct)]
66/// tags: Vec<String>,
67/// #[validate(rename = "t-shirt", one_of(&["S", "M", "L"]))]
68/// size: String,
69/// }
70/// ```
71///
72/// Each item is a call on the field's rules, in order: `required` is
73/// `.required()`, `max = 100` is `.max(100)`, `unique("users", "email")` is
74/// `.unique("users", "email")`, so every rule of `renox::validation::Field`
75/// works, and arguments may use `self`. Three are special: `each(…)` applies
76/// rules to each item of a list (errors `tags.0`, …), `distinct` refuses
77/// repeated items, and `rename = "…"` names the field as the form does. For
78/// `prepare`, `authorize` and `after`, add `#[validate(hooks)]` on the struct
79/// and `impl renox::validation::ValidateHooks`. Anything else: implement
80/// `Validate` by hand.
81#[proc_macro_derive(Validate, attributes(validate))]
82pub fn derive_validate(input: TokenStream) -> TokenStream {
83 let input = parse_macro_input!(input as DeriveInput);
84 validate::expand(input)
85 .unwrap_or_else(|err| err.to_compile_error())
86 .into()
87}
88
89/// Implements `renox::db::FromRow`, so `sql(…).fetch_as::<T>()` can read
90/// rows of any query (joins, aggregates, a few columns) into the struct.
91///
92/// ```
93/// # use renox::prelude::*;
94/// # use serde::Serialize;
95/// #[derive(FromRow, Serialize)]
96/// struct ProductRow {
97/// id: i64,
98/// name: String,
99/// #[row(rename = "category_name")]
100/// category: Option<String>,
101/// #[row(skip)]
102/// note: String, // Default
103/// }
104/// ```
105///
106/// `derive(Model)` implements `FromRow` too.
107#[proc_macro_derive(FromRow, attributes(row))]
108pub fn derive_from_row(input: TokenStream) -> TokenStream {
109 let input = parse_macro_input!(input as DeriveInput);
110 from_row::expand(input)
111 .unwrap_or_else(|err| err.to_compile_error())
112 .into()
113}
114
115/// A fieldless enum stored as text: in the database (a `TEXT` column), in
116/// forms (`<select>`), in JSON and in templates. Variants are stored in
117/// snake_case (`OnHold` → `on_hold`) unless renamed with `#[db(rename = "…")]`.
118///
119/// Generates `as_str()`, `ALL` (every variant, e.g. for a `<select>`),
120/// `Display`, `FromStr`, `Serialize`, `Deserialize`, `ToDbValue`, and
121/// decoding on every database, so the enum can be a model field.
122///
123/// ```
124/// # use renox::prelude::*;
125/// #[derive(DbEnum, Debug, Clone, Copy, PartialEq, Default)]
126/// enum Status {
127/// #[default]
128/// Draft,
129/// Published,
130/// #[db(rename = "hidden")]
131/// Archived,
132/// }
133///
134/// assert_eq!(Status::Archived.as_str(), "hidden");
135/// assert_eq!("published".parse::<Status>().unwrap(), Status::Published);
136/// assert_eq!(Status::ALL.len(), 3);
137/// ```
138#[proc_macro_derive(DbEnum, attributes(db))]
139pub fn derive_db_enum(input: TokenStream) -> TokenStream {
140 let input = parse_macro_input!(input as DeriveInput);
141 db_enum::expand(input)
142 .unwrap_or_else(|err| err.to_compile_error())
143 .into()
144}
145
146/// Embeds the SQL migrations of a directory (default `migrations`, relative
147/// to the crate root) as `&'static [renox::db::Migration]`.
148///
149/// Files are `<timestamp>_<name>.up.sql` with an optional matching
150/// `.down.sql`, or a plain `<timestamp>_<name>.sql` that can't be rolled back.
151/// Add a `build.rs` with `println!("cargo:rerun-if-changed=migrations");` so
152/// new files are picked up (`rnx new` creates it).
153#[proc_macro]
154pub fn migrations(input: TokenStream) -> TokenStream {
155 migrations::expand(input.into())
156 .unwrap_or_else(|err| err.to_compile_error())
157 .into()
158}
159
160/// Marks an async test, like `#[tokio::test]`, using the Tokio that Renox
161/// re-exports, so apps don't need `tokio` as a dependency.
162///
163/// ```
164/// # use renox::prelude::*;
165/// # use renox::testing::TestApp;
166/// #[renox::test]
167/// async fn home_page() {
168/// let app = TestApp::new(App::new()).await;
169/// app.get("/health").await.assert_ok();
170/// }
171/// ```
172#[proc_macro_attribute]
173pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream {
174 let attr = proc_macro2::TokenStream::from(attr);
175 let item = proc_macro2::TokenStream::from(item);
176 let extra = if attr.is_empty() {
177 quote::quote! {}
178 } else {
179 quote::quote! { #attr, }
180 };
181 quote::quote! {
182 #[::renox::tokio::test(#extra crate = "::renox::tokio")]
183 #item
184 }
185 .into()
186}
187
188/// Embeds `resources/views`, `resources/lang` and `public` in the binary, so
189/// a release build runs from a single file:
190///
191/// ```
192/// # use renox::prelude::*;
193/// # let _ =
194/// App::new().embed(renox::embedded!())
195/// # ;
196/// ```
197///
198/// Files are read from disk while `APP_DEBUG` is on (templates reload), and
199/// from the binary otherwise. Add `cargo:rerun-if-changed=resources` and
200/// `=public` to `build.rs` so new files are picked up (`rnx new` does).
201#[proc_macro]
202pub fn embedded(input: TokenStream) -> TokenStream {
203 embedded::expand(input.into())
204 .unwrap_or_else(|err| err.to_compile_error())
205 .into()
206}