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 let _ = ::rullst_orm::Orm::init(&::std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string())).await;
33
34 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 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 ::rullst_orm::CURRENT_TX.scope(tx_arc.clone(), async move {
49 let __test_closure = async move {
51 #block
52 };
53 __test_closure.await;
54 }).await;
55
56 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#[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 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 let rels = relationships::generate(&parsed);
108
109 let builder_code = builder::generate(
111 &parsed,
112 &rels.flags,
113 &rels.inits,
114 &rels.methods,
115 &rels.eager_loads,
116 );
117
118 let factory_observer_code = factory_observer::generate(&parsed);
120
121 let model_code = models::generate(&parsed, &rels.model_methods);
123
124 let expanded = quote::quote! {
126 #builder_code
127 #factory_observer_code
128 #model_code
129 };
130
131 TokenStream::from(expanded)
132}
133
134#[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 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 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 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}