Skip to main content

backbone_core/
macros.rs

1//! Code-generation macros for backbone-schema generated repositories.
2//!
3//! These macros centralise the boilerplate `CrudRepository<E>` trait impl so
4//! the generator can emit a single line instead of ~125 lines per entity.
5//!
6//! # Usage (in generated repository files)
7//!
8//! ```rust,ignore
9//! // Entities that use JSONB-based soft delete (metadata.deleted_at):
10//! backbone_core::impl_crud_repository!(UserRepository, User, soft_delete);
11//!
12//! // Entities that use hard deletes only:
13//! backbone_core::impl_crud_repository!(RoleRepository, Role, no_soft_delete);
14//! ```
15
16/// Report an `?include=` hydration failure.
17///
18/// Hydration runs after the main query and only shapes the response, so it does
19/// not fail the request — but it used to return an empty expansion SILENTLY,
20/// which read as "no related rows": every expanded relation rendered as `null`
21/// and the underlying wiring bug was invisible. Logging at error level keeps
22/// the response stable while making the failure diagnosable from service logs.
23pub fn log_include_hydration_failure(table: &str, error: &str) {
24    tracing::error!(
25        relation_table = table,
26        error = error,
27        "include hydration failed; relation expanded as null"
28    );
29}
30
31/// Implement `backbone_core::CrudRepository<E>` for a generated repository struct.
32///
33/// This macro removes ~125 lines of boilerplate per entity by centralising the
34/// `anyhow::Result → RepositoryError` mapping that is otherwise copy-pasted
35/// identically for every entity.
36///
37/// # Method mapping — `soft_delete` variant
38///
39/// | CrudRepository method         | Inherent struct method called       |
40/// |-------------------------------|-------------------------------------|
41/// | `create`                      | `create(&entity)`                   |
42/// | `find_by_id`                  | `find_by_id(id)`                    |
43/// | `find_by_id_including_deleted`| `find_deleted_by_id(id)`            |
44/// | `update`                      | `update(&id, &entity)`              |
45/// | `update_changed`              | `update_changed(&id, &before, &after)` |
46/// | `soft_delete`                 | `soft_delete(id)`                   |
47/// | `restore`                     | `restore(id)`                       |
48/// | `hard_delete`                 | `permanent_delete(id)`              |
49/// | `list`                        | `list_paginated(pagination)`        |
50/// | `list_deleted`                | `list_deleted(pagination)`          |
51/// | `list_deleted_filtered`       | `list_deleted_filtered(pagination, filters)` |
52/// | `count`                       | `count_active()`                    |
53/// | `count_deleted`               | `count_deleted()`                   |
54/// | `bulk_create`                 | `bulk_create(&entities)` (one transaction) |
55/// | `bulk_update_changed`         | `bulk_update_changed(&pairs)` (one transaction) |
56/// | `empty_trash`                 | `empty_trash()`                     |
57///
58/// # Method mapping — `no_soft_delete` variant
59///
60/// | CrudRepository method         | Inherent struct method called       |
61/// |-------------------------------|-------------------------------------|
62/// | `find_by_id_including_deleted`| `find_by_id(id)` (same as normal)   |
63/// | `soft_delete`                 | `delete(id)`                        |
64/// | `restore`                     | `find_by_id(id)` (no-op restore)    |
65/// | `hard_delete`                 | `delete(id)`                        |
66/// | `list_deleted`                | `Ok((vec![], 0))`                   |
67/// | `count`                       | `count()`                           |
68/// | `count_deleted`               | `Ok(0)`                             |
69/// | `empty_trash`                 | `Ok(0)`                             |
70#[macro_export]
71macro_rules! impl_crud_repository {
72    // ── Soft-delete variant ────────────────────────────────────────────────────
73    //
74    // For entities that store deleted_at inside the metadata JSONB column.
75    // The generated struct exposes: soft_delete, restore, permanent_delete,
76    // list_deleted, find_deleted_by_id, count_active, count_deleted, empty_trash.
77    ($repo:ty, $entity:ty, soft_delete) => {
78        #[async_trait::async_trait]
79        impl backbone_core::CrudRepository<$entity> for $repo {
80            async fn fetch_related_json(
81                &self,
82                table: &str,
83                ids: &[String],
84            ) -> Vec<serde_json::Value> {
85                match backbone_orm::fetch_by_ids_as_json(
86                    (&**self).pool(),
87                    (&**self).table_name(),
88                    table,
89                    ids,
90                )
91                .await
92                {
93                    Ok(rows) => rows,
94                    Err(err) => {
95                        backbone_core::log_include_hydration_failure(table, &err.to_string());
96                        Vec::new()
97                    }
98                }
99            }
100
101            async fn reference_visible(
102                &self,
103                table: &str,
104                column: &str,
105                value: &serde_json::Value,
106            ) -> Result<Option<bool>, backbone_core::RepositoryError> {
107                backbone_orm::reference_visible(
108                    (&**self).pool(),
109                    (&**self).table_name(),
110                    table,
111                    column,
112                    value,
113                )
114                .await
115                .map_err(backbone_core::RepositoryError::from_source)
116            }
117
118            // ── NOTE on method resolution ─────────────────────────────────────
119            // This repo is a newtype wrapping `GenericCrudRepository<E, SoftDelete>`
120            // and implements `Deref` to it.  Inside a trait impl, unqualified `self.foo()`
121            // resolves to the *trait* method (recursive) when both the trait and the
122            // inner type have a method with the same name.  We use `(&**self).foo()`
123            // to force resolution through the `Deref` target's inherent methods.
124
125            async fn create(
126                &self,
127                entity: $entity,
128            ) -> Result<$entity, backbone_core::RepositoryError> {
129                (&**self).create(&entity)
130                    .await
131                    .map_err(backbone_core::RepositoryError::from_source)
132            }
133
134            async fn find_by_id(
135                &self,
136                id: &str,
137            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
138                (&**self).find_by_id(id)
139                    .await
140                    .map_err(backbone_core::RepositoryError::from_source)
141            }
142
143            async fn find_by_id_including_deleted(
144                &self,
145                id: &str,
146            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
147                (&**self).find_deleted_by_id(id)
148                    .await
149                    .map_err(backbone_core::RepositoryError::from_source)
150            }
151
152            async fn update(
153                &self,
154                entity: $entity,
155            ) -> Result<$entity, backbone_core::RepositoryError> {
156                let id = backbone_core::PersistentEntity::entity_id(&entity);
157                (&**self).update(&id, &entity)
158                    .await
159                    .map_err(backbone_core::RepositoryError::from_source)
160                    .and_then(|opt| opt.ok_or(backbone_core::RepositoryError::NotFound))
161            }
162
163            async fn update_changed(
164                &self,
165                before: &$entity,
166                after: $entity,
167            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
168                let id = backbone_core::PersistentEntity::entity_id(&after);
169                (&**self)
170                    .update_changed(&id, before, &after)
171                    .await
172                    .map_err(backbone_core::RepositoryError::from_source)
173            }
174
175            async fn soft_delete(
176                &self,
177                id: &str,
178            ) -> Result<bool, backbone_core::RepositoryError> {
179                (&**self).soft_delete(id)
180                    .await
181                    .map_err(backbone_core::RepositoryError::from_source)
182            }
183
184            async fn restore(
185                &self,
186                id: &str,
187            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
188                (&**self).restore(id)
189                    .await
190                    .map_err(backbone_core::RepositoryError::from_source)
191            }
192
193            async fn hard_delete(
194                &self,
195                id: &str,
196            ) -> Result<bool, backbone_core::RepositoryError> {
197                (&**self).permanent_delete(id)
198                    .await
199                    .map_err(backbone_core::RepositoryError::from_source)
200            }
201
202            async fn list(
203                &self,
204                page: u32,
205                limit: u32,
206            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
207                let pagination =
208                    backbone_orm::repository::PaginationParams::new(page, limit);
209                (&**self).list_paginated(pagination)
210                    .await
211                    .map(|r| (r.data, r.pagination.total))
212                    .map_err(backbone_core::RepositoryError::from_source)
213            }
214
215            async fn list_filtered(
216                &self,
217                page: u32,
218                limit: u32,
219                filters: std::collections::HashMap<String, String>,
220            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
221                let pagination =
222                    backbone_orm::repository::PaginationParams::new(page, limit);
223                (&**self).list_paginated_filtered(pagination, Some(&filters))
224                    .await
225                    .map(|r| (r.data, r.pagination.total))
226                    .map_err(backbone_core::RepositoryError::from_source)
227            }
228
229            async fn list_filtered_with_info(
230                &self,
231                page: u32,
232                limit: u32,
233                filters: std::collections::HashMap<String, String>,
234            ) -> Result<
235                (Vec<$entity>, backbone_orm::repository::PaginationInfo),
236                backbone_core::RepositoryError,
237            > {
238                let pagination =
239                    backbone_orm::repository::PaginationParams::new(page, limit);
240                (&**self)
241                    .list_paginated_filtered(pagination, Some(&filters))
242                    .await
243                    .map(|r| (r.data, r.pagination))
244                    .map_err(backbone_core::RepositoryError::from_source)
245            }
246
247            fn table_name(&self) -> Option<&str> {
248                Some((&**self).table_name())
249            }
250
251            fn write_pool(&self) -> Option<&backbone_orm::PgPool> {
252                Some((&**self).pool())
253            }
254
255            async fn aggregate_filtered(
256                &self,
257                spec: &backbone_orm::repository::AggregateSpec,
258                filters: std::collections::HashMap<String, String>,
259            ) -> Result<backbone_orm::repository::AggregateResult, backbone_core::RepositoryError> {
260                (&**self).aggregate_filtered(spec, Some(&filters))
261                    .await
262                    .map_err(backbone_core::RepositoryError::from_source)
263            }
264
265            async fn list_deleted(
266                &self,
267                page: u32,
268                limit: u32,
269            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
270                let pagination =
271                    backbone_orm::repository::PaginationParams::new(page, limit);
272                (&**self).list_deleted(pagination)
273                    .await
274                    .map(|r| (r.data, r.pagination.total))
275                    .map_err(backbone_core::RepositoryError::from_source)
276            }
277
278            async fn list_deleted_filtered(
279                &self,
280                page: u32,
281                limit: u32,
282                filters: std::collections::HashMap<String, String>,
283            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
284                let pagination =
285                    backbone_orm::repository::PaginationParams::new(page, limit);
286                (&**self).list_deleted_filtered(pagination, Some(&filters))
287                    .await
288                    .map(|r| (r.data, r.pagination.total))
289                    .map_err(backbone_core::RepositoryError::from_source)
290            }
291
292            async fn count(&self) -> Result<u64, backbone_core::RepositoryError> {
293                (&**self).count_active()
294                    .await
295                    .map_err(backbone_core::RepositoryError::from_source)
296            }
297
298            async fn count_deleted(&self) -> Result<u64, backbone_core::RepositoryError> {
299                (&**self).count_deleted()
300                    .await
301                    .map_err(backbone_core::RepositoryError::from_source)
302            }
303
304            async fn bulk_create(
305                &self,
306                entities: Vec<$entity>,
307            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
308                // One transaction: every row or none, the failed row named by its index.
309                (&**self).bulk_create(&entities)
310                    .await
311                    .map_err(backbone_core::RepositoryError::from_batch_source)
312            }
313
314            async fn empty_trash(&self) -> Result<u64, backbone_core::RepositoryError> {
315                (&**self).empty_trash()
316                    .await
317                    .map_err(backbone_core::RepositoryError::from_source)
318            }
319
320            // ── Atomic batch operations (single-transaction, all-or-nothing) ──
321
322            async fn bulk_soft_delete(
323                &self,
324                ids: &[String],
325            ) -> Result<u64, backbone_core::RepositoryError> {
326                (&**self).bulk_soft_delete(ids)
327                    .await
328                    .map_err(backbone_core::RepositoryError::from_source)
329            }
330
331            async fn bulk_restore(
332                &self,
333                ids: &[String],
334            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
335                (&**self).bulk_restore(ids)
336                    .await
337                    .map_err(backbone_core::RepositoryError::from_source)
338            }
339
340            async fn bulk_hard_delete(
341                &self,
342                ids: &[String],
343            ) -> Result<u64, backbone_core::RepositoryError> {
344                (&**self).bulk_permanent_delete(ids)
345                    .await
346                    .map_err(backbone_core::RepositoryError::from_source)
347            }
348
349            async fn restore_all(&self) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
350                (&**self).restore_all()
351                    .await
352                    .map_err(backbone_core::RepositoryError::from_source)
353            }
354
355            async fn bulk_update(
356                &self,
357                entities: Vec<$entity>,
358            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
359                (&**self).bulk_update(&entities)
360                    .await
361                    .map_err(backbone_core::RepositoryError::from_source)
362            }
363
364            async fn bulk_update_changed(
365                &self,
366                pairs: Vec<($entity, $entity)>,
367            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
368                (&**self).bulk_update_changed(&pairs)
369                    .await
370                    .map_err(backbone_core::RepositoryError::from_batch_source)
371            }
372        }
373    };
374
375    // ── No-soft-delete variant ─────────────────────────────────────────────────
376    //
377    // For entities that use hard deletes only (no soft_delete, restore, trash).
378    // Soft-delete-related trait methods return sensible no-op results.
379    ($repo:ty, $entity:ty, no_soft_delete) => {
380        #[async_trait::async_trait]
381        impl backbone_core::CrudRepository<$entity> for $repo {
382            async fn fetch_related_json(
383                &self,
384                table: &str,
385                ids: &[String],
386            ) -> Vec<serde_json::Value> {
387                match backbone_orm::fetch_by_ids_as_json(
388                    (&**self).pool(),
389                    (&**self).table_name(),
390                    table,
391                    ids,
392                )
393                .await
394                {
395                    Ok(rows) => rows,
396                    Err(err) => {
397                        backbone_core::log_include_hydration_failure(table, &err.to_string());
398                        Vec::new()
399                    }
400                }
401            }
402
403            async fn reference_visible(
404                &self,
405                table: &str,
406                column: &str,
407                value: &serde_json::Value,
408            ) -> Result<Option<bool>, backbone_core::RepositoryError> {
409                backbone_orm::reference_visible(
410                    (&**self).pool(),
411                    (&**self).table_name(),
412                    table,
413                    column,
414                    value,
415                )
416                .await
417                .map_err(backbone_core::RepositoryError::from_source)
418            }
419
420            async fn create(
421                &self,
422                entity: $entity,
423            ) -> Result<$entity, backbone_core::RepositoryError> {
424                (&**self).create(&entity)
425                    .await
426                    .map_err(backbone_core::RepositoryError::from_source)
427            }
428
429            async fn find_by_id(
430                &self,
431                id: &str,
432            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
433                (&**self).find_by_id(id)
434                    .await
435                    .map_err(backbone_core::RepositoryError::from_source)
436            }
437
438            async fn find_by_id_including_deleted(
439                &self,
440                id: &str,
441            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
442                // No soft delete: "including deleted" == normal lookup
443                (&**self).find_by_id(id)
444                    .await
445                    .map_err(backbone_core::RepositoryError::from_source)
446            }
447
448            async fn update(
449                &self,
450                entity: $entity,
451            ) -> Result<$entity, backbone_core::RepositoryError> {
452                let id = backbone_core::PersistentEntity::entity_id(&entity);
453                (&**self).update(&id, &entity)
454                    .await
455                    .map_err(backbone_core::RepositoryError::from_source)
456                    .and_then(|opt| opt.ok_or(backbone_core::RepositoryError::NotFound))
457            }
458
459            async fn update_changed(
460                &self,
461                before: &$entity,
462                after: $entity,
463            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
464                let id = backbone_core::PersistentEntity::entity_id(&after);
465                (&**self)
466                    .update_changed(&id, before, &after)
467                    .await
468                    .map_err(backbone_core::RepositoryError::from_source)
469            }
470
471            async fn soft_delete(
472                &self,
473                id: &str,
474            ) -> Result<bool, backbone_core::RepositoryError> {
475                // No soft delete: fall back to hard delete
476                (&**self).delete(id)
477                    .await
478                    .map_err(backbone_core::RepositoryError::from_source)
479            }
480
481            async fn restore(
482                &self,
483                id: &str,
484            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
485                // No soft delete: "restore" is a no-op, return the current row
486                (&**self).find_by_id(id)
487                    .await
488                    .map_err(backbone_core::RepositoryError::from_source)
489            }
490
491            async fn hard_delete(
492                &self,
493                id: &str,
494            ) -> Result<bool, backbone_core::RepositoryError> {
495                (&**self).delete(id)
496                    .await
497                    .map_err(backbone_core::RepositoryError::from_source)
498            }
499
500            async fn list(
501                &self,
502                page: u32,
503                limit: u32,
504            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
505                let pagination =
506                    backbone_orm::repository::PaginationParams::new(page, limit);
507                (&**self).list_paginated(pagination)
508                    .await
509                    .map(|r| (r.data, r.pagination.total))
510                    .map_err(backbone_core::RepositoryError::from_source)
511            }
512
513            async fn list_filtered(
514                &self,
515                page: u32,
516                limit: u32,
517                filters: std::collections::HashMap<String, String>,
518            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
519                let pagination =
520                    backbone_orm::repository::PaginationParams::new(page, limit);
521                (&**self).list_paginated_filtered(pagination, Some(&filters))
522                    .await
523                    .map(|r| (r.data, r.pagination.total))
524                    .map_err(backbone_core::RepositoryError::from_source)
525            }
526
527            async fn list_filtered_with_info(
528                &self,
529                page: u32,
530                limit: u32,
531                filters: std::collections::HashMap<String, String>,
532            ) -> Result<
533                (Vec<$entity>, backbone_orm::repository::PaginationInfo),
534                backbone_core::RepositoryError,
535            > {
536                let pagination =
537                    backbone_orm::repository::PaginationParams::new(page, limit);
538                (&**self)
539                    .list_paginated_filtered(pagination, Some(&filters))
540                    .await
541                    .map(|r| (r.data, r.pagination))
542                    .map_err(backbone_core::RepositoryError::from_source)
543            }
544
545            fn table_name(&self) -> Option<&str> {
546                Some((&**self).table_name())
547            }
548
549            fn write_pool(&self) -> Option<&backbone_orm::PgPool> {
550                Some((&**self).pool())
551            }
552
553            async fn aggregate_filtered(
554                &self,
555                spec: &backbone_orm::repository::AggregateSpec,
556                filters: std::collections::HashMap<String, String>,
557            ) -> Result<backbone_orm::repository::AggregateResult, backbone_core::RepositoryError> {
558                (&**self).aggregate_filtered(spec, Some(&filters))
559                    .await
560                    .map_err(backbone_core::RepositoryError::from_source)
561            }
562
563            async fn list_deleted(
564                &self,
565                _page: u32,
566                _limit: u32,
567            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
568                // No soft delete: trash is always empty
569                Ok((vec![], 0))
570            }
571
572            async fn list_deleted_filtered(
573                &self,
574                _page: u32,
575                _limit: u32,
576                _filters: std::collections::HashMap<String, String>,
577            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
578                // No soft delete: trash is always empty
579                Ok((vec![], 0))
580            }
581
582            async fn count(&self) -> Result<u64, backbone_core::RepositoryError> {
583                (&**self).count()
584                    .await
585                    .map_err(backbone_core::RepositoryError::from_source)
586            }
587
588            async fn count_deleted(&self) -> Result<u64, backbone_core::RepositoryError> {
589                Ok(0)
590            }
591
592            async fn bulk_create(
593                &self,
594                entities: Vec<$entity>,
595            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
596                // One transaction: every row or none, the failed row named by its index.
597                (&**self).bulk_create(&entities)
598                    .await
599                    .map_err(backbone_core::RepositoryError::from_batch_source)
600            }
601
602            async fn empty_trash(&self) -> Result<u64, backbone_core::RepositoryError> {
603                Ok(0)
604            }
605
606            // ── Atomic batch operations ───────────────────────────────────────
607
608            async fn bulk_soft_delete(
609                &self,
610                ids: &[String],
611            ) -> Result<u64, backbone_core::RepositoryError> {
612                // No soft delete: fall back to hard delete
613                (&**self).bulk_delete(ids)
614                    .await
615                    .map_err(backbone_core::RepositoryError::from_source)
616            }
617
618            async fn bulk_restore(
619                &self,
620                _ids: &[String],
621            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
622                // No soft delete: nothing is ever in trash to restore
623                Ok(Vec::new())
624            }
625
626            async fn bulk_hard_delete(
627                &self,
628                ids: &[String],
629            ) -> Result<u64, backbone_core::RepositoryError> {
630                (&**self).bulk_delete(ids)
631                    .await
632                    .map_err(backbone_core::RepositoryError::from_source)
633            }
634
635            async fn restore_all(&self) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
636                // No soft delete: trash is always empty
637                Ok(Vec::new())
638            }
639
640            async fn bulk_update(
641                &self,
642                entities: Vec<$entity>,
643            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
644                (&**self).bulk_update(&entities)
645                    .await
646                    .map_err(backbone_core::RepositoryError::from_source)
647            }
648
649            async fn bulk_update_changed(
650                &self,
651                pairs: Vec<($entity, $entity)>,
652            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
653                (&**self).bulk_update_changed(&pairs)
654                    .await
655                    .map_err(backbone_core::RepositoryError::from_batch_source)
656            }
657        }
658    };
659}