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/// | `count`                       | `count_active()`                    |
52/// | `count_deleted`               | `count_deleted()`                   |
53/// | `bulk_create`                 | `create(&entity)` (loop)            |
54/// | `empty_trash`                 | `empty_trash()`                     |
55///
56/// # Method mapping — `no_soft_delete` variant
57///
58/// | CrudRepository method         | Inherent struct method called       |
59/// |-------------------------------|-------------------------------------|
60/// | `find_by_id_including_deleted`| `find_by_id(id)` (same as normal)   |
61/// | `soft_delete`                 | `delete(id)`                        |
62/// | `restore`                     | `find_by_id(id)` (no-op restore)    |
63/// | `hard_delete`                 | `delete(id)`                        |
64/// | `list_deleted`                | `Ok((vec![], 0))`                   |
65/// | `count`                       | `count()`                           |
66/// | `count_deleted`               | `Ok(0)`                             |
67/// | `empty_trash`                 | `Ok(0)`                             |
68#[macro_export]
69macro_rules! impl_crud_repository {
70    // ── Soft-delete variant ────────────────────────────────────────────────────
71    //
72    // For entities that store deleted_at inside the metadata JSONB column.
73    // The generated struct exposes: soft_delete, restore, permanent_delete,
74    // list_deleted, find_deleted_by_id, count_active, count_deleted, empty_trash.
75    ($repo:ty, $entity:ty, soft_delete) => {
76        #[async_trait::async_trait]
77        impl backbone_core::CrudRepository<$entity> for $repo {
78            async fn fetch_related_json(
79                &self,
80                table: &str,
81                ids: &[String],
82            ) -> Vec<serde_json::Value> {
83                match backbone_orm::fetch_by_ids_as_json(
84                    (&**self).pool(),
85                    (&**self).table_name(),
86                    table,
87                    ids,
88                )
89                .await
90                {
91                    Ok(rows) => rows,
92                    Err(err) => {
93                        backbone_core::log_include_hydration_failure(table, &err.to_string());
94                        Vec::new()
95                    }
96                }
97            }
98
99            // ── NOTE on method resolution ─────────────────────────────────────
100            // This repo is a newtype wrapping `GenericCrudRepository<E, SoftDelete>`
101            // and implements `Deref` to it.  Inside a trait impl, unqualified `self.foo()`
102            // resolves to the *trait* method (recursive) when both the trait and the
103            // inner type have a method with the same name.  We use `(&**self).foo()`
104            // to force resolution through the `Deref` target's inherent methods.
105
106            async fn create(
107                &self,
108                entity: $entity,
109            ) -> Result<$entity, backbone_core::RepositoryError> {
110                (&**self).create(&entity)
111                    .await
112                    .map_err(backbone_core::RepositoryError::from_source)
113            }
114
115            async fn find_by_id(
116                &self,
117                id: &str,
118            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
119                (&**self).find_by_id(id)
120                    .await
121                    .map_err(backbone_core::RepositoryError::from_source)
122            }
123
124            async fn find_by_id_including_deleted(
125                &self,
126                id: &str,
127            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
128                (&**self).find_deleted_by_id(id)
129                    .await
130                    .map_err(backbone_core::RepositoryError::from_source)
131            }
132
133            async fn update(
134                &self,
135                entity: $entity,
136            ) -> Result<$entity, backbone_core::RepositoryError> {
137                let id = backbone_core::PersistentEntity::entity_id(&entity);
138                (&**self).update(&id, &entity)
139                    .await
140                    .map_err(backbone_core::RepositoryError::from_source)
141                    .and_then(|opt| opt.ok_or(backbone_core::RepositoryError::NotFound))
142            }
143
144            async fn update_changed(
145                &self,
146                before: &$entity,
147                after: $entity,
148            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
149                let id = backbone_core::PersistentEntity::entity_id(&after);
150                (&**self)
151                    .update_changed(&id, before, &after)
152                    .await
153                    .map_err(backbone_core::RepositoryError::from_source)
154            }
155
156            async fn soft_delete(
157                &self,
158                id: &str,
159            ) -> Result<bool, backbone_core::RepositoryError> {
160                (&**self).soft_delete(id)
161                    .await
162                    .map_err(backbone_core::RepositoryError::from_source)
163            }
164
165            async fn restore(
166                &self,
167                id: &str,
168            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
169                (&**self).restore(id)
170                    .await
171                    .map_err(backbone_core::RepositoryError::from_source)
172            }
173
174            async fn hard_delete(
175                &self,
176                id: &str,
177            ) -> Result<bool, backbone_core::RepositoryError> {
178                (&**self).permanent_delete(id)
179                    .await
180                    .map_err(backbone_core::RepositoryError::from_source)
181            }
182
183            async fn list(
184                &self,
185                page: u32,
186                limit: u32,
187            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
188                let pagination =
189                    backbone_orm::repository::PaginationParams { page, per_page: limit };
190                (&**self).list_paginated(pagination)
191                    .await
192                    .map(|r| (r.data, r.pagination.total))
193                    .map_err(backbone_core::RepositoryError::from_source)
194            }
195
196            async fn list_filtered(
197                &self,
198                page: u32,
199                limit: u32,
200                filters: std::collections::HashMap<String, String>,
201            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
202                let pagination =
203                    backbone_orm::repository::PaginationParams { page, per_page: limit };
204                (&**self).list_paginated_filtered(pagination, Some(&filters))
205                    .await
206                    .map(|r| (r.data, r.pagination.total))
207                    .map_err(backbone_core::RepositoryError::from_source)
208            }
209
210            async fn list_filtered_with_info(
211                &self,
212                page: u32,
213                limit: u32,
214                filters: std::collections::HashMap<String, String>,
215            ) -> Result<
216                (Vec<$entity>, backbone_orm::repository::PaginationInfo),
217                backbone_core::RepositoryError,
218            > {
219                let pagination =
220                    backbone_orm::repository::PaginationParams { page, per_page: limit };
221                (&**self)
222                    .list_paginated_filtered(pagination, Some(&filters))
223                    .await
224                    .map(|r| (r.data, r.pagination))
225                    .map_err(backbone_core::RepositoryError::from_source)
226            }
227
228            fn table_name(&self) -> Option<&str> {
229                Some((&**self).table_name())
230            }
231
232            async fn aggregate_filtered(
233                &self,
234                spec: &backbone_orm::repository::AggregateSpec,
235                filters: std::collections::HashMap<String, String>,
236            ) -> Result<backbone_orm::repository::AggregateResult, backbone_core::RepositoryError> {
237                (&**self).aggregate_filtered(spec, Some(&filters))
238                    .await
239                    .map_err(backbone_core::RepositoryError::from_source)
240            }
241
242            async fn list_deleted(
243                &self,
244                page: u32,
245                limit: u32,
246            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
247                let pagination =
248                    backbone_orm::repository::PaginationParams { page, per_page: limit };
249                (&**self).list_deleted(pagination)
250                    .await
251                    .map(|r| (r.data, r.pagination.total))
252                    .map_err(backbone_core::RepositoryError::from_source)
253            }
254
255            async fn count(&self) -> Result<u64, backbone_core::RepositoryError> {
256                (&**self).count_active()
257                    .await
258                    .map_err(backbone_core::RepositoryError::from_source)
259            }
260
261            async fn count_deleted(&self) -> Result<u64, backbone_core::RepositoryError> {
262                (&**self).count_deleted()
263                    .await
264                    .map_err(backbone_core::RepositoryError::from_source)
265            }
266
267            async fn bulk_create(
268                &self,
269                entities: Vec<$entity>,
270            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
271                let mut results = Vec::with_capacity(entities.len());
272                for entity in entities {
273                    let created = (&**self)
274                        .create(&entity)
275                        .await
276                        .map_err(backbone_core::RepositoryError::from_source)?;
277                    results.push(created);
278                }
279                Ok(results)
280            }
281
282            async fn empty_trash(&self) -> Result<u64, backbone_core::RepositoryError> {
283                (&**self).empty_trash()
284                    .await
285                    .map_err(backbone_core::RepositoryError::from_source)
286            }
287
288            // ── Atomic batch operations (single-transaction, all-or-nothing) ──
289
290            async fn bulk_soft_delete(
291                &self,
292                ids: &[String],
293            ) -> Result<u64, backbone_core::RepositoryError> {
294                (&**self).bulk_soft_delete(ids)
295                    .await
296                    .map_err(backbone_core::RepositoryError::from_source)
297            }
298
299            async fn bulk_restore(
300                &self,
301                ids: &[String],
302            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
303                (&**self).bulk_restore(ids)
304                    .await
305                    .map_err(backbone_core::RepositoryError::from_source)
306            }
307
308            async fn bulk_hard_delete(
309                &self,
310                ids: &[String],
311            ) -> Result<u64, backbone_core::RepositoryError> {
312                (&**self).bulk_permanent_delete(ids)
313                    .await
314                    .map_err(backbone_core::RepositoryError::from_source)
315            }
316
317            async fn restore_all(&self) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
318                (&**self).restore_all()
319                    .await
320                    .map_err(backbone_core::RepositoryError::from_source)
321            }
322
323            async fn bulk_update(
324                &self,
325                entities: Vec<$entity>,
326            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
327                (&**self).bulk_update(&entities)
328                    .await
329                    .map_err(backbone_core::RepositoryError::from_source)
330            }
331        }
332    };
333
334    // ── No-soft-delete variant ─────────────────────────────────────────────────
335    //
336    // For entities that use hard deletes only (no soft_delete, restore, trash).
337    // Soft-delete-related trait methods return sensible no-op results.
338    ($repo:ty, $entity:ty, no_soft_delete) => {
339        #[async_trait::async_trait]
340        impl backbone_core::CrudRepository<$entity> for $repo {
341            async fn fetch_related_json(
342                &self,
343                table: &str,
344                ids: &[String],
345            ) -> Vec<serde_json::Value> {
346                match backbone_orm::fetch_by_ids_as_json(
347                    (&**self).pool(),
348                    (&**self).table_name(),
349                    table,
350                    ids,
351                )
352                .await
353                {
354                    Ok(rows) => rows,
355                    Err(err) => {
356                        backbone_core::log_include_hydration_failure(table, &err.to_string());
357                        Vec::new()
358                    }
359                }
360            }
361
362            async fn create(
363                &self,
364                entity: $entity,
365            ) -> Result<$entity, backbone_core::RepositoryError> {
366                (&**self).create(&entity)
367                    .await
368                    .map_err(backbone_core::RepositoryError::from_source)
369            }
370
371            async fn find_by_id(
372                &self,
373                id: &str,
374            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
375                (&**self).find_by_id(id)
376                    .await
377                    .map_err(backbone_core::RepositoryError::from_source)
378            }
379
380            async fn find_by_id_including_deleted(
381                &self,
382                id: &str,
383            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
384                // No soft delete: "including deleted" == normal lookup
385                (&**self).find_by_id(id)
386                    .await
387                    .map_err(backbone_core::RepositoryError::from_source)
388            }
389
390            async fn update(
391                &self,
392                entity: $entity,
393            ) -> Result<$entity, backbone_core::RepositoryError> {
394                let id = backbone_core::PersistentEntity::entity_id(&entity);
395                (&**self).update(&id, &entity)
396                    .await
397                    .map_err(backbone_core::RepositoryError::from_source)
398                    .and_then(|opt| opt.ok_or(backbone_core::RepositoryError::NotFound))
399            }
400
401            async fn update_changed(
402                &self,
403                before: &$entity,
404                after: $entity,
405            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
406                let id = backbone_core::PersistentEntity::entity_id(&after);
407                (&**self)
408                    .update_changed(&id, before, &after)
409                    .await
410                    .map_err(backbone_core::RepositoryError::from_source)
411            }
412
413            async fn soft_delete(
414                &self,
415                id: &str,
416            ) -> Result<bool, backbone_core::RepositoryError> {
417                // No soft delete: fall back to hard delete
418                (&**self).delete(id)
419                    .await
420                    .map_err(backbone_core::RepositoryError::from_source)
421            }
422
423            async fn restore(
424                &self,
425                id: &str,
426            ) -> Result<Option<$entity>, backbone_core::RepositoryError> {
427                // No soft delete: "restore" is a no-op, return the current row
428                (&**self).find_by_id(id)
429                    .await
430                    .map_err(backbone_core::RepositoryError::from_source)
431            }
432
433            async fn hard_delete(
434                &self,
435                id: &str,
436            ) -> Result<bool, backbone_core::RepositoryError> {
437                (&**self).delete(id)
438                    .await
439                    .map_err(backbone_core::RepositoryError::from_source)
440            }
441
442            async fn list(
443                &self,
444                page: u32,
445                limit: u32,
446            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
447                let pagination =
448                    backbone_orm::repository::PaginationParams { page, per_page: limit };
449                (&**self).list_paginated(pagination)
450                    .await
451                    .map(|r| (r.data, r.pagination.total))
452                    .map_err(backbone_core::RepositoryError::from_source)
453            }
454
455            async fn list_filtered(
456                &self,
457                page: u32,
458                limit: u32,
459                filters: std::collections::HashMap<String, String>,
460            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
461                let pagination =
462                    backbone_orm::repository::PaginationParams { page, per_page: limit };
463                (&**self).list_paginated_filtered(pagination, Some(&filters))
464                    .await
465                    .map(|r| (r.data, r.pagination.total))
466                    .map_err(backbone_core::RepositoryError::from_source)
467            }
468
469            async fn list_filtered_with_info(
470                &self,
471                page: u32,
472                limit: u32,
473                filters: std::collections::HashMap<String, String>,
474            ) -> Result<
475                (Vec<$entity>, backbone_orm::repository::PaginationInfo),
476                backbone_core::RepositoryError,
477            > {
478                let pagination =
479                    backbone_orm::repository::PaginationParams { page, per_page: limit };
480                (&**self)
481                    .list_paginated_filtered(pagination, Some(&filters))
482                    .await
483                    .map(|r| (r.data, r.pagination))
484                    .map_err(backbone_core::RepositoryError::from_source)
485            }
486
487            fn table_name(&self) -> Option<&str> {
488                Some((&**self).table_name())
489            }
490
491            async fn aggregate_filtered(
492                &self,
493                spec: &backbone_orm::repository::AggregateSpec,
494                filters: std::collections::HashMap<String, String>,
495            ) -> Result<backbone_orm::repository::AggregateResult, backbone_core::RepositoryError> {
496                (&**self).aggregate_filtered(spec, Some(&filters))
497                    .await
498                    .map_err(backbone_core::RepositoryError::from_source)
499            }
500
501            async fn list_deleted(
502                &self,
503                _page: u32,
504                _limit: u32,
505            ) -> Result<(Vec<$entity>, u64), backbone_core::RepositoryError> {
506                // No soft delete: trash is always empty
507                Ok((vec![], 0))
508            }
509
510            async fn count(&self) -> Result<u64, backbone_core::RepositoryError> {
511                (&**self).count()
512                    .await
513                    .map_err(backbone_core::RepositoryError::from_source)
514            }
515
516            async fn count_deleted(&self) -> Result<u64, backbone_core::RepositoryError> {
517                Ok(0)
518            }
519
520            async fn bulk_create(
521                &self,
522                entities: Vec<$entity>,
523            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
524                let mut results = Vec::with_capacity(entities.len());
525                for entity in entities {
526                    let created = (&**self)
527                        .create(&entity)
528                        .await
529                        .map_err(backbone_core::RepositoryError::from_source)?;
530                    results.push(created);
531                }
532                Ok(results)
533            }
534
535            async fn empty_trash(&self) -> Result<u64, backbone_core::RepositoryError> {
536                Ok(0)
537            }
538
539            // ── Atomic batch operations ───────────────────────────────────────
540
541            async fn bulk_soft_delete(
542                &self,
543                ids: &[String],
544            ) -> Result<u64, backbone_core::RepositoryError> {
545                // No soft delete: fall back to hard delete
546                (&**self).bulk_delete(ids)
547                    .await
548                    .map_err(backbone_core::RepositoryError::from_source)
549            }
550
551            async fn bulk_restore(
552                &self,
553                _ids: &[String],
554            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
555                // No soft delete: nothing is ever in trash to restore
556                Ok(Vec::new())
557            }
558
559            async fn bulk_hard_delete(
560                &self,
561                ids: &[String],
562            ) -> Result<u64, backbone_core::RepositoryError> {
563                (&**self).bulk_delete(ids)
564                    .await
565                    .map_err(backbone_core::RepositoryError::from_source)
566            }
567
568            async fn restore_all(&self) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
569                // No soft delete: trash is always empty
570                Ok(Vec::new())
571            }
572
573            async fn bulk_update(
574                &self,
575                entities: Vec<$entity>,
576            ) -> Result<Vec<$entity>, backbone_core::RepositoryError> {
577                (&**self).bulk_update(&entities)
578                    .await
579                    .map_err(backbone_core::RepositoryError::from_source)
580            }
581        }
582    };
583}