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}