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    /// The database refused the write through a constraint that stands for a
41    /// rule: a foreign key, a CHECK, a generated unique index or an exclusion.
42    /// One violation, coded as the application-side check is.
43    #[error("validation failed: {}", .0.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; "))]
44    Violations(Vec<crate::violation::Violation>),
45}
46
47impl RepositoryError {
48    /// The repository error for a failure an inner layer reported as any error type.
49    /// A database error inside it (however wrapped in context) converts as a `sqlx::Error`
50    /// does, so a constraint the database refused with stays a violation; anything else
51    /// keeps its sentence as a database error.
52    pub fn from_source(e: impl Into<anyhow::Error>) -> Self {
53        let e: anyhow::Error = e.into();
54        #[cfg(feature = "postgres")]
55        let e = match e.downcast::<sqlx::Error>() {
56            Ok(db) => return RepositoryError::from(db),
57            Err(e) => e,
58        };
59        RepositoryError::DatabaseError(e.to_string())
60    }
61}
62
63impl From<serde_json::Error> for RepositoryError {
64    fn from(e: serde_json::Error) -> Self {
65        RepositoryError::SerializationError(e.to_string())
66    }
67}
68
69#[cfg(feature = "postgres")]
70impl From<sqlx::Error> for RepositoryError {
71    fn from(e: sqlx::Error) -> Self {
72        match e {
73            sqlx::Error::RowNotFound => RepositoryError::NotFound,
74            sqlx::Error::Database(db_err) => {
75                let pg = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>();
76                if let Some(v) = constraint_violation(
77                    db_err.kind(),
78                    db_err.code().as_deref(),
79                    db_err.constraint(),
80                    pg.and_then(|p| p.detail()).unwrap_or(""),
81                    pg.and_then(|p| p.column()),
82                    pg.and_then(|p| p.table()),
83                ) {
84                    return RepositoryError::Violations(vec![v]);
85                }
86                let msg = db_err.message().to_string();
87                if msg.contains("duplicate key") || msg.contains("unique constraint") {
88                    RepositoryError::AlreadyExists(msg)
89                } else {
90                    RepositoryError::DatabaseError(msg)
91                }
92            }
93            _ => RepositoryError::DatabaseError(e.to_string()),
94        }
95    }
96}
97
98/// The violation a database constraint error stands for, or `None` for an error
99/// that names no rule. A foreign key answers `reference_missing` on the column
100/// that names a missing record, or `still_referenced` when a removed record is
101/// still pointed at. A NOT NULL answers `required` on its column, worded as the
102/// application-side required check is. A CHECK, a generated unique index (`uq_…`)
103/// and an exclusion answer the code their constraint name carries (see
104/// [`crate::violation::code_from_constraint`]). Another unique index keeps its
105/// `AlreadyExists` answer.
106///
107/// The `code` stays machine-readable for clients to translate; the sentence is
108/// for a person, so it never names a table, a constraint or a rule identifier.
109/// `table` is the table Postgres reports, used to read the column out of a
110/// generated CHECK's name, since Postgres reports no column for a CHECK.
111#[cfg(feature = "postgres")]
112fn constraint_violation(
113    kind: sqlx::error::ErrorKind,
114    sqlstate: Option<&str>,
115    constraint: Option<&str>,
116    detail: &str,
117    column: Option<&str>,
118    table: Option<&str>,
119) -> Option<crate::violation::Violation> {
120    use crate::violation::{code_from_constraint, Violation};
121    use sqlx::error::ErrorKind;
122    if kind == ErrorKind::NotNullViolation || sqlstate == Some("23502") {
123        // 23502 names no constraint, only the column.
124        let column = column.filter(|c| !c.is_empty())?;
125        return Some(Violation::new(
126            column,
127            "required",
128            format!("{column} is required"),
129        ));
130    }
131    let constraint = constraint?;
132    let code = code_from_constraint(constraint);
133    let path = column
134        .filter(|c| !c.is_empty())
135        .map(str::to_string)
136        .or_else(|| key_column(detail))
137        .or_else(|| table.and_then(|t| check_column(constraint, t, &code)))
138        .unwrap_or_default();
139    Some(match kind {
140        ErrorKind::ForeignKeyViolation if detail.contains("still referenced") => Violation::new(
141            "",
142            "still_referenced",
143            "This record is still referenced by another record, so it cannot be removed",
144        ),
145        ErrorKind::ForeignKeyViolation => {
146            let message = match path.as_str() {
147                "" => "The record refers to a record that does not exist".to_string(),
148                p => format!("{p} refers to a record that does not exist"),
149            };
150            Violation::new(path, "reference_missing", message)
151        }
152        ErrorKind::CheckViolation => {
153            let message = check_sentence(&path, &code);
154            Violation::new(path, code, message)
155        }
156        ErrorKind::UniqueViolation if constraint.starts_with("uq_") => {
157            let message = match path.as_str() {
158                "" => "This value is already taken by another record".to_string(),
159                p => format!("{p} is already taken by another record"),
160            };
161            Violation::new(path, code, message)
162        }
163        _ if sqlstate == Some("23P01") => Violation::new(
164            "",
165            code,
166            "The record overlaps another one it may not overlap",
167        ),
168        _ => return None,
169    })
170}
171
172/// A person's sentence for a broken CHECK, keyed by its rule code. The generated
173/// field checks' codes read as the application-side check does (without the
174/// bound itself, which the constraint error does not carry); any other code
175/// gets a neutral sentence rather than its identifier.
176#[cfg(feature = "postgres")]
177fn check_sentence(path: &str, code: &str) -> String {
178    let field = if path.is_empty() {
179        "A value".to_string()
180    } else {
181        path.to_string()
182    };
183    let predicate = match code {
184        "required" => "is required",
185        "max_length" => "is too long",
186        "min_length" => "is too short",
187        "length" => "does not have the required length",
188        "max" => "is too large",
189        "min" => "is too small",
190        "range" => "is out of the allowed range",
191        "positive" => "must be positive",
192        "non_negative" => "must not be negative",
193        "pattern" => "does not have the expected form",
194        "alpha_dash" => "may hold only letters, digits, '_' and '-'",
195        "alpha_num" => "may hold only letters and digits",
196        "values" => "is not one of the allowed values",
197        "email" => "must be an email address",
198        "url" => "must be an absolute URL",
199        "state_transition_not_declared" => "cannot move to that state from its current one",
200        _ if path.is_empty() => return "The record does not satisfy one of its rules".to_string(),
201        _ => "does not satisfy one of its rules",
202    };
203    format!("{field} {predicate}")
204}
205
206/// The column a generated field CHECK guards, read out of its name.
207///
208/// The schema generator names a field check `ck_<table>_<field>_<code>__<code>`
209/// (e.g. `ck_tickets_title_max_length__max_length`); a row rule's name carries no
210/// field and a name too long for Postgres ends in a hash, and neither yields one.
211#[cfg(feature = "postgres")]
212fn check_column(constraint: &str, table: &str, code: &str) -> Option<String> {
213    let head = constraint.strip_prefix("ck_")?.split_once("__")?.0;
214    let rest = head.strip_prefix(table)?.strip_prefix('_')?;
215    let field = rest.strip_suffix(code)?.strip_suffix('_')?;
216    (!field.is_empty()).then(|| field.to_string())
217}
218
219/// Columns a unique key carries for scoping, not as the value a person chose.
220#[cfg(feature = "postgres")]
221const SCOPE_COLUMNS: &[&str] = &[
222    "company_id",
223    "tenant_id",
224    "org_unit_id",
225    "organization_id",
226    "deleted_at",
227];
228
229/// The column a constraint error's detail names (`Key (district_id)=(…)`). For a
230/// composite key (`Key (company_id, code)=(…)`) it is the last column that is not
231/// a tenant/scope column, read through an expression such as `lower(code::text)`.
232#[cfg(feature = "postgres")]
233fn key_column(detail: &str) -> Option<String> {
234    let rest = detail.strip_prefix("Key (")?;
235    let cols = &rest[..rest.find(")=")?];
236    let names: Vec<String> = cols
237        .split(',')
238        .filter_map(|part| {
239            // The first identifier inside an expression: `lower(sku::text)` → `sku`.
240            let inner = part.rsplit('(').next().unwrap_or(part);
241            let ident: String = inner
242                .trim()
243                .trim_matches('"')
244                .chars()
245                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
246                .collect();
247            (!ident.is_empty()).then_some(ident)
248        })
249        .collect();
250    names
251        .iter()
252        .rev()
253        .find(|n| !SCOPE_COLUMNS.contains(&n.as_str()))
254        .or_else(|| (names.len() == 1).then(|| &names[0]))
255        .cloned()
256}
257
258#[cfg(all(test, feature = "postgres"))]
259mod constraint_violation_tests {
260    use super::*;
261    use sqlx::error::ErrorKind;
262
263    #[test]
264    fn a_missing_reference_names_the_column_and_never_the_table() {
265        let v = constraint_violation(
266            ErrorKind::ForeignKeyViolation,
267            Some("23503"),
268            Some("fk_subdistricts_district_id"),
269            "Key (district_id)=(1484f475-14be-4abc-985b-7291543d32bc) is not present in table \"districts\".",
270            None,
271            None,
272        )
273        .unwrap();
274        assert_eq!((v.path.as_str(), v.code.as_str()), ("district_id", "reference_missing"));
275        assert!(!v.message.contains("districts\"") && !v.message.contains("fk_"), "{}", v.message);
276    }
277
278    #[test]
279    fn removing_a_referenced_record_is_still_referenced() {
280        let v = constraint_violation(
281            ErrorKind::ForeignKeyViolation,
282            Some("23503"),
283            Some("fk_provinces_country_id"),
284            "Key (id)=(866f1b4e-a9f3-4626-95d1-13c183916b4b) is still referenced from table \"provinces\".",
285            None,
286            None,
287        )
288        .unwrap();
289        assert_eq!((v.path.as_str(), v.code.as_str()), ("", "still_referenced"));
290    }
291
292    #[test]
293    fn a_rule_constraint_answers_its_rules_code() {
294        // Postgres reports no column for a CHECK; the generator's name carries it.
295        let check = constraint_violation(ErrorKind::CheckViolation, Some("23514"), Some("ck_countries_name_max_length__max_length"), "", None, Some("countries")).unwrap();
296        assert_eq!((check.path.as_str(), check.code.as_str()), ("name", "max_length"));
297        let unique = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_accounts__account_code_taken"), "Key (company_id, code)=(a, b) already exists.", None, Some("accounts")).unwrap();
298        assert_eq!((unique.path.as_str(), unique.code.as_str()), ("code", "account_code_taken"));
299        let overlap = constraint_violation(ErrorKind::Other, Some("23P01"), Some("ex_tax_rates__overlapping_validity"), "", None, Some("tax_rates")).unwrap();
300        assert_eq!(overlap.code, "overlapping_validity");
301    }
302
303    #[test]
304    fn a_missing_required_value_is_a_required_violation_on_its_column() {
305        // 23502 names no constraint, only the column.
306        let v = constraint_violation(
307            ErrorKind::NotNullViolation,
308            Some("23502"),
309            None,
310            "",
311            Some("employee_id"),
312            Some("payslips"),
313        )
314        .unwrap();
315        assert_eq!(
316            (v.path.as_str(), v.code.as_str()),
317            ("employee_id", "required")
318        );
319        assert_eq!(v.message, "employee_id is required");
320    }
321
322    #[test]
323    fn a_composite_unique_key_names_its_own_column_not_the_tenant() {
324        let v = constraint_violation(
325            ErrorKind::UniqueViolation,
326            Some("23505"),
327            Some("uq_accounts__code_taken"),
328            "Key (company_id, code)=(7c0a…, 1100) already exists.",
329            None,
330            Some("accounts"),
331        )
332        .unwrap();
333        assert_eq!(v.path, "code");
334        assert!(
335            v.message.starts_with("code "),
336            "a sentence with a subject: {}",
337            v.message
338        );
339        let scoped = constraint_violation(
340            ErrorKind::UniqueViolation,
341            Some("23505"),
342            Some("uq_items__sku_taken"),
343            "Key (org_unit_id, lower(sku::text))=(a, b) already exists.",
344            None,
345            Some("items"),
346        )
347        .unwrap();
348        assert_eq!(scoped.path, "sku");
349        let bare = constraint_violation(
350            ErrorKind::UniqueViolation,
351            Some("23505"),
352            Some("uq_x__taken"),
353            "",
354            None,
355            None,
356        )
357        .unwrap();
358        assert_eq!(bare.path, "");
359        assert!(
360            bare.message.starts_with(char::is_uppercase),
361            "{}",
362            bare.message
363        );
364    }
365
366    #[test]
367    fn a_check_reads_as_a_sentence_never_as_the_rule_identifier() {
368        let v = constraint_violation(
369            ErrorKind::CheckViolation,
370            Some("23514"),
371            Some("ck_tickets_title_max_length__max_length"),
372            "",
373            None,
374            Some("tickets"),
375        )
376        .unwrap();
377        assert_eq!(v.code, "max_length", "the code stays machine-readable");
378        assert!(
379            !v.message.contains('`') && !v.message.contains("max_length"),
380            "{}",
381            v.message
382        );
383        assert!(v.message.starts_with("title "), "{}", v.message);
384        // A row rule names no single column: a whole-record sentence, still without identifiers.
385        let row = constraint_violation(
386            ErrorKind::CheckViolation,
387            Some("23514"),
388            Some("ck_journals_g_jrn_1__unbalanced"),
389            "",
390            None,
391            Some("journals"),
392        )
393        .unwrap();
394        assert_eq!((row.path.as_str(), row.code.as_str()), ("", "unbalanced"));
395        assert!(
396            !row.message.contains("unbalanced") && !row.message.contains('`'),
397            "{}",
398            row.message
399        );
400        // A hand-written trigger sets the column itself.
401        let tg = constraint_violation(
402            ErrorKind::CheckViolation,
403            Some("23514"),
404            Some("tg_journals__state_transition_not_declared"),
405            "",
406            Some("status"),
407            Some("journals"),
408        )
409        .unwrap();
410        assert_eq!(
411            (tg.path.as_str(), tg.code.as_str()),
412            ("status", "state_transition_not_declared")
413        );
414        assert!(
415            !tg.message.contains("state_transition_not_declared"),
416            "{}",
417            tg.message
418        );
419    }
420
421    #[test]
422    fn a_database_error_inside_any_error_converts_as_the_database_error_does() {
423        let wrapped = anyhow::Error::new(sqlx::Error::RowNotFound).context("loading the row");
424        assert!(matches!(RepositoryError::from_source(wrapped), RepositoryError::NotFound));
425        assert!(matches!(RepositoryError::from_source(sqlx::Error::RowNotFound), RepositoryError::NotFound));
426        let other = RepositoryError::from_source(anyhow::anyhow!("pool closed"));
427        assert!(matches!(other, RepositoryError::DatabaseError(ref m) if m == "pool closed"), "{other:?}");
428    }
429
430    #[test]
431    fn an_error_that_names_no_rule_keeps_its_old_answer() {
432        assert!(constraint_violation(
433            ErrorKind::UniqueViolation,
434            Some("23505"),
435            Some("users_email_key"),
436            "",
437            None,
438            None
439        )
440        .is_none());
441        assert!(
442            constraint_violation(ErrorKind::Other, Some("42P01"), None, "", None, None).is_none()
443        );
444        // A not-null error without a column says nothing a person could act on.
445        assert!(constraint_violation(
446            ErrorKind::NotNullViolation,
447            Some("23502"),
448            None,
449            "",
450            None,
451            None
452        )
453        .is_none());
454    }
455}
456
457// ============================================================
458// Entity Traits
459// ============================================================
460
461/// Trait for entities that can be persisted.
462///
463/// This trait defines the common fields and behavior required by all entities
464/// that can be stored and retrieved from a repository.
465pub trait PersistentEntity: Clone + Send + Sync + Debug + Serialize + DeserializeOwned + 'static {
466    /// Get the entity's unique identifier
467    fn entity_id(&self) -> String;
468
469    /// Set the entity's unique identifier
470    fn set_entity_id(&mut self, id: String);
471
472    /// Get creation timestamp
473    fn created_at(&self) -> Option<DateTime<Utc>>;
474
475    /// Set creation timestamp
476    fn set_created_at(&mut self, ts: DateTime<Utc>);
477
478    /// Get last update timestamp
479    fn updated_at(&self) -> Option<DateTime<Utc>>;
480
481    /// Set last update timestamp
482    fn set_updated_at(&mut self, ts: DateTime<Utc>);
483
484    /// Get soft delete timestamp (None if not deleted)
485    fn deleted_at(&self) -> Option<DateTime<Utc>>;
486
487    /// Set soft delete timestamp
488    fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>);
489
490    /// Check if entity is soft-deleted
491    fn is_deleted(&self) -> bool {
492        self.deleted_at().is_some()
493    }
494
495    /// Mark entity as deleted (soft delete)
496    fn mark_deleted(&mut self) {
497        self.set_deleted_at(Some(Utc::now()));
498        self.set_updated_at(Utc::now());
499    }
500
501    /// Restore a soft-deleted entity
502    fn restore(&mut self) {
503        self.set_deleted_at(None);
504        self.set_updated_at(Utc::now());
505    }
506
507    /// Touch the entity (update timestamp)
508    fn touch(&mut self) {
509        self.set_updated_at(Utc::now());
510    }
511
512    /// Generate a new ID for this entity type
513    fn generate_id() -> String {
514        uuid::Uuid::new_v4().to_string()
515    }
516
517    /// Fields that a generic write (PUT, PATCH and their bulk forms) may not
518    /// change, by their serialized name.
519    ///
520    /// The generic service refuses a write that would give one of these a new
521    /// value, and still accepts a write that carries the stored value
522    /// unchanged, so a form that sends the whole record keeps working. Code
523    /// that owns the field — a verb, a write service — changes it through its
524    /// own path. `id` and `metadata` are refused for every entity regardless
525    /// of this list. The schema generator fills it from the fields the schema
526    /// declares as not freely writable; the default protects nothing more.
527    fn write_protected_fields() -> &'static [&'static str] {
528        &[]
529    }
530
531    /// Fields a state machine owns, by their serialized name: they move only through the
532    /// machine's verbs.
533    ///
534    /// A state field is usually also in [`Self::write_protected_fields`], which refuses it to
535    /// every generic write. A verb's own write (one running inside
536    /// [`as_transition`](crate::write_guard::as_transition)) may still move these fields — and
537    /// only these: every other protected field stays refused inside a transition too. The
538    /// schema generator fills it from the entity's state machines; the default is none, which
539    /// leaves a protected state field refused to verbs as well.
540    fn state_fields() -> &'static [&'static str] {
541        &[]
542    }
543}
544
545/// Trait for entities that support partial updates via field map
546pub trait PartialUpdatable: PersistentEntity {
547    /// Apply partial updates from a field map
548    fn apply_partial_update(&mut self, fields: &HashMap<String, serde_json::Value>) -> Result<(), RepositoryError>;
549}
550
551/// Trait for entities with version/optimistic locking
552pub trait Versioned {
553    fn version(&self) -> u64;
554    fn set_version(&mut self, version: u64);
555    fn increment_version(&mut self) {
556        self.set_version(self.version() + 1);
557    }
558}
559
560// ============================================================
561// Repository Traits
562// ============================================================
563
564/// Core CRUD repository trait
565///
566/// This trait defines the basic CRUD operations that all repositories must implement.
567/// It's designed to work with the `CrudService` trait from the HTTP layer.
568#[async_trait]
569pub trait CrudRepository<E>: Send + Sync
570where
571    E: PersistentEntity,
572{
573    /// Create a new entity
574    async fn create(&self, entity: E) -> Result<E, RepositoryError>;
575
576    /// Find entity by ID (excluding soft-deleted)
577    async fn find_by_id(&self, id: &str) -> Result<Option<E>, RepositoryError>;
578
579    /// Find entity by ID (including soft-deleted, for trash operations)
580    async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<E>, RepositoryError>;
581
582    /// Update an existing entity
583    async fn update(&self, entity: E) -> Result<E, RepositoryError>;
584
585    /// Update an entity from `before` (the row as read) to `after`, writing only the fields
586    /// whose value differs between the two. `Ok(None)` when the row no longer exists or was
587    /// soft-deleted since it was read — the write must never revive it.
588    ///
589    /// The path every generic read-modify-write (PUT, PATCH) takes: a field the caller did not
590    /// change keeps whatever value the database holds now, so a concurrent write to it (a verb
591    /// moving a status) is not overwritten with the stale value read before it. The generated
592    /// Postgres repositories override this with a changed-columns UPDATE guarded by the
593    /// not-deleted predicate. The default falls back to the full [`Self::update`], which a
594    /// repository with no partial write path has to accept.
595    async fn update_changed(&self, before: &E, after: E) -> Result<Option<E>, RepositoryError> {
596        let _ = before;
597        match self.update(after).await {
598            Ok(saved) => Ok(Some(saved)),
599            Err(RepositoryError::NotFound) => Ok(None),
600            Err(e) => Err(e),
601        }
602    }
603
604    /// Soft delete an entity
605    async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError>;
606
607    /// Restore a soft-deleted entity
608    async fn restore(&self, id: &str) -> Result<Option<E>, RepositoryError>;
609
610    /// Permanently delete an entity
611    async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError>;
612
613    /// List entities with pagination (excluding soft-deleted)
614    async fn list(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
615
616    /// List soft-deleted entities with pagination
617    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
618
619    /// Count all entities (excluding soft-deleted)
620    async fn count(&self) -> Result<u64, RepositoryError>;
621
622    /// Count soft-deleted entities
623    async fn count_deleted(&self) -> Result<u64, RepositoryError>;
624
625    /// Bulk create entities
626    async fn bulk_create(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError>;
627
628    /// Permanently delete all soft-deleted entities
629    async fn empty_trash(&self) -> Result<u64, RepositoryError>;
630
631    /// List entities with pagination and filters (excluding soft-deleted)
632    ///
633    /// Default implementation ignores filters and delegates to `list()`.
634    /// Override in repository implementations that support filter pushdown.
635    async fn list_filtered(
636        &self,
637        page: u32,
638        limit: u32,
639        filters: HashMap<String, String>,
640    ) -> Result<(Vec<E>, u64), RepositoryError> {
641        let _ = filters; // ignored by default
642        self.list(page, limit).await
643    }
644
645    /// `list_filtered`, carrying the pagination info (cursor positions
646    /// included) so the HTTP layer can surface keyset paging. Default: the
647    /// tuple form with the cursor fields absent; the generated Postgres
648    /// repositories override this with the real keyset walk.
649    async fn list_filtered_with_info(
650        &self,
651        page: u32,
652        limit: u32,
653        filters: HashMap<String, String>,
654    ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), RepositoryError> {
655        let (rows, total) = self.list_filtered(page, limit, filters).await?;
656        Ok((
657            rows,
658            backbone_orm::repository::PaginationInfo::new(page, limit, total),
659        ))
660    }
661
662    /// Group and reduce entities under the same filters as `list_filtered`.
663    ///
664    /// There is deliberately no useful default. A repository that cannot group
665    /// must SAY so: an honest error tells the caller its chart is unavailable,
666    /// whereas a default returning zeros would render an empty chart that looks
667    /// like a real answer about a real, empty table. The Postgres-backed
668    /// generated repositories override this via `impl_crud_repository!`.
669    /// The schema-qualified table this repository reads, when it knows it.
670    ///
671    /// The Postgres-backed generated repositories override this; anything that
672    /// cannot name a table returns `None` and simply has no history.
673    fn table_name(&self) -> Option<&str> {
674        None
675    }
676
677    async fn aggregate_filtered(
678        &self,
679        spec: &backbone_orm::repository::AggregateSpec,
680        filters: HashMap<String, String>,
681    ) -> Result<backbone_orm::repository::AggregateResult, RepositoryError> {
682        let _ = (spec, filters);
683        Err(RepositoryError::DatabaseError(
684            "aggregate is not supported by this repository".to_string(),
685        ))
686    }
687
688    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON
689    /// (raw `row_to_json`). `table` comes from `EntityRepoMeta::relations()`
690    /// (generator-emitted), never client input. Default: no expansion. The
691    /// Postgres-backed generated repos override this via `impl_crud_repository!`.
692    async fn fetch_related_json(
693        &self,
694        _table: &str,
695        _ids: &[String],
696    ) -> Vec<serde_json::Value> {
697        Vec::new()
698    }
699
700    /// Check if an entity exists by ID
701    async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
702        Ok(self.find_by_id(id).await?.is_some())
703    }
704
705    // ── Atomic batch operations ───────────────────────────────────────────────
706    //
707    // The default implementations loop over the single-row methods and are NOT
708    // transactional — they exist so non-Postgres implementors (mocks, custom
709    // adapters) keep compiling. The macro-generated repositories override these
710    // (via `impl_crud_repository!`) with truly atomic, single-transaction
711    // versions backed by `GenericCrudRepository`.
712
713    /// Soft-delete many entities by id. Returns the number affected.
714    async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
715        let mut n = 0;
716        for id in ids {
717            if self.soft_delete(id).await? {
718                n += 1;
719            }
720        }
721        Ok(n)
722    }
723
724    /// Restore many soft-deleted entities by id. Returns the restored entities.
725    async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<E>, RepositoryError> {
726        let mut out = Vec::with_capacity(ids.len());
727        for id in ids {
728            if let Some(e) = self.restore(id).await? {
729                out.push(e);
730            }
731        }
732        Ok(out)
733    }
734
735    /// Permanently delete many entities by id. Returns the number affected.
736    async fn bulk_hard_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
737        let mut n = 0;
738        for id in ids {
739            if self.hard_delete(id).await? {
740                n += 1;
741            }
742        }
743        Ok(n)
744    }
745
746    /// Restore every soft-deleted entity. Returns the restored entities.
747    async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
748        let mut restored = Vec::new();
749        // Restoring removes rows from the deleted set, so page 1 always returns
750        // the next batch of still-deleted rows until none remain.
751        loop {
752            let (batch, _) = self.list_deleted(1, 500).await?;
753            if batch.is_empty() {
754                break;
755            }
756            for entity in &batch {
757                if let Some(e) = self.restore(&entity.entity_id()).await? {
758                    restored.push(e);
759                }
760            }
761        }
762        Ok(restored)
763    }
764
765    /// Update many entities atomically. Returns the updated entities.
766    async fn bulk_update(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError> {
767        let mut out = Vec::with_capacity(entities.len());
768        for entity in entities {
769            out.push(self.update(entity).await?);
770        }
771        Ok(out)
772    }
773}
774
775/// Extended repository with search/filter capabilities
776#[async_trait]
777pub trait SearchableRepository<E>: CrudRepository<E>
778where
779    E: PersistentEntity,
780{
781    /// Search entities with filters
782    async fn search(
783        &self,
784        filters: HashMap<String, String>,
785        page: u32,
786        limit: u32,
787    ) -> Result<(Vec<E>, u64), RepositoryError>;
788
789    /// Find by a specific field value
790    async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
791
792    /// Find all by a specific field value
793    async fn find_all_by_field(
794        &self,
795        field: &str,
796        value: &str,
797        page: u32,
798        limit: u32,
799    ) -> Result<(Vec<E>, u64), RepositoryError>;
800}
801
802// ============================================================
803// PostgreSQL-Specific Traits
804// ============================================================
805
806#[cfg(feature = "postgres")]
807pub use postgres_traits::*;
808
809#[cfg(feature = "postgres")]
810mod postgres_traits {
811    use super::*;
812    use sqlx::postgres::PgRow;
813    use sqlx::FromRow;
814
815    /// Trait for mapping entities to/from PostgreSQL rows
816    ///
817    /// Implement this trait to enable automatic PostgreSQL persistence for your entity.
818    ///
819    /// # Example
820    ///
821    /// ```ignore
822    /// use backbone_core::persistence::{PostgresEntity, PersistentEntity};
823    /// use sqlx::FromRow;
824    ///
825    /// #[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
826    /// struct User {
827    ///     id: String,
828    ///     name: String,
829    ///     email: String,
830    ///     created_at: Option<DateTime<Utc>>,
831    ///     updated_at: Option<DateTime<Utc>>,
832    ///     deleted_at: Option<DateTime<Utc>>,
833    /// }
834    ///
835    /// impl PostgresEntity for User {
836    ///     fn table_name() -> &'static str { "users" }
837    ///     fn select_columns() -> &'static [&'static str] {
838    ///         &["id", "name", "email", "created_at", "updated_at", "deleted_at"]
839    ///     }
840    ///     fn insert_columns() -> &'static [&'static str] {
841    ///         &["id", "name", "email", "created_at", "updated_at"]
842    ///     }
843    ///     fn update_columns() -> &'static [&'static str] {
844    ///         &["name", "email", "updated_at"]
845    ///     }
846    ///     fn bind_for_insert(entity: &Self, query: Query<'_, ...>) -> Query<'_, ...> {
847    ///         query.bind(&entity.id).bind(&entity.name).bind(&entity.email)
848    ///             .bind(&entity.created_at).bind(&entity.updated_at)
849    ///     }
850    /// }
851    /// ```
852    pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
853        /// Table name for this entity
854        fn table_name() -> &'static str;
855
856        /// Primary key column name (default: "id")
857        fn id_column() -> &'static str {
858            "id"
859        }
860
861        /// Column names for SELECT queries (excluding computed columns)
862        fn select_columns() -> &'static [&'static str];
863
864        /// Column names for INSERT (all columns that should be inserted)
865        fn insert_columns() -> &'static [&'static str];
866
867        /// Column names for UPDATE (columns that can be updated, excluding id)
868        fn update_columns() -> &'static [&'static str] {
869            Self::insert_columns()
870        }
871
872        /// Bind entity values to a query for INSERT
873        ///
874        /// The order must match insert_columns()
875        fn bind_for_insert<'q>(
876            entity: &'q Self,
877            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
878        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
879
880        /// Bind entity values to a query for UPDATE
881        ///
882        /// The order must match update_columns()
883        fn bind_for_update<'q>(
884            entity: &'q Self,
885            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
886        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
887            // Default: same as insert (override if different)
888            Self::bind_for_insert(entity, query)
889        }
890
891        /// Build SELECT by ID query
892        fn select_by_id_query() -> String {
893            format!(
894                "SELECT {} FROM {} WHERE {} = $1 AND deleted_at IS NULL",
895                Self::select_columns().join(", "),
896                Self::table_name(),
897                Self::id_column()
898            )
899        }
900
901        /// Build SELECT by ID including deleted
902        fn select_by_id_including_deleted_query() -> String {
903            format!(
904                "SELECT {} FROM {} WHERE {} = $1",
905                Self::select_columns().join(", "),
906                Self::table_name(),
907                Self::id_column()
908            )
909        }
910
911        /// Build paginated list query
912        fn list_query() -> String {
913            format!(
914                "SELECT {} FROM {} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
915                Self::select_columns().join(", "),
916                Self::table_name()
917            )
918        }
919
920        /// Build count query
921        fn count_query() -> String {
922            format!(
923                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
924                Self::table_name()
925            )
926        }
927
928        /// Build list deleted (trash) query
929        fn list_deleted_query() -> String {
930            format!(
931                "SELECT {} FROM {} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2",
932                Self::select_columns().join(", "),
933                Self::table_name()
934            )
935        }
936
937        /// Build count deleted query
938        fn count_deleted_query() -> String {
939            format!(
940                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NOT NULL",
941                Self::table_name()
942            )
943        }
944
945        /// Build soft delete query
946        fn soft_delete_query() -> String {
947            format!(
948                "UPDATE {} SET deleted_at = NOW(), updated_at = NOW() WHERE {} = $1 AND deleted_at IS NULL",
949                Self::table_name(),
950                Self::id_column()
951            )
952        }
953
954        /// Build restore query
955        fn restore_query() -> String {
956            format!(
957                "UPDATE {} SET deleted_at = NULL, updated_at = NOW() WHERE {} = $1 AND deleted_at IS NOT NULL RETURNING {}",
958                Self::table_name(),
959                Self::id_column(),
960                Self::select_columns().join(", ")
961            )
962        }
963
964        /// Build hard delete query
965        fn hard_delete_query() -> String {
966            format!(
967                "DELETE FROM {} WHERE {} = $1",
968                Self::table_name(),
969                Self::id_column()
970            )
971        }
972
973        /// Build empty trash query
974        fn empty_trash_query() -> String {
975            format!(
976                "DELETE FROM {} WHERE deleted_at IS NOT NULL",
977                Self::table_name()
978            )
979        }
980    }
981}