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    /// Fields that a generic write (PUT, PATCH and their bulk forms) may not
126    /// change, by their serialized name.
127    ///
128    /// The generic service refuses a write that would give one of these a new
129    /// value, and still accepts a write that carries the stored value
130    /// unchanged, so a form that sends the whole record keeps working. Code
131    /// that owns the field — a verb, a write service — changes it through its
132    /// own path. `id` and `metadata` are refused for every entity regardless
133    /// of this list. The schema generator fills it from the fields the schema
134    /// declares as not freely writable; the default protects nothing more.
135    fn write_protected_fields() -> &'static [&'static str] {
136        &[]
137    }
138}
139
140/// Trait for entities that support partial updates via field map
141pub trait PartialUpdatable: PersistentEntity {
142    /// Apply partial updates from a field map
143    fn apply_partial_update(&mut self, fields: &HashMap<String, serde_json::Value>) -> Result<(), RepositoryError>;
144}
145
146/// Trait for entities with version/optimistic locking
147pub trait Versioned {
148    fn version(&self) -> u64;
149    fn set_version(&mut self, version: u64);
150    fn increment_version(&mut self) {
151        self.set_version(self.version() + 1);
152    }
153}
154
155// ============================================================
156// Repository Traits
157// ============================================================
158
159/// Core CRUD repository trait
160///
161/// This trait defines the basic CRUD operations that all repositories must implement.
162/// It's designed to work with the `CrudService` trait from the HTTP layer.
163#[async_trait]
164pub trait CrudRepository<E>: Send + Sync
165where
166    E: PersistentEntity,
167{
168    /// Create a new entity
169    async fn create(&self, entity: E) -> Result<E, RepositoryError>;
170
171    /// Find entity by ID (excluding soft-deleted)
172    async fn find_by_id(&self, id: &str) -> Result<Option<E>, RepositoryError>;
173
174    /// Find entity by ID (including soft-deleted, for trash operations)
175    async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<E>, RepositoryError>;
176
177    /// Update an existing entity
178    async fn update(&self, entity: E) -> Result<E, RepositoryError>;
179
180    /// Soft delete an entity
181    async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError>;
182
183    /// Restore a soft-deleted entity
184    async fn restore(&self, id: &str) -> Result<Option<E>, RepositoryError>;
185
186    /// Permanently delete an entity
187    async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError>;
188
189    /// List entities with pagination (excluding soft-deleted)
190    async fn list(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
191
192    /// List soft-deleted entities with pagination
193    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
194
195    /// Count all entities (excluding soft-deleted)
196    async fn count(&self) -> Result<u64, RepositoryError>;
197
198    /// Count soft-deleted entities
199    async fn count_deleted(&self) -> Result<u64, RepositoryError>;
200
201    /// Bulk create entities
202    async fn bulk_create(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError>;
203
204    /// Permanently delete all soft-deleted entities
205    async fn empty_trash(&self) -> Result<u64, RepositoryError>;
206
207    /// List entities with pagination and filters (excluding soft-deleted)
208    ///
209    /// Default implementation ignores filters and delegates to `list()`.
210    /// Override in repository implementations that support filter pushdown.
211    async fn list_filtered(
212        &self,
213        page: u32,
214        limit: u32,
215        filters: HashMap<String, String>,
216    ) -> Result<(Vec<E>, u64), RepositoryError> {
217        let _ = filters; // ignored by default
218        self.list(page, limit).await
219    }
220
221    /// `list_filtered`, carrying the pagination info (cursor positions
222    /// included) so the HTTP layer can surface keyset paging. Default: the
223    /// tuple form with the cursor fields absent; the generated Postgres
224    /// repositories override this with the real keyset walk.
225    async fn list_filtered_with_info(
226        &self,
227        page: u32,
228        limit: u32,
229        filters: HashMap<String, String>,
230    ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), RepositoryError> {
231        let (rows, total) = self.list_filtered(page, limit, filters).await?;
232        Ok((
233            rows,
234            backbone_orm::repository::PaginationInfo::new(page, limit, total),
235        ))
236    }
237
238    /// Group and reduce entities under the same filters as `list_filtered`.
239    ///
240    /// There is deliberately no useful default. A repository that cannot group
241    /// must SAY so: an honest error tells the caller its chart is unavailable,
242    /// whereas a default returning zeros would render an empty chart that looks
243    /// like a real answer about a real, empty table. The Postgres-backed
244    /// generated repositories override this via `impl_crud_repository!`.
245    /// The schema-qualified table this repository reads, when it knows it.
246    ///
247    /// The Postgres-backed generated repositories override this; anything that
248    /// cannot name a table returns `None` and simply has no history.
249    fn table_name(&self) -> Option<&str> {
250        None
251    }
252
253    async fn aggregate_filtered(
254        &self,
255        spec: &backbone_orm::repository::AggregateSpec,
256        filters: HashMap<String, String>,
257    ) -> Result<backbone_orm::repository::AggregateResult, RepositoryError> {
258        let _ = (spec, filters);
259        Err(RepositoryError::DatabaseError(
260            "aggregate is not supported by this repository".to_string(),
261        ))
262    }
263
264    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON
265    /// (raw `row_to_json`). `table` comes from `EntityRepoMeta::relations()`
266    /// (generator-emitted), never client input. Default: no expansion. The
267    /// Postgres-backed generated repos override this via `impl_crud_repository!`.
268    async fn fetch_related_json(
269        &self,
270        _table: &str,
271        _ids: &[String],
272    ) -> Vec<serde_json::Value> {
273        Vec::new()
274    }
275
276    /// Check if an entity exists by ID
277    async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
278        Ok(self.find_by_id(id).await?.is_some())
279    }
280
281    // ── Atomic batch operations ───────────────────────────────────────────────
282    //
283    // The default implementations loop over the single-row methods and are NOT
284    // transactional — they exist so non-Postgres implementors (mocks, custom
285    // adapters) keep compiling. The macro-generated repositories override these
286    // (via `impl_crud_repository!`) with truly atomic, single-transaction
287    // versions backed by `GenericCrudRepository`.
288
289    /// Soft-delete many entities by id. Returns the number affected.
290    async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
291        let mut n = 0;
292        for id in ids {
293            if self.soft_delete(id).await? {
294                n += 1;
295            }
296        }
297        Ok(n)
298    }
299
300    /// Restore many soft-deleted entities by id. Returns the restored entities.
301    async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<E>, RepositoryError> {
302        let mut out = Vec::with_capacity(ids.len());
303        for id in ids {
304            if let Some(e) = self.restore(id).await? {
305                out.push(e);
306            }
307        }
308        Ok(out)
309    }
310
311    /// Permanently delete many entities by id. Returns the number affected.
312    async fn bulk_hard_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
313        let mut n = 0;
314        for id in ids {
315            if self.hard_delete(id).await? {
316                n += 1;
317            }
318        }
319        Ok(n)
320    }
321
322    /// Restore every soft-deleted entity. Returns the restored entities.
323    async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
324        let mut restored = Vec::new();
325        // Restoring removes rows from the deleted set, so page 1 always returns
326        // the next batch of still-deleted rows until none remain.
327        loop {
328            let (batch, _) = self.list_deleted(1, 500).await?;
329            if batch.is_empty() {
330                break;
331            }
332            for entity in &batch {
333                if let Some(e) = self.restore(&entity.entity_id()).await? {
334                    restored.push(e);
335                }
336            }
337        }
338        Ok(restored)
339    }
340
341    /// Update many entities atomically. Returns the updated entities.
342    async fn bulk_update(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError> {
343        let mut out = Vec::with_capacity(entities.len());
344        for entity in entities {
345            out.push(self.update(entity).await?);
346        }
347        Ok(out)
348    }
349}
350
351/// Extended repository with search/filter capabilities
352#[async_trait]
353pub trait SearchableRepository<E>: CrudRepository<E>
354where
355    E: PersistentEntity,
356{
357    /// Search entities with filters
358    async fn search(
359        &self,
360        filters: HashMap<String, String>,
361        page: u32,
362        limit: u32,
363    ) -> Result<(Vec<E>, u64), RepositoryError>;
364
365    /// Find by a specific field value
366    async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
367
368    /// Find all by a specific field value
369    async fn find_all_by_field(
370        &self,
371        field: &str,
372        value: &str,
373        page: u32,
374        limit: u32,
375    ) -> Result<(Vec<E>, u64), RepositoryError>;
376}
377
378// ============================================================
379// PostgreSQL-Specific Traits
380// ============================================================
381
382#[cfg(feature = "postgres")]
383pub use postgres_traits::*;
384
385#[cfg(feature = "postgres")]
386mod postgres_traits {
387    use super::*;
388    use sqlx::postgres::PgRow;
389    use sqlx::FromRow;
390
391    /// Trait for mapping entities to/from PostgreSQL rows
392    ///
393    /// Implement this trait to enable automatic PostgreSQL persistence for your entity.
394    ///
395    /// # Example
396    ///
397    /// ```ignore
398    /// use backbone_core::persistence::{PostgresEntity, PersistentEntity};
399    /// use sqlx::FromRow;
400    ///
401    /// #[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
402    /// struct User {
403    ///     id: String,
404    ///     name: String,
405    ///     email: String,
406    ///     created_at: Option<DateTime<Utc>>,
407    ///     updated_at: Option<DateTime<Utc>>,
408    ///     deleted_at: Option<DateTime<Utc>>,
409    /// }
410    ///
411    /// impl PostgresEntity for User {
412    ///     fn table_name() -> &'static str { "users" }
413    ///     fn select_columns() -> &'static [&'static str] {
414    ///         &["id", "name", "email", "created_at", "updated_at", "deleted_at"]
415    ///     }
416    ///     fn insert_columns() -> &'static [&'static str] {
417    ///         &["id", "name", "email", "created_at", "updated_at"]
418    ///     }
419    ///     fn update_columns() -> &'static [&'static str] {
420    ///         &["name", "email", "updated_at"]
421    ///     }
422    ///     fn bind_for_insert(entity: &Self, query: Query<'_, ...>) -> Query<'_, ...> {
423    ///         query.bind(&entity.id).bind(&entity.name).bind(&entity.email)
424    ///             .bind(&entity.created_at).bind(&entity.updated_at)
425    ///     }
426    /// }
427    /// ```
428    pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
429        /// Table name for this entity
430        fn table_name() -> &'static str;
431
432        /// Primary key column name (default: "id")
433        fn id_column() -> &'static str {
434            "id"
435        }
436
437        /// Column names for SELECT queries (excluding computed columns)
438        fn select_columns() -> &'static [&'static str];
439
440        /// Column names for INSERT (all columns that should be inserted)
441        fn insert_columns() -> &'static [&'static str];
442
443        /// Column names for UPDATE (columns that can be updated, excluding id)
444        fn update_columns() -> &'static [&'static str] {
445            Self::insert_columns()
446        }
447
448        /// Bind entity values to a query for INSERT
449        ///
450        /// The order must match insert_columns()
451        fn bind_for_insert<'q>(
452            entity: &'q Self,
453            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
454        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
455
456        /// Bind entity values to a query for UPDATE
457        ///
458        /// The order must match update_columns()
459        fn bind_for_update<'q>(
460            entity: &'q Self,
461            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
462        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
463            // Default: same as insert (override if different)
464            Self::bind_for_insert(entity, query)
465        }
466
467        /// Build SELECT by ID query
468        fn select_by_id_query() -> String {
469            format!(
470                "SELECT {} FROM {} WHERE {} = $1 AND deleted_at IS NULL",
471                Self::select_columns().join(", "),
472                Self::table_name(),
473                Self::id_column()
474            )
475        }
476
477        /// Build SELECT by ID including deleted
478        fn select_by_id_including_deleted_query() -> String {
479            format!(
480                "SELECT {} FROM {} WHERE {} = $1",
481                Self::select_columns().join(", "),
482                Self::table_name(),
483                Self::id_column()
484            )
485        }
486
487        /// Build paginated list query
488        fn list_query() -> String {
489            format!(
490                "SELECT {} FROM {} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
491                Self::select_columns().join(", "),
492                Self::table_name()
493            )
494        }
495
496        /// Build count query
497        fn count_query() -> String {
498            format!(
499                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
500                Self::table_name()
501            )
502        }
503
504        /// Build list deleted (trash) query
505        fn list_deleted_query() -> String {
506            format!(
507                "SELECT {} FROM {} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2",
508                Self::select_columns().join(", "),
509                Self::table_name()
510            )
511        }
512
513        /// Build count deleted query
514        fn count_deleted_query() -> String {
515            format!(
516                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NOT NULL",
517                Self::table_name()
518            )
519        }
520
521        /// Build soft delete query
522        fn soft_delete_query() -> String {
523            format!(
524                "UPDATE {} SET deleted_at = NOW(), updated_at = NOW() WHERE {} = $1 AND deleted_at IS NULL",
525                Self::table_name(),
526                Self::id_column()
527            )
528        }
529
530        /// Build restore query
531        fn restore_query() -> String {
532            format!(
533                "UPDATE {} SET deleted_at = NULL, updated_at = NOW() WHERE {} = $1 AND deleted_at IS NOT NULL RETURNING {}",
534                Self::table_name(),
535                Self::id_column(),
536                Self::select_columns().join(", ")
537            )
538        }
539
540        /// Build hard delete query
541        fn hard_delete_query() -> String {
542            format!(
543                "DELETE FROM {} WHERE {} = $1",
544                Self::table_name(),
545                Self::id_column()
546            )
547        }
548
549        /// Build empty trash query
550        fn empty_trash_query() -> String {
551            format!(
552                "DELETE FROM {} WHERE deleted_at IS NOT NULL",
553                Self::table_name()
554            )
555        }
556    }
557}