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