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