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            async fn aggregate_filtered(
252                &self,
253                spec: &backbone_orm::repository::AggregateSpec,
254                filters: std::collections::HashMap<String, String>,
255            ) -> Result<backbone_orm::repository::AggregateResult, backbone_core::RepositoryError> {
256                (&**self).aggregate_filtered(spec, Some(&filters))
257                    .await
258                    .map_err(backbone_core::RepositoryError::from_source)
259            }
260
261            async fn list_deleted(
262                &self,
263                page: u32,
264                limit: u32,
265            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
266                let pagination =
267                    backbone_orm::repository::PaginationParams::new(page, limit);
268                (&**self).list_deleted(pagination)
269                    .await
270                    .map(|r| (r.data, r.pagination.total))
271                    .map_err(backbone_core::RepositoryError::from_source)
272            }
273
274            async fn list_deleted_filtered(
275                &self,
276                page: u32,
277                limit: u32,
278                filters: std::collections::HashMap<String, String>,
279            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
280                let pagination =
281                    backbone_orm::repository::PaginationParams::new(page, limit);
282                (&**self).list_deleted_filtered(pagination, Some(&filters))
283                    .await
284                    .map(|r| (r.data, r.pagination.total))
285                    .map_err(backbone_core::RepositoryError::from_source)
286            }
287
288            async fn count(&self) -> Result<u64, backbone_core::RepositoryError> {
289                (&**self).count_active()
290                    .await
291                    .map_err(backbone_core::RepositoryError::from_source)
292            }
293
294            async fn count_deleted(&self) -> Result<u64, backbone_core::RepositoryError> {
295                (&**self).count_deleted()
296                    .await
297                    .map_err(backbone_core::RepositoryError::from_source)
298            }
299
300            async fn bulk_create(
301                &self,
302                entities: Vec<$entity>,
303            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
304                // One transaction: every row or none, the failed row named by its index.
305                (&**self).bulk_create(&entities)
306                    .await
307                    .map_err(backbone_core::RepositoryError::from_batch_source)
308            }
309
310            async fn empty_trash(&self) -> Result<u64, backbone_core::RepositoryError> {
311                (&**self).empty_trash()
312                    .await
313                    .map_err(backbone_core::RepositoryError::from_source)
314            }
315
316            // ── Atomic batch operations (single-transaction, all-or-nothing) ──
317
318            async fn bulk_soft_delete(
319                &self,
320                ids: &[String],
321            ) -> Result<u64, backbone_core::RepositoryError> {
322                (&**self).bulk_soft_delete(ids)
323                    .await
324                    .map_err(backbone_core::RepositoryError::from_source)
325            }
326
327            async fn bulk_restore(
328                &self,
329                ids: &[String],
330            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
331                (&**self).bulk_restore(ids)
332                    .await
333                    .map_err(backbone_core::RepositoryError::from_source)
334            }
335
336            async fn bulk_hard_delete(
337                &self,
338                ids: &[String],
339            ) -> Result<u64, backbone_core::RepositoryError> {
340                (&**self).bulk_permanent_delete(ids)
341                    .await
342                    .map_err(backbone_core::RepositoryError::from_source)
343            }
344
345            async fn restore_all(&self) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
346                (&**self).restore_all()
347                    .await
348                    .map_err(backbone_core::RepositoryError::from_source)
349            }
350
351            async fn bulk_update(
352                &self,
353                entities: Vec<$entity>,
354            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
355                (&**self).bulk_update(&entities)
356                    .await
357                    .map_err(backbone_core::RepositoryError::from_source)
358            }
359
360            async fn bulk_update_changed(
361                &self,
362                pairs: Vec<($entity, $entity)>,
363            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
364                (&**self).bulk_update_changed(&pairs)
365                    .await
366                    .map_err(backbone_core::RepositoryError::from_batch_source)
367            }
368        }
369    };
370
371    // ── No-soft-delete variant ─────────────────────────────────────────────────
372    //
373    // For entities that use hard deletes only (no soft_delete, restore, trash).
374    // Soft-delete-related trait methods return sensible no-op results.
375    ($repo:ty, $entity:ty, no_soft_delete) => {
376        #[async_trait::async_trait]
377        impl backbone_core::CrudRepository<$entity> for $repo {
378            async fn fetch_related_json(
379                &self,
380                table: &str,
381                ids: &[String],
382            ) -> Vec<serde_json::Value> {
383                match backbone_orm::fetch_by_ids_as_json(
384                    (&**self).pool(),
385                    (&**self).table_name(),
386                    table,
387                    ids,
388                )
389                .await
390                {
391                    Ok(rows) => rows,
392                    Err(err) => {
393                        backbone_core::log_include_hydration_failure(table, &err.to_string());
394                        Vec::new()
395                    }
396                }
397            }
398
399            async fn reference_visible(
400                &self,
401                table: &str,
402                column: &str,
403                value: &serde_json::Value,
404            ) -> Result<Option<bool>, backbone_core::RepositoryError> {
405                backbone_orm::reference_visible(
406                    (&**self).pool(),
407                    (&**self).table_name(),
408                    table,
409                    column,
410                    value,
411                )
412                .await
413                .map_err(backbone_core::RepositoryError::from_source)
414            }
415
416            async fn create(
417                &self,
418                entity: $entity,
419            ) -> Result<$entity, backbone_core::RepositoryError> {
420                (&**self).create(&entity)
421                    .await
422                    .map_err(backbone_core::RepositoryError::from_source)
423            }
424
425            async fn find_by_id(
426                &self,
427                id: &str,
428            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
429                (&**self).find_by_id(id)
430                    .await
431                    .map_err(backbone_core::RepositoryError::from_source)
432            }
433
434            async fn find_by_id_including_deleted(
435                &self,
436                id: &str,
437            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
438                // No soft delete: "including deleted" == normal lookup
439                (&**self).find_by_id(id)
440                    .await
441                    .map_err(backbone_core::RepositoryError::from_source)
442            }
443
444            async fn update(
445                &self,
446                entity: $entity,
447            ) -> Result<$entity, backbone_core::RepositoryError> {
448                let id = backbone_core::PersistentEntity::entity_id(&entity);
449                (&**self).update(&id, &entity)
450                    .await
451                    .map_err(backbone_core::RepositoryError::from_source)
452                    .and_then(|opt| opt.ok_or(backbone_core::RepositoryError::NotFound))
453            }
454
455            async fn update_changed(
456                &self,
457                before: &$entity,
458                after: $entity,
459            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
460                let id = backbone_core::PersistentEntity::entity_id(&after);
461                (&**self)
462                    .update_changed(&id, before, &after)
463                    .await
464                    .map_err(backbone_core::RepositoryError::from_source)
465            }
466
467            async fn soft_delete(
468                &self,
469                id: &str,
470            ) -> Result<bool, backbone_core::RepositoryError> {
471                // No soft delete: fall back to hard delete
472                (&**self).delete(id)
473                    .await
474                    .map_err(backbone_core::RepositoryError::from_source)
475            }
476
477            async fn restore(
478                &self,
479                id: &str,
480            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
481                // No soft delete: "restore" is a no-op, return the current row
482                (&**self).find_by_id(id)
483                    .await
484                    .map_err(backbone_core::RepositoryError::from_source)
485            }
486
487            async fn hard_delete(
488                &self,
489                id: &str,
490            ) -> Result<bool, backbone_core::RepositoryError> {
491                (&**self).delete(id)
492                    .await
493                    .map_err(backbone_core::RepositoryError::from_source)
494            }
495
496            async fn list(
497                &self,
498                page: u32,
499                limit: u32,
500            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
501                let pagination =
502                    backbone_orm::repository::PaginationParams::new(page, limit);
503                (&**self).list_paginated(pagination)
504                    .await
505                    .map(|r| (r.data, r.pagination.total))
506                    .map_err(backbone_core::RepositoryError::from_source)
507            }
508
509            async fn list_filtered(
510                &self,
511                page: u32,
512                limit: u32,
513                filters: std::collections::HashMap<String, String>,
514            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
515                let pagination =
516                    backbone_orm::repository::PaginationParams::new(page, limit);
517                (&**self).list_paginated_filtered(pagination, Some(&filters))
518                    .await
519                    .map(|r| (r.data, r.pagination.total))
520                    .map_err(backbone_core::RepositoryError::from_source)
521            }
522
523            async fn list_filtered_with_info(
524                &self,
525                page: u32,
526                limit: u32,
527                filters: std::collections::HashMap<String, String>,
528            ) -> Result<
529                (Vec<$entity>, backbone_orm::repository::PaginationInfo),
530                backbone_core::RepositoryError,
531            > {
532                let pagination =
533                    backbone_orm::repository::PaginationParams::new(page, limit);
534                (&**self)
535                    .list_paginated_filtered(pagination, Some(&filters))
536                    .await
537                    .map(|r| (r.data, r.pagination))
538                    .map_err(backbone_core::RepositoryError::from_source)
539            }
540
541            fn table_name(&self) -> Option<&str> {
542                Some((&**self).table_name())
543            }
544
545            async fn aggregate_filtered(
546                &self,
547                spec: &backbone_orm::repository::AggregateSpec,
548                filters: std::collections::HashMap<String, String>,
549            ) -> Result<backbone_orm::repository::AggregateResult, backbone_core::RepositoryError> {
550                (&**self).aggregate_filtered(spec, Some(&filters))
551                    .await
552                    .map_err(backbone_core::RepositoryError::from_source)
553            }
554
555            async fn list_deleted(
556                &self,
557                _page: u32,
558                _limit: u32,
559            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
560                // No soft delete: trash is always empty
561                Ok((vec![], 0))
562            }
563
564            async fn list_deleted_filtered(
565                &self,
566                _page: u32,
567                _limit: u32,
568                _filters: std::collections::HashMap<String, String>,
569            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
570                // No soft delete: trash is always empty
571                Ok((vec![], 0))
572            }
573
574            async fn count(&self) -> Result<u64, backbone_core::RepositoryError> {
575                (&**self).count()
576                    .await
577                    .map_err(backbone_core::RepositoryError::from_source)
578            }
579
580            async fn count_deleted(&self) -> Result<u64, backbone_core::RepositoryError> {
581                Ok(0)
582            }
583
584            async fn bulk_create(
585                &self,
586                entities: Vec<$entity>,
587            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
588                // One transaction: every row or none, the failed row named by its index.
589                (&**self).bulk_create(&entities)
590                    .await
591                    .map_err(backbone_core::RepositoryError::from_batch_source)
592            }
593
594            async fn empty_trash(&self) -> Result<u64, backbone_core::RepositoryError> {
595                Ok(0)
596            }
597
598            // ── Atomic batch operations ───────────────────────────────────────
599
600            async fn bulk_soft_delete(
601                &self,
602                ids: &[String],
603            ) -> Result<u64, backbone_core::RepositoryError> {
604                // No soft delete: fall back to hard delete
605                (&**self).bulk_delete(ids)
606                    .await
607                    .map_err(backbone_core::RepositoryError::from_source)
608            }
609
610            async fn bulk_restore(
611                &self,
612                _ids: &[String],
613            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
614                // No soft delete: nothing is ever in trash to restore
615                Ok(Vec::new())
616            }
617
618            async fn bulk_hard_delete(
619                &self,
620                ids: &[String],
621            ) -> Result<u64, backbone_core::RepositoryError> {
622                (&**self).bulk_delete(ids)
623                    .await
624                    .map_err(backbone_core::RepositoryError::from_source)
625            }
626
627            async fn restore_all(&self) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
628                // No soft delete: trash is always empty
629                Ok(Vec::new())
630            }
631
632            async fn bulk_update(
633                &self,
634                entities: Vec<$entity>,
635            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
636                (&**self).bulk_update(&entities)
637                    .await
638                    .map_err(backbone_core::RepositoryError::from_source)
639            }
640
641            async fn bulk_update_changed(
642                &self,
643                pairs: Vec<($entity, $entity)>,
644            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
645                (&**self).bulk_update_changed(&pairs)
646                    .await
647                    .map_err(backbone_core::RepositoryError::from_batch_source)
648            }
649        }
650    };
651}