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}