Skip to main content

backbone_core/persistence/
traits.rs

1//! Repository Traits - Contracts for Entity Persistence
2//!
3//! These traits define the contracts that entities and repositories must implement
4//! to work with the generic repository implementations.
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use serde::{de::DeserializeOwned, Serialize};
9use std::collections::HashMap;
10use std::fmt::Debug;
11
12// ============================================================
13// Error Types
14// ============================================================
15
16/// Repository error types
17#[derive(Debug, thiserror::Error)]
18pub enum RepositoryError {
19    #[error("Entity not found")]
20    NotFound,
21
22    #[error("Entity already exists: {0}")]
23    AlreadyExists(String),
24
25    #[error("Validation error: {0}")]
26    ValidationError(String),
27
28    #[error("Database error: {0}")]
29    DatabaseError(String),
30
31    #[error("Serialization error: {0}")]
32    SerializationError(String),
33
34    #[error("Conflict: {0}")]
35    Conflict(String),
36
37    #[error("Internal error: {0}")]
38    InternalError(String),
39}
40
41impl From<serde_json::Error> for RepositoryError {
42    fn from(e: serde_json::Error) -> Self {
43        RepositoryError::SerializationError(e.to_string())
44    }
45}
46
47#[cfg(feature = "postgres")]
48impl From<sqlx::Error> for RepositoryError {
49    fn from(e: sqlx::Error) -> Self {
50        match e {
51            sqlx::Error::RowNotFound => RepositoryError::NotFound,
52            sqlx::Error::Database(db_err) => {
53                let msg = db_err.message().to_string();
54                if msg.contains("duplicate key") || msg.contains("unique constraint") {
55                    RepositoryError::AlreadyExists(msg)
56                } else {
57                    RepositoryError::DatabaseError(msg)
58                }
59            }
60            _ => RepositoryError::DatabaseError(e.to_string()),
61        }
62    }
63}
64
65// ============================================================
66// Entity Traits
67// ============================================================
68
69/// Trait for entities that can be persisted.
70///
71/// This trait defines the common fields and behavior required by all entities
72/// that can be stored and retrieved from a repository.
73pub trait PersistentEntity: Clone + Send + Sync + Debug + Serialize + DeserializeOwned + 'static {
74    /// Get the entity's unique identifier
75    fn entity_id(&self) -> String;
76
77    /// Set the entity's unique identifier
78    fn set_entity_id(&mut self, id: String);
79
80    /// Get creation timestamp
81    fn created_at(&self) -> Option<DateTime<Utc>>;
82
83    /// Set creation timestamp
84    fn set_created_at(&mut self, ts: DateTime<Utc>);
85
86    /// Get last update timestamp
87    fn updated_at(&self) -> Option<DateTime<Utc>>;
88
89    /// Set last update timestamp
90    fn set_updated_at(&mut self, ts: DateTime<Utc>);
91
92    /// Get soft delete timestamp (None if not deleted)
93    fn deleted_at(&self) -> Option<DateTime<Utc>>;
94
95    /// Set soft delete timestamp
96    fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>);
97
98    /// Check if entity is soft-deleted
99    fn is_deleted(&self) -> bool {
100        self.deleted_at().is_some()
101    }
102
103    /// Mark entity as deleted (soft delete)
104    fn mark_deleted(&mut self) {
105        self.set_deleted_at(Some(Utc::now()));
106        self.set_updated_at(Utc::now());
107    }
108
109    /// Restore a soft-deleted entity
110    fn restore(&mut self) {
111        self.set_deleted_at(None);
112        self.set_updated_at(Utc::now());
113    }
114
115    /// Touch the entity (update timestamp)
116    fn touch(&mut self) {
117        self.set_updated_at(Utc::now());
118    }
119
120    /// Generate a new ID for this entity type
121    fn generate_id() -> String {
122        uuid::Uuid::new_v4().to_string()
123    }
124}
125
126/// Trait for entities that support partial updates via field map
127pub trait PartialUpdatable: PersistentEntity {
128    /// Apply partial updates from a field map
129    fn apply_partial_update(&mut self, fields: &HashMap<String, serde_json::Value>) -> Result<(), RepositoryError>;
130}
131
132/// Trait for entities with version/optimistic locking
133pub trait Versioned {
134    fn version(&self) -> u64;
135    fn set_version(&mut self, version: u64);
136    fn increment_version(&mut self) {
137        self.set_version(self.version() + 1);
138    }
139}
140
141// ============================================================
142// Repository Traits
143// ============================================================
144
145/// Core CRUD repository trait
146///
147/// This trait defines the basic CRUD operations that all repositories must implement.
148/// It's designed to work with the `CrudService` trait from the HTTP layer.
149#[async_trait]
150pub trait CrudRepository<E>: Send + Sync
151where
152    E: PersistentEntity,
153{
154    /// Create a new entity
155    async fn create(&self, entity: E) -> Result<E, RepositoryError>;
156
157    /// Find entity by ID (excluding soft-deleted)
158    async fn find_by_id(&self, id: &str) -> Result<Option<E>, RepositoryError>;
159
160    /// Find entity by ID (including soft-deleted, for trash operations)
161    async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<E>, RepositoryError>;
162
163    /// Update an existing entity
164    async fn update(&self, entity: E) -> Result<E, RepositoryError>;
165
166    /// Soft delete an entity
167    async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError>;
168
169    /// Restore a soft-deleted entity
170    async fn restore(&self, id: &str) -> Result<Option<E>, RepositoryError>;
171
172    /// Permanently delete an entity
173    async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError>;
174
175    /// List entities with pagination (excluding soft-deleted)
176    async fn list(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
177
178    /// List soft-deleted entities with pagination
179    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
180
181    /// Count all entities (excluding soft-deleted)
182    async fn count(&self) -> Result<u64, RepositoryError>;
183
184    /// Count soft-deleted entities
185    async fn count_deleted(&self) -> Result<u64, RepositoryError>;
186
187    /// Bulk create entities
188    async fn bulk_create(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError>;
189
190    /// Permanently delete all soft-deleted entities
191    async fn empty_trash(&self) -> Result<u64, RepositoryError>;
192
193    /// List entities with pagination and filters (excluding soft-deleted)
194    ///
195    /// Default implementation ignores filters and delegates to `list()`.
196    /// Override in repository implementations that support filter pushdown.
197    async fn list_filtered(
198        &self,
199        page: u32,
200        limit: u32,
201        filters: HashMap<String, String>,
202    ) -> Result<(Vec<E>, u64), RepositoryError> {
203        let _ = filters; // ignored by default
204        self.list(page, limit).await
205    }
206
207    /// `list_filtered`, carrying the pagination info (cursor positions
208    /// included) so the HTTP layer can surface keyset paging. Default: the
209    /// tuple form with the cursor fields absent; the generated Postgres
210    /// repositories override this with the real keyset walk.
211    async fn list_filtered_with_info(
212        &self,
213        page: u32,
214        limit: u32,
215        filters: HashMap<String, String>,
216    ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), RepositoryError> {
217        let (rows, total) = self.list_filtered(page, limit, filters).await?;
218        Ok((
219            rows,
220            backbone_orm::repository::PaginationInfo::new(page, limit, total),
221        ))
222    }
223
224    /// Group and reduce entities under the same filters as `list_filtered`.
225    ///
226    /// There is deliberately no useful default. A repository that cannot group
227    /// must SAY so: an honest error tells the caller its chart is unavailable,
228    /// whereas a default returning zeros would render an empty chart that looks
229    /// like a real answer about a real, empty table. The Postgres-backed
230    /// generated repositories override this via `impl_crud_repository!`.
231    /// The schema-qualified table this repository reads, when it knows it.
232    ///
233    /// The Postgres-backed generated repositories override this; anything that
234    /// cannot name a table returns `None` and simply has no history.
235    fn table_name(&self) -> Option<&str> {
236        None
237    }
238
239    async fn aggregate_filtered(
240        &self,
241        spec: &backbone_orm::repository::AggregateSpec,
242        filters: HashMap<String, String>,
243    ) -> Result<backbone_orm::repository::AggregateResult, RepositoryError> {
244        let _ = (spec, filters);
245        Err(RepositoryError::DatabaseError(
246            "aggregate is not supported by this repository".to_string(),
247        ))
248    }
249
250    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON
251    /// (raw `row_to_json`). `table` comes from `EntityRepoMeta::relations()`
252    /// (generator-emitted), never client input. Default: no expansion. The
253    /// Postgres-backed generated repos override this via `impl_crud_repository!`.
254    async fn fetch_related_json(
255        &self,
256        _table: &str,
257        _ids: &[String],
258    ) -> Vec<serde_json::Value> {
259        Vec::new()
260    }
261
262    /// Check if an entity exists by ID
263    async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
264        Ok(self.find_by_id(id).await?.is_some())
265    }
266
267    // ── Atomic batch operations ───────────────────────────────────────────────
268    //
269    // The default implementations loop over the single-row methods and are NOT
270    // transactional — they exist so non-Postgres implementors (mocks, custom
271    // adapters) keep compiling. The macro-generated repositories override these
272    // (via `impl_crud_repository!`) with truly atomic, single-transaction
273    // versions backed by `GenericCrudRepository`.
274
275    /// Soft-delete many entities by id. Returns the number affected.
276    async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
277        let mut n = 0;
278        for id in ids {
279            if self.soft_delete(id).await? {
280                n += 1;
281            }
282        }
283        Ok(n)
284    }
285
286    /// Restore many soft-deleted entities by id. Returns the restored entities.
287    async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<E>, RepositoryError> {
288        let mut out = Vec::with_capacity(ids.len());
289        for id in ids {
290            if let Some(e) = self.restore(id).await? {
291                out.push(e);
292            }
293        }
294        Ok(out)
295    }
296
297    /// Permanently delete many entities by id. Returns the number affected.
298    async fn bulk_hard_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
299        let mut n = 0;
300        for id in ids {
301            if self.hard_delete(id).await? {
302                n += 1;
303            }
304        }
305        Ok(n)
306    }
307
308    /// Restore every soft-deleted entity. Returns the restored entities.
309    async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
310        let mut restored = Vec::new();
311        // Restoring removes rows from the deleted set, so page 1 always returns
312        // the next batch of still-deleted rows until none remain.
313        loop {
314            let (batch, _) = self.list_deleted(1, 500).await?;
315            if batch.is_empty() {
316                break;
317            }
318            for entity in &batch {
319                if let Some(e) = self.restore(&entity.entity_id()).await? {
320                    restored.push(e);
321                }
322            }
323        }
324        Ok(restored)
325    }
326
327    /// Update many entities atomically. Returns the updated entities.
328    async fn bulk_update(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError> {
329        let mut out = Vec::with_capacity(entities.len());
330        for entity in entities {
331            out.push(self.update(entity).await?);
332        }
333        Ok(out)
334    }
335}
336
337/// Extended repository with search/filter capabilities
338#[async_trait]
339pub trait SearchableRepository<E>: CrudRepository<E>
340where
341    E: PersistentEntity,
342{
343    /// Search entities with filters
344    async fn search(
345        &self,
346        filters: HashMap<String, String>,
347        page: u32,
348        limit: u32,
349    ) -> Result<(Vec<E>, u64), RepositoryError>;
350
351    /// Find by a specific field value
352    async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
353
354    /// Find all by a specific field value
355    async fn find_all_by_field(
356        &self,
357        field: &str,
358        value: &str,
359        page: u32,
360        limit: u32,
361    ) -> Result<(Vec<E>, u64), RepositoryError>;
362}
363
364// ============================================================
365// PostgreSQL-Specific Traits
366// ============================================================
367
368#[cfg(feature = "postgres")]
369pub use postgres_traits::*;
370
371#[cfg(feature = "postgres")]
372mod postgres_traits {
373    use super::*;
374    use sqlx::postgres::PgRow;
375    use sqlx::FromRow;
376
377    /// Trait for mapping entities to/from PostgreSQL rows
378    ///
379    /// Implement this trait to enable automatic PostgreSQL persistence for your entity.
380    ///
381    /// # Example
382    ///
383    /// ```ignore
384    /// use backbone_core::persistence::{PostgresEntity, PersistentEntity};
385    /// use sqlx::FromRow;
386    ///
387    /// #[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
388    /// struct User {
389    ///     id: String,
390    ///     name: String,
391    ///     email: String,
392    ///     created_at: Option<DateTime<Utc>>,
393    ///     updated_at: Option<DateTime<Utc>>,
394    ///     deleted_at: Option<DateTime<Utc>>,
395    /// }
396    ///
397    /// impl PostgresEntity for User {
398    ///     fn table_name() -> &'static str { "users" }
399    ///     fn select_columns() -> &'static [&'static str] {
400    ///         &["id", "name", "email", "created_at", "updated_at", "deleted_at"]
401    ///     }
402    ///     fn insert_columns() -> &'static [&'static str] {
403    ///         &["id", "name", "email", "created_at", "updated_at"]
404    ///     }
405    ///     fn update_columns() -> &'static [&'static str] {
406    ///         &["name", "email", "updated_at"]
407    ///     }
408    ///     fn bind_for_insert(entity: &Self, query: Query<'_, ...>) -> Query<'_, ...> {
409    ///         query.bind(&entity.id).bind(&entity.name).bind(&entity.email)
410    ///             .bind(&entity.created_at).bind(&entity.updated_at)
411    ///     }
412    /// }
413    /// ```
414    pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
415        /// Table name for this entity
416        fn table_name() -> &'static str;
417
418        /// Primary key column name (default: "id")
419        fn id_column() -> &'static str {
420            "id"
421        }
422
423        /// Column names for SELECT queries (excluding computed columns)
424        fn select_columns() -> &'static [&'static str];
425
426        /// Column names for INSERT (all columns that should be inserted)
427        fn insert_columns() -> &'static [&'static str];
428
429        /// Column names for UPDATE (columns that can be updated, excluding id)
430        fn update_columns() -> &'static [&'static str] {
431            Self::insert_columns()
432        }
433
434        /// Bind entity values to a query for INSERT
435        ///
436        /// The order must match insert_columns()
437        fn bind_for_insert<'q>(
438            entity: &'q Self,
439            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
440        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
441
442        /// Bind entity values to a query for UPDATE
443        ///
444        /// The order must match update_columns()
445        fn bind_for_update<'q>(
446            entity: &'q Self,
447            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
448        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
449            // Default: same as insert (override if different)
450            Self::bind_for_insert(entity, query)
451        }
452
453        /// Build SELECT by ID query
454        fn select_by_id_query() -> String {
455            format!(
456                "SELECT {} FROM {} WHERE {} = $1 AND deleted_at IS NULL",
457                Self::select_columns().join(", "),
458                Self::table_name(),
459                Self::id_column()
460            )
461        }
462
463        /// Build SELECT by ID including deleted
464        fn select_by_id_including_deleted_query() -> String {
465            format!(
466                "SELECT {} FROM {} WHERE {} = $1",
467                Self::select_columns().join(", "),
468                Self::table_name(),
469                Self::id_column()
470            )
471        }
472
473        /// Build paginated list query
474        fn list_query() -> String {
475            format!(
476                "SELECT {} FROM {} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
477                Self::select_columns().join(", "),
478                Self::table_name()
479            )
480        }
481
482        /// Build count query
483        fn count_query() -> String {
484            format!(
485                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
486                Self::table_name()
487            )
488        }
489
490        /// Build list deleted (trash) query
491        fn list_deleted_query() -> String {
492            format!(
493                "SELECT {} FROM {} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2",
494                Self::select_columns().join(", "),
495                Self::table_name()
496            )
497        }
498
499        /// Build count deleted query
500        fn count_deleted_query() -> String {
501            format!(
502                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NOT NULL",
503                Self::table_name()
504            )
505        }
506
507        /// Build soft delete query
508        fn soft_delete_query() -> String {
509            format!(
510                "UPDATE {} SET deleted_at = NOW(), updated_at = NOW() WHERE {} = $1 AND deleted_at IS NULL",
511                Self::table_name(),
512                Self::id_column()
513            )
514        }
515
516        /// Build restore query
517        fn restore_query() -> String {
518            format!(
519                "UPDATE {} SET deleted_at = NULL, updated_at = NOW() WHERE {} = $1 AND deleted_at IS NOT NULL RETURNING {}",
520                Self::table_name(),
521                Self::id_column(),
522                Self::select_columns().join(", ")
523            )
524        }
525
526        /// Build hard delete query
527        fn hard_delete_query() -> String {
528            format!(
529                "DELETE FROM {} WHERE {} = $1",
530                Self::table_name(),
531                Self::id_column()
532            )
533        }
534
535        /// Build empty trash query
536        fn empty_trash_query() -> String {
537            format!(
538                "DELETE FROM {} WHERE deleted_at IS NOT NULL",
539                Self::table_name()
540            )
541        }
542    }
543}