Skip to main content

rullst_orm_macros/
lib.rs

1extern crate proc_macro;
2
3use proc_macro::TokenStream;
4use syn::{DeriveInput, parse_macro_input};
5
6mod builder;
7mod enums;
8mod factory_observer;
9mod feature_gates;
10mod models;
11mod nexus;
12#[cfg_attr(mutants, mutants::skip)]
13mod parser;
14mod privacy;
15mod relationships;
16mod turso;
17
18#[cfg_attr(test, mutants::skip)]
19#[proc_macro_attribute]
20pub fn test(_args: TokenStream, input: TokenStream) -> TokenStream {
21    let input_fn = parse_macro_input!(input as syn::ItemFn);
22    let fn_name = &input_fn.sig.ident;
23    let vis = &input_fn.vis;
24    let block = &input_fn.block;
25    let attrs = &input_fn.attrs;
26
27    let expanded = quote::quote! {
28        #(#attrs)*
29        #[::tokio::test]
30        #vis async fn #fn_name() {
31            // Ensure DB is initialized (if already initialized in parallel, it ignores the error)
32            let _ = ::rullst_orm::Orm::init(&::std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string())).await;
33
34            // SQLite permits only one concurrent schema writer. Keep generated
35            // sandbox tests deterministic while preserving parallel execution
36            // for database servers that support it.
37            let _sqlite_sandbox_guard = if matches!(::rullst_orm::Orm::try_driver(), Ok("sqlite")) {
38                Some(::rullst_orm::TEST_SANDBOX_LOCK.lock().await)
39            } else {
40                None
41            };
42
43            // Start transaction for Sandbox isolation
44            let tx = ::rullst_orm::Orm::begin_transaction().await.expect("Failed to begin sandbox transaction");
45            let tx_arc = ::std::sync::Arc::new(::tokio::sync::Mutex::new(Some(tx)));
46
47            // Scope the transaction globally for this tokio task
48            ::rullst_orm::CURRENT_TX.scope(tx_arc.clone(), async move {
49                // Execute user's test
50                let __test_closure = async move {
51                    #block
52                };
53                __test_closure.await;
54            }).await;
55
56            // Automatic Rollback
57            if let Some(tx) = tx_arc.lock().await.take() {
58                let _ = tx.rollback().await;
59            }
60        }
61    };
62    TokenStream::from(expanded)
63}
64
65#[cfg_attr(test, mutants::skip)]
66#[proc_macro_derive(PersonalData, attributes(privacy))]
67pub fn derive_personal_data(input: TokenStream) -> TokenStream {
68    privacy::derive_personal_data_impl(input)
69}
70
71#[cfg_attr(test, mutants::skip)]
72/// Derives the bounded relational-enum label and codec contract.
73///
74/// Container attributes accept `type_name` and `rename_all = "lowercase"` or
75/// `"snake_case"`; unit variants accept `rename`. Runtime DDL is emitted only
76/// when the enum is passed to `rullst_orm::schema::Blueprint::native_enum`.
77#[proc_macro_derive(Enum, attributes(rullst_enum))]
78pub fn derive_enum(input: TokenStream) -> TokenStream {
79    enums::derive_enum_impl(input.into()).into()
80}
81
82#[cfg_attr(test, mutants::skip)]
83#[proc_macro_derive(Nexus, attributes(nexus))]
84pub fn derive_nexus(input: TokenStream) -> TokenStream {
85    nexus::derive_nexus_impl(input)
86}
87
88#[cfg_attr(test, mutants::skip)]
89#[proc_macro_derive(Orm, attributes(orm, sqlx))]
90pub fn rullst_macro(input: TokenStream) -> TokenStream {
91    let input = parse_macro_input!(input as DeriveInput);
92
93    // Parse the input
94    let parsed = match parser::parse(&input) {
95        Ok(p) => p,
96        Err(e) => return TokenStream::from(e.to_compile_error()),
97    };
98
99    if parsed.backend == "turso" {
100        return match turso::generate(&parsed, &input) {
101            Ok(expanded) => expanded.into(),
102            Err(error) => error.to_compile_error().into(),
103        };
104    }
105
106    // Generate relationships
107    let rels = relationships::generate(&parsed);
108
109    // Generate the builder
110    let builder_code = builder::generate(
111        &parsed,
112        &rels.flags,
113        &rels.inits,
114        &rels.methods,
115        &rels.eager_loads,
116    );
117
118    // Generate factory and observers
119    let factory_observer_code = factory_observer::generate(&parsed);
120
121    // Generate the model impl
122    let model_code = models::generate(&parsed, &rels.model_methods);
123
124    // Combine
125    let expanded = quote::quote! {
126        #builder_code
127        #factory_observer_code
128        #model_code
129    };
130
131    TokenStream::from(expanded)
132}
133
134/// Derives the typed Turso/libSQL model contract without generating SQLx code.
135#[cfg_attr(test, mutants::skip)]
136#[proc_macro_derive(TursoModel, attributes(orm, sqlx))]
137pub fn derive_turso_model(input: TokenStream) -> TokenStream {
138    let input = parse_macro_input!(input as DeriveInput);
139    let parsed = match parser::parse(&input) {
140        Ok(parsed) => parsed,
141        Err(error) => return error.to_compile_error().into(),
142    };
143    match turso::generate(&parsed, &input) {
144        Ok(expanded) => expanded.into(),
145        Err(error) => error.to_compile_error().into(),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use ::core::prelude::v1::test;
153    use syn::parse_quote;
154
155    fn run_macro_generator(input: &DeriveInput) -> (parser::ParsedModel, String, String) {
156        let parsed = parser::parse(input).unwrap();
157        let rels = relationships::generate(&parsed);
158        let builder = builder::generate(
159            &parsed,
160            &rels.flags,
161            &rels.inits,
162            &rels.methods,
163            &rels.eager_loads,
164        );
165        let _factory = factory_observer::generate(&parsed);
166        let models = models::generate(&parsed, &rels.model_methods);
167        (parsed, builder.to_string(), models.to_string())
168    }
169
170    fn assert_generated_runtime_is_zero_panic(label: &str, generated: &str) {
171        for forbidden in [
172            ". unwrap (",
173            ". expect (",
174            "panic !",
175            "todo !",
176            "unimplemented !",
177        ] {
178            assert!(
179                !generated.contains(forbidden),
180                "{label} emitted forbidden runtime code `{forbidden}`: {generated}"
181            );
182        }
183    }
184
185    #[test]
186    fn test_basic_model() {
187        let input: DeriveInput = parse_quote! {
188            #[derive(Orm)]
189            #[orm(table = "users", searchable)]
190            pub struct User {
191                pub id: i32,
192                pub name: String,
193                pub email: String,
194            }
195        };
196        let (parsed, builder, models) = run_macro_generator(&input);
197        assert_eq!(parsed.table_name, "users");
198        assert!(builder.contains("where_id"));
199        assert!(models.contains("fn delete"));
200        assert!(models.contains("fn search"));
201        assert!(models.contains("PostCommitScope :: new"));
202        assert!(models.contains("after_commit"));
203        assert!(models.contains("scout :: get_search_engine"));
204    }
205
206    #[test]
207    fn generated_query_cache_is_transaction_aware_and_strongly_namespaced() {
208        let input: DeriveInput = parse_quote! {
209            #[derive(Orm)]
210            #[orm(table = "users", tenant = "organization_id")]
211            pub struct User {
212                pub id: i32,
213                pub organization_id: i32,
214                pub name: String,
215            }
216        };
217        let (_, builder, models) = run_macro_generator(&input);
218
219        assert!(builder.contains("query_cache :: query_key"));
220        assert!(builder.contains("CURRENT_TX"));
221        assert!(builder.contains("get_with_tx_internal (& mut * * tx , false)"));
222        assert!(builder.contains("get_with_tx_internal (pool , true)"));
223        assert!(builder.contains("TTL greater than zero"));
224        assert!(!builder.contains("DefaultHasher"));
225        assert!(models.contains("find_with_tx"));
226        assert!(models.contains("Transaction < '_ >"));
227    }
228
229    #[test]
230    fn test_model_with_relations() {
231        let input: DeriveInput = parse_quote! {
232            #[derive(Orm)]
233            pub struct Post {
234                pub id: i32,
235                pub title: String,
236                #[orm(has_many = "Comment", foreign_key = "post_id", local_key = "id")]
237                comments: Option<Vec<Comment>>,
238                #[orm(has_one = "Author", foreign_key = "post_id", local_key = "id")]
239                author: Option<Author>,
240                #[orm(belongs_to = "User", foreign_key = "user_id", local_key = "id")]
241                user: Option<User>,
242                #[orm(belongs_to_many = "Tag", pivot_table = "post_tags", foreign_key = "post_id", related_key = "tag_id")]
243                tags: Option<Vec<Tag>>,
244                #[orm(morph_one = "Image", morph_name = "imageable")]
245                image: Option<Image>,
246                #[orm(morph_many = "Comment", morph_name = "commentable")]
247                morph_comments: Option<Vec<Comment>>,
248            }
249        };
250        let (parsed, _, _) = run_macro_generator(&input);
251        assert!(!parsed.relations.is_empty());
252    }
253
254    #[test]
255    fn generates_typed_inverse_polymorphic_loading() {
256        let input: DeriveInput = parse_quote! {
257            #[derive(Orm)]
258            pub struct Comment {
259                pub id: i32,
260                pub commentable_id: i32,
261                pub commentable_type: String,
262                #[orm(morph_to = "Post", morph_name = "commentable")]
263                post: Option<Post>,
264            }
265        };
266        let (parsed, builder, models) = run_macro_generator(&input);
267
268        assert_eq!(parsed.relations[0].rel_type, "morph_to");
269        assert!(models.contains("self . commentable_type != stringify ! (Post)"));
270        assert!(models.contains("self . commentable_id . clone"));
271        assert!(builder.contains("related_by_id"));
272        assert!(builder.contains("with_post"));
273    }
274
275    #[test]
276    fn generated_runtime_code_has_no_panicking_calls() {
277        let input: DeriveInput = parse_quote! {
278            #[derive(Orm)]
279            #[orm(soft_delete(field = "deleted_at", value = "null", delval = "now()"))]
280            pub struct Post {
281                pub id: i32,
282                pub title: String,
283                pub deleted_at: Option<String>,
284                #[orm(has_many = "Comment", foreign_key = "post_id", local_key = "id")]
285                comments: Option<Vec<Comment>>,
286            }
287        };
288        let (_, builder, models) = run_macro_generator(&input);
289
290        assert_generated_runtime_is_zero_panic("ORM builder", &builder);
291        assert_generated_runtime_is_zero_panic("ORM model", &models);
292    }
293
294    #[test]
295    fn generated_query_spans_are_structured_and_do_not_capture_sql() {
296        let input: DeriveInput = parse_quote! {
297            #[derive(Orm)]
298            pub struct Post {
299                pub id: i32,
300                pub title: String,
301            }
302        };
303        let (_, builder, models) = run_macro_generator(&input);
304        let generated = format!("{builder} {models}");
305
306        assert!(generated.contains("rullst.orm.query"));
307        assert!(generated.contains("orm . operation"));
308        assert!(generated.contains("orm . table"));
309        assert!(generated.contains("orm . model"));
310        assert!(!generated.contains("fields ( sql"));
311        assert!(!generated.contains("fields ( bindings"));
312    }
313
314    #[test]
315    fn inconsistent_soft_delete_state_emits_compile_error() {
316        let input: DeriveInput = parse_quote! {
317            #[derive(Orm)]
318            pub struct Post {
319                pub id: i32,
320                pub deleted_at: Option<String>,
321            }
322        };
323        let mut parsed = parser::parse(&input).unwrap();
324        assert!(parsed.has_soft_deletes);
325        parsed.soft_delete = None;
326        let rels = relationships::generate(&parsed);
327
328        let builder = builder::generate(
329            &parsed,
330            &rels.flags,
331            &rels.inits,
332            &rels.methods,
333            &rels.eager_loads,
334        )
335        .to_string();
336        let models = models::generate(&parsed, &rels.model_methods).to_string();
337
338        assert!(builder.contains("compile_error"));
339        assert!(models.contains("compile_error"));
340    }
341
342    #[test]
343    fn test_model_with_soft_deletes() {
344        let input: DeriveInput = parse_quote! {
345            #[derive(Orm)]
346            pub struct User {
347                pub id: i32,
348                pub name: String,
349                pub deleted_at: Option<String>,
350            }
351        };
352        let (parsed, builder, _) = run_macro_generator(&input);
353        assert!(parsed.has_soft_deletes);
354        assert!(builder.contains("deleted_at IS NULL"));
355    }
356
357    #[test]
358    fn test_model_with_hidden_fields() {
359        let input: DeriveInput = parse_quote! {
360            #[derive(Orm)]
361            pub struct User {
362                pub id: i32,
363                pub name: String,
364                #[orm(hidden)]
365                pub password: String,
366            }
367        };
368        let (parsed, _, models) = run_macro_generator(&input);
369        assert_eq!(parsed.hidden_fields.len(), 1);
370        assert!(models.contains("password"));
371    }
372
373    #[test]
374    fn test_model_with_explicit_soft_delete_config() {
375        let input: DeriveInput = parse_quote! {
376            #[derive(Orm)]
377            #[orm(soft_delete(field = "is_deleted", value = "0", delval = "1"))]
378            pub struct Post {
379                pub id: i32,
380                pub title: String,
381                pub is_deleted: i32,
382            }
383        };
384        let (parsed, _, _) = run_macro_generator(&input);
385        assert!(parsed.has_soft_deletes);
386    }
387
388    #[test]
389    fn test_model_with_all_hooks_and_scopes() {
390        let input: DeriveInput = parse_quote! {
391            #[derive(Orm)]
392            #[orm(global_scope = "active", tenant_column = "account_id", before_save = "hash_pwd", after_save = "log_evt", before_delete = "check_perm", after_delete = "clear_cache", after_fetch = "decrypt_data")]
393            pub struct User {
394                pub id: i32,
395                pub account_id: String,
396            }
397        };
398        let (parsed, _, _) = run_macro_generator(&input);
399        assert_eq!(parsed.global_scope, "active");
400        assert_eq!(parsed.tenant_column, "account_id");
401        assert_eq!(parsed.before_save, "hash_pwd");
402        assert_eq!(parsed.after_save, "log_evt");
403        assert_eq!(parsed.before_delete, "check_perm");
404        assert_eq!(parsed.after_delete, "clear_cache");
405        assert_eq!(parsed.after_fetch, "decrypt_data");
406    }
407
408    #[test]
409    fn test_model_with_soft_delete_null_sentinel() {
410        let input: DeriveInput = parse_quote! {
411            #[derive(Orm)]
412            #[orm(soft_delete(field = "deleted_at", value = "null", delval = "now()"))]
413            pub struct Audit {
414                pub id: i32,
415                pub message: String,
416                pub deleted_at: Option<String>,
417            }
418        };
419        run_macro_generator(&input);
420    }
421
422    #[test]
423    fn test_model_with_soft_delete_bigint_timestamp() {
424        let input: DeriveInput = parse_quote! {
425            #[derive(Orm)]
426            #[orm(soft_delete(field = "deleted_at", value = "0", delval = "UNIX_TIMESTAMP()"))]
427            pub struct Article {
428                pub id: i32,
429                pub title: String,
430                pub deleted_at: i64,
431            }
432        };
433        run_macro_generator(&input);
434    }
435
436    #[test]
437    fn test_model_with_orm_skip_field() {
438        let input: DeriveInput = parse_quote! {
439            #[derive(Orm)]
440            pub struct Account {
441                pub id: i32,
442                pub name: String,
443                #[orm(skip)]
444                pub password_hash: String,
445            }
446        };
447        run_macro_generator(&input);
448    }
449
450    #[test]
451    fn test_model_with_sqlx_skip_field() {
452        let input: DeriveInput = parse_quote! {
453            #[derive(Orm)]
454            pub struct Account {
455                pub id: i32,
456                pub name: String,
457                #[sqlx(skip)]
458                pub password_hash: String,
459            }
460        };
461        run_macro_generator(&input);
462    }
463
464    #[test]
465    fn test_model_with_combined_soft_delete_and_skip() {
466        let input: DeriveInput = parse_quote! {
467            #[derive(Orm)]
468            #[orm(soft_delete(field = "is_active", value = "true", delval = "false"))]
469            pub struct User {
470                pub id: i32,
471                pub name: String,
472                pub is_active: bool,
473                #[sqlx(skip)]
474                pub internal_note: String,
475            }
476        };
477        run_macro_generator(&input);
478    }
479
480    #[test]
481    fn test_parser_errors() {
482        // lowercase relation model
483        let input: DeriveInput = parse_quote! {
484            #[derive(Orm)]
485            pub struct Post {
486                pub id: i32,
487                #[orm(has_many = "comment")]
488                comments: Option<Vec<Comment>>,
489            }
490        };
491        let res = parser::parse(&input);
492        println!(
493            "PARSE RESULT FOR LOWERCASE RELATION: {:?}",
494            res.as_ref().map(|p| &p.table_name)
495        );
496        assert!(res.is_err());
497
498        // empty table name
499        let input: DeriveInput = parse_quote! {
500            #[derive(Orm)]
501            #[orm(table = "")]
502            pub struct Post {
503                pub id: i32,
504            }
505        };
506        assert!(parser::parse(&input).is_err());
507
508        // empty has_many
509        let input: DeriveInput = parse_quote! {
510            #[derive(Orm)]
511            pub struct Post {
512                pub id: i32,
513                #[orm(has_many = "")]
514                comments: Option<Vec<Comment>>,
515            }
516        };
517        assert!(parser::parse(&input).is_err());
518    }
519}