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                if let Some(v) = scope_refusal(db_err.code().as_deref(), db_err.message()) {
114                    return RepositoryError::Violations(vec![v]);
115                }
116                let pg = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>();
117                if let Some(v) = constraint_violation(
118                    db_err.kind(),
119                    db_err.code().as_deref(),
120                    db_err.constraint(),
121                    pg.and_then(|p| p.detail()).unwrap_or(""),
122                    pg.and_then(|p| p.column()),
123                    pg.and_then(|p| p.table()),
124                ) {
125                    return RepositoryError::Violations(vec![v]);
126                }
127                let msg = db_err.message().to_string();
128                if msg.contains("duplicate key") || msg.contains("unique constraint") {
129                    // Never the raw message: it names the index.
130                    RepositoryError::Violations(vec![taken_violation("", "taken")])
131                } else {
132                    RepositoryError::DatabaseError(msg)
133                }
134            }
135            _ => RepositoryError::DatabaseError(e.to_string()),
136        }
137    }
138}
139
140/// The violation a row-level security refusal stands for: a write whose new row the
141/// table's policy does not admit (`42501` with the policy sentence), which is how a
142/// forged `company_id` or `org_unit_id` is refused. It answers `scope_refused` with no
143/// path, since Postgres names neither the policy's column nor the value, and a sentence
144/// that names no table or policy. Any other `42501` (a missing grant) is a server fault
145/// and stays one.
146#[cfg(feature = "postgres")]
147fn scope_refusal(sqlstate: Option<&str>, message: &str) -> Option<crate::violation::Violation> {
148    (sqlstate == Some("42501") && message.contains("row-level security policy")).then(|| {
149        crate::violation::Violation::new(
150            "",
151            "scope_refused",
152            "The record is outside the company or unit this request may write to",
153        )
154    })
155}
156
157/// The `taken` answer for a unique key, on `path` when it is known.
158#[cfg(feature = "postgres")]
159fn taken_violation(path: &str, code: &str) -> crate::violation::Violation {
160    let message = match path {
161        "" => "This value is already taken by another record".to_string(),
162        p => format!("{p} is already taken by another record"),
163    };
164    crate::violation::Violation::new(path, code, message)
165}
166
167/// The violation a database constraint error stands for, or `None` for an error
168/// that names no rule. A foreign key answers `reference_missing` on the column
169/// that names a missing record, or `still_referenced` when a removed record is
170/// still pointed at. A NOT NULL answers `required` on its column, worded as the
171/// application-side required check is. A CHECK and an exclusion answer the code
172/// their constraint name carries (see [`crate::violation::code_from_constraint`]).
173/// Every unique key answers too: the code its generated name carries
174/// (`uq_…__<code>`), or `taken` for any other name, which is never repeated.
175///
176/// The `code` stays machine-readable for clients to translate; the sentence is
177/// for a person, so it never names a table, a constraint or a rule identifier.
178/// `table` is the table Postgres reports, used to read the column out of a
179/// generated constraint's name (see [`constraint_column`]) when Postgres reports
180/// none: a CHECK never carries one, and on a table with row-level security
181/// Postgres leaves the `Key (…)` detail out of unique and foreign-key errors too.
182#[cfg(feature = "postgres")]
183fn constraint_violation(
184    kind: sqlx::error::ErrorKind,
185    sqlstate: Option<&str>,
186    constraint: Option<&str>,
187    detail: &str,
188    column: Option<&str>,
189    table: Option<&str>,
190) -> Option<crate::violation::Violation> {
191    use crate::violation::{code_from_constraint, Violation};
192    use sqlx::error::ErrorKind;
193    if kind == ErrorKind::NotNullViolation || sqlstate == Some("23502") {
194        // 23502 names no constraint, only the column.
195        let column = column.filter(|c| !c.is_empty())?;
196        return Some(Violation::new(
197            column,
198            "required",
199            format!("{column} is required"),
200        ));
201    }
202    if kind == ErrorKind::UniqueViolation || sqlstate == Some("23505") {
203        let generated = constraint.and_then(generated_code);
204        let path = column
205            .filter(|c| !c.is_empty())
206            .map(str::to_string)
207            .or_else(|| key_column(detail))
208            .or_else(|| {
209                let (c, t) = (constraint?, table?);
210                constraint_column(c, t, generated.as_deref().unwrap_or(""))
211            })
212            .unwrap_or_default();
213        return Some(taken_violation(&path, generated.as_deref().unwrap_or("taken")));
214    }
215    let constraint = constraint?;
216    let code = code_from_constraint(constraint);
217    let path = column
218        .filter(|c| !c.is_empty())
219        .map(str::to_string)
220        .or_else(|| key_column(detail))
221        .or_else(|| table.and_then(|t| constraint_column(constraint, t, &code)))
222        .unwrap_or_default();
223    Some(match kind {
224        ErrorKind::ForeignKeyViolation if detail.contains("still referenced") => Violation::new(
225            "",
226            "still_referenced",
227            "This record is still referenced by another record, so it cannot be removed",
228        ),
229        ErrorKind::ForeignKeyViolation => {
230            let message = match path.as_str() {
231                "" => "The record refers to a record that does not exist".to_string(),
232                p => format!("{p} refers to a record that does not exist"),
233            };
234            Violation::new(path, "reference_missing", message)
235        }
236        ErrorKind::CheckViolation => {
237            let message = check_sentence(&path, &code);
238            Violation::new(path, code, message)
239        }
240        _ if sqlstate == Some("23P01") => Violation::new(
241            "",
242            code,
243            "The record overlaps another one it may not overlap",
244        ),
245        _ => return None,
246    })
247}
248
249/// A person's sentence for a broken CHECK, keyed by its rule code. The generated
250/// field checks' codes read as the application-side check does (without the
251/// bound itself, which the constraint error does not carry); any other code
252/// gets a neutral sentence rather than its identifier.
253#[cfg(feature = "postgres")]
254fn check_sentence(path: &str, code: &str) -> String {
255    let field = if path.is_empty() {
256        "A value".to_string()
257    } else {
258        path.to_string()
259    };
260    let predicate = match code {
261        "required" => "is required",
262        "max_length" => "is too long",
263        "min_length" => "is too short",
264        "length" => "does not have the required length",
265        "max" => "is too large",
266        "min" => "is too small",
267        "range" => "is out of the allowed range",
268        "positive" => "must be positive",
269        "non_negative" => "must not be negative",
270        "pattern" => "does not have the expected form",
271        "alpha_dash" => "may hold only letters, digits, '_' and '-'",
272        "alpha_num" => "may hold only letters and digits",
273        "values" => "is not one of the allowed values",
274        "email" => "must be an email address",
275        "url" => "must be an absolute URL",
276        "state_transition_not_declared" => "cannot move to that state from its current one",
277        _ if path.is_empty() => return "The record does not satisfy one of its rules".to_string(),
278        _ => "does not satisfy one of its rules",
279    };
280    format!("{field} {predicate}")
281}
282
283/// The code a generated constraint name carries after its `__`, or `None` for a name
284/// the generator did not write (which must never reach a client as a code).
285#[cfg(feature = "postgres")]
286fn generated_code(constraint: &str) -> Option<String> {
287    let code = crate::violation::code_from_constraint(constraint);
288    (code != constraint).then_some(code)
289}
290
291/// Tenant keys a generated unique index names before its field, in key order.
292#[cfg(feature = "postgres")]
293const TENANT_KEYS: &[&str] = &["company_id", "tenant_id", "org_unit_id", "organization_id"];
294
295/// The field a generated constraint concerns, read out of its name; `table` is the bare
296/// table Postgres reports. The schema generator names a constraint
297/// `<prefix>_<table>_<segment>__<code>` (`constraint_name` in metaphor-plugin-schema's
298/// `generators/sql.rs`), and the segment names the field:
299///
300/// - a field check, `ck_<table>_<field>_<code>__<code>`
301///   (`ck_tickets_title_max_length__max_length` → `title`);
302/// - a row rule, `ck_<table>_<field>__<code>`, on the field its guard reports
303///   (`ck_projects_kind__website_required_for_client` → `kind`);
304/// - a unique index, `uq_<table>_[<tenant keys>_]<field>__<code>`
305///   (`uq_projects_contact_email__unique` → `contact_email`,
306///   `uq_projects_company_id_code__code_taken` → `code`);
307/// - a foreign key, `fk_<table>_<column>` (or `fk_<table>_<column>_fk` on a link table).
308///
309/// Read back: the part between the prefix and the first `__`, less `<table>_`; a name too
310/// long for Postgres, whose head the generator ends in `_<8 hex digits>`, names no field;
311/// then a trailing `_<code>` is dropped, and leading tenant keys while something remains.
312#[cfg(feature = "postgres")]
313fn constraint_column(constraint: &str, table: &str, code: &str) -> Option<String> {
314    let ident = |s: &str| {
315        (!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'))
316            .then(|| s.to_string())
317    };
318    if let Some(rest) = constraint.strip_prefix("fk_") {
319        let column = rest.strip_prefix(table)?.strip_prefix('_')?;
320        return ident(column.strip_suffix("_fk").unwrap_or(column));
321    }
322    let head = ["ck_", "uq_", "ex_", "tg_"]
323        .iter()
324        .find_map(|p| constraint.strip_prefix(p))?
325        .split_once("__")?
326        .0;
327    if ends_in_digest(head) {
328        return None;
329    }
330    let mut rest = head.strip_prefix(table)?.strip_prefix('_')?;
331    if !code.is_empty() {
332        if let Some(field) = rest.strip_suffix(code).and_then(|f| f.strip_suffix('_')) {
333            if !field.is_empty() {
334                rest = field;
335            }
336        }
337    }
338    while let Some(next) = TENANT_KEYS.iter().find_map(|key| {
339        rest.strip_prefix(key)?.strip_prefix('_').filter(|r| !r.is_empty())
340    }) {
341        rest = next;
342    }
343    ident(rest)
344}
345
346/// Whether a constraint name's head ends in the `_<8 hex digits>` digest the generator puts on
347/// a name too long for Postgres.
348#[cfg(feature = "postgres")]
349fn ends_in_digest(head: &str) -> bool {
350    let bytes = head.as_bytes();
351    bytes.len() > 9
352        && bytes[bytes.len() - 9] == b'_'
353        && bytes[bytes.len() - 8..].iter().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
354}
355
356/// Columns a unique key carries for scoping, not as the value a person chose.
357#[cfg(feature = "postgres")]
358const SCOPE_COLUMNS: &[&str] = &[
359    "company_id",
360    "tenant_id",
361    "org_unit_id",
362    "organization_id",
363    "deleted_at",
364];
365
366/// The column a constraint error's detail names (`Key (district_id)=(…)`). For a
367/// composite key (`Key (company_id, code)=(…)`) it is the last column that is not
368/// a tenant/scope column, read through an expression such as `lower(code::text)`.
369#[cfg(feature = "postgres")]
370fn key_column(detail: &str) -> Option<String> {
371    let rest = detail.strip_prefix("Key (")?;
372    let cols = &rest[..rest.find(")=")?];
373    let names: Vec<String> = cols
374        .split(',')
375        .filter_map(|part| {
376            // The first identifier inside an expression: `lower(sku::text)` → `sku`.
377            let inner = part.rsplit('(').next().unwrap_or(part);
378            let ident: String = inner
379                .trim()
380                .trim_matches('"')
381                .chars()
382                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
383                .collect();
384            (!ident.is_empty()).then_some(ident)
385        })
386        .collect();
387    names
388        .iter()
389        .rev()
390        .find(|n| !SCOPE_COLUMNS.contains(&n.as_str()))
391        .or_else(|| (names.len() == 1).then(|| &names[0]))
392        .cloned()
393}
394
395#[cfg(all(test, feature = "postgres"))]
396mod constraint_violation_tests {
397    use super::*;
398    use sqlx::error::ErrorKind;
399
400    #[test]
401    fn a_missing_reference_names_the_column_and_never_the_table() {
402        let v = constraint_violation(
403            ErrorKind::ForeignKeyViolation,
404            Some("23503"),
405            Some("fk_subdistricts_district_id"),
406            "Key (district_id)=(1484f475-14be-4abc-985b-7291543d32bc) is not present in table \"districts\".",
407            None,
408            None,
409        )
410        .unwrap();
411        assert_eq!((v.path.as_str(), v.code.as_str()), ("district_id", "reference_missing"));
412        assert!(!v.message.contains("districts\"") && !v.message.contains("fk_"), "{}", v.message);
413    }
414
415    #[test]
416    fn removing_a_referenced_record_is_still_referenced() {
417        let v = constraint_violation(
418            ErrorKind::ForeignKeyViolation,
419            Some("23503"),
420            Some("fk_provinces_country_id"),
421            "Key (id)=(866f1b4e-a9f3-4626-95d1-13c183916b4b) is still referenced from table \"provinces\".",
422            None,
423            None,
424        )
425        .unwrap();
426        assert_eq!((v.path.as_str(), v.code.as_str()), ("", "still_referenced"));
427    }
428
429    #[test]
430    fn a_rule_constraint_answers_its_rules_code() {
431        // Postgres reports no column for a CHECK; the generator's name carries it.
432        let check = constraint_violation(ErrorKind::CheckViolation, Some("23514"), Some("ck_countries_name_max_length__max_length"), "", None, Some("countries")).unwrap();
433        assert_eq!((check.path.as_str(), check.code.as_str()), ("name", "max_length"));
434        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();
435        assert_eq!((unique.path.as_str(), unique.code.as_str()), ("code", "account_code_taken"));
436        let overlap = constraint_violation(ErrorKind::Other, Some("23P01"), Some("ex_tax_rates__overlapping_validity"), "", None, Some("tax_rates")).unwrap();
437        assert_eq!(overlap.code, "overlapping_validity");
438    }
439
440    #[test]
441    fn a_missing_required_value_is_a_required_violation_on_its_column() {
442        // 23502 names no constraint, only the column.
443        let v = constraint_violation(
444            ErrorKind::NotNullViolation,
445            Some("23502"),
446            None,
447            "",
448            Some("employee_id"),
449            Some("payslips"),
450        )
451        .unwrap();
452        assert_eq!(
453            (v.path.as_str(), v.code.as_str()),
454            ("employee_id", "required")
455        );
456        assert_eq!(v.message, "employee_id is required");
457    }
458
459    #[test]
460    fn a_composite_unique_key_names_its_own_column_not_the_tenant() {
461        let v = constraint_violation(
462            ErrorKind::UniqueViolation,
463            Some("23505"),
464            Some("uq_accounts__code_taken"),
465            "Key (company_id, code)=(7c0a…, 1100) already exists.",
466            None,
467            Some("accounts"),
468        )
469        .unwrap();
470        assert_eq!(v.path, "code");
471        assert!(
472            v.message.starts_with("code "),
473            "a sentence with a subject: {}",
474            v.message
475        );
476        let scoped = constraint_violation(
477            ErrorKind::UniqueViolation,
478            Some("23505"),
479            Some("uq_items__sku_taken"),
480            "Key (org_unit_id, lower(sku::text))=(a, b) already exists.",
481            None,
482            Some("items"),
483        )
484        .unwrap();
485        assert_eq!(scoped.path, "sku");
486        let bare = constraint_violation(
487            ErrorKind::UniqueViolation,
488            Some("23505"),
489            Some("uq_x__taken"),
490            "",
491            None,
492            None,
493        )
494        .unwrap();
495        assert_eq!(bare.path, "");
496        assert!(
497            bare.message.starts_with(char::is_uppercase),
498            "{}",
499            bare.message
500        );
501    }
502
503    #[test]
504    fn a_check_reads_as_a_sentence_never_as_the_rule_identifier() {
505        let v = constraint_violation(
506            ErrorKind::CheckViolation,
507            Some("23514"),
508            Some("ck_tickets_title_max_length__max_length"),
509            "",
510            None,
511            Some("tickets"),
512        )
513        .unwrap();
514        assert_eq!(v.code, "max_length", "the code stays machine-readable");
515        assert!(
516            !v.message.contains('`') && !v.message.contains("max_length"),
517            "{}",
518            v.message
519        );
520        assert!(v.message.starts_with("title "), "{}", v.message);
521        // A rule whose name was shortened to a digest names no column: a whole-record
522        // sentence, still without identifiers.
523        let row = constraint_violation(
524            ErrorKind::CheckViolation,
525            Some("23514"),
526            Some("ck_journals_lines_must_balance_1f2e3d4c__unbalanced"),
527            "",
528            None,
529            Some("journals"),
530        )
531        .unwrap();
532        assert_eq!((row.path.as_str(), row.code.as_str()), ("", "unbalanced"));
533        assert!(
534            !row.message.contains("unbalanced") && !row.message.contains('`'),
535            "{}",
536            row.message
537        );
538        // A hand-written trigger sets the column itself.
539        let tg = constraint_violation(
540            ErrorKind::CheckViolation,
541            Some("23514"),
542            Some("tg_journals__state_transition_not_declared"),
543            "",
544            Some("status"),
545            Some("journals"),
546        )
547        .unwrap();
548        assert_eq!(
549            (tg.path.as_str(), tg.code.as_str()),
550            ("status", "state_transition_not_declared")
551        );
552        assert!(
553            !tg.message.contains("state_transition_not_declared"),
554            "{}",
555            tg.message
556        );
557    }
558
559    #[test]
560    fn a_database_error_inside_any_error_converts_as_the_database_error_does() {
561        let wrapped = anyhow::Error::new(sqlx::Error::RowNotFound).context("loading the row");
562        assert!(matches!(RepositoryError::from_source(wrapped), RepositoryError::NotFound));
563        assert!(matches!(RepositoryError::from_source(sqlx::Error::RowNotFound), RepositoryError::NotFound));
564        let other = RepositoryError::from_source(anyhow::anyhow!("pool closed"));
565        assert!(matches!(other, RepositoryError::DatabaseError(ref m) if m == "pool closed"), "{other:?}");
566    }
567
568    #[test]
569    fn every_unique_key_answers_taken_and_never_names_its_index() {
570        // A name the generator did not write: `taken`, the path from the detail when given.
571        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("users_email_key"), "Key (email)=(a@b.c) already exists.", None, Some("users")).unwrap();
572        assert_eq!((v.path.as_str(), v.code.as_str()), ("email", "taken"));
573        assert!(!v.message.contains("users_email_key"), "{}", v.message);
574        // RLS tables leave the detail out: a legacy `idx_` name yields no path, still coded.
575        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("idx_projects_contact_email"), "", None, Some("projects")).unwrap();
576        assert_eq!((v.path.as_str(), v.code.as_str()), ("", "taken"));
577        assert!(!v.message.contains("idx_"), "{}", v.message);
578        // The generated name carries the path when the detail is absent: `@unique` …
579        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_projects_contact_email__unique"), "", None, Some("projects")).unwrap();
580        assert_eq!((v.path.as_str(), v.code.as_str()), ("contact_email", "unique"));
581        assert!(!v.message.contains("uq_"), "{}", v.message);
582        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_tickets_title__unique"), "", None, Some("tickets")).unwrap();
583        assert_eq!((v.path.as_str(), v.code.as_str()), ("title", "unique"));
584        // … a declared uniqueness rule, its tenant key dropped …
585        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_projects_company_id_code__code_taken"), "", None, Some("projects")).unwrap();
586        assert_eq!((v.path.as_str(), v.code.as_str()), ("code", "code_taken"));
587        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_items_company_id_org_unit_id_sku__unique"), "", None, Some("items")).unwrap();
588        assert_eq!(v.path, "sku");
589        // … a field that is itself a tenant key stays when nothing else remains.
590        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_members_org_unit_id__unique"), "", None, Some("members")).unwrap();
591        assert_eq!(v.path, "org_unit_id");
592        // A digest-shortened name names no field.
593        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_projects_company_id_some_long_fiel_0a1b2c3d__code_taken"), "", None, Some("projects")).unwrap();
594        assert_eq!((v.path.as_str(), v.code.as_str()), ("", "code_taken"));
595        // The service-owned tenancy index, named without `__`: `taken`, never its name.
596        let v = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_tickets_org_unit_id_reference"), "", None, Some("tickets")).unwrap();
597        assert_eq!((v.path.as_str(), v.code.as_str()), ("", "taken"));
598        assert!(!v.message.contains("uq_"), "{}", v.message);
599    }
600
601    #[test]
602    fn a_foreign_key_on_a_table_with_rls_names_its_column_from_the_constraint() {
603        // Postgres omits `Key (…)` when the table has row-level security.
604        let v = constraint_violation(ErrorKind::ForeignKeyViolation, Some("23503"), Some("fk_tickets_project_id"), "", None, Some("tickets")).unwrap();
605        assert_eq!((v.path.as_str(), v.code.as_str()), ("project_id", "reference_missing"));
606        assert!(!v.message.contains("fk_"), "{}", v.message);
607        let link = constraint_violation(ErrorKind::ForeignKeyViolation, Some("23503"), Some("fk_user_roles_role_id_fk"), "", None, Some("user_roles")).unwrap();
608        assert_eq!(link.path, "role_id");
609        // Another table's key does not parse as this table's.
610        let other = constraint_violation(ErrorKind::ForeignKeyViolation, Some("23503"), Some("fk_other_project_id"), "", None, Some("tickets")).unwrap();
611        assert_eq!(other.path, "");
612    }
613
614    #[test]
615    fn a_rule_check_about_a_field_answers_on_that_field() {
616        let v = constraint_violation(ErrorKind::CheckViolation, Some("23514"), Some("ck_projects_kind__website_required_for_client"), "", None, Some("projects")).unwrap();
617        assert_eq!((v.path.as_str(), v.code.as_str()), ("kind", "website_required_for_client"));
618        assert!(!v.message.contains("ck_") && !v.message.contains("website_required"), "{}", v.message);
619        // A field check keeps reading as one.
620        let v = constraint_violation(ErrorKind::CheckViolation, Some("23514"), Some("ck_shifts_call_note_max_length__max_length"), "", None, Some("shifts")).unwrap();
621        assert_eq!(v.path, "call_note");
622        // A digest-shortened rule name keeps no path.
623        let v = constraint_violation(ErrorKind::CheckViolation, Some("23514"), Some("ck_projects_a_very_long_field_name_dead00ff__website_required_for_client"), "", None, Some("projects")).unwrap();
624        assert_eq!((v.path.as_str(), v.code.as_str()), ("", "website_required_for_client"));
625    }
626
627    #[test]
628    fn a_row_security_refusal_is_scope_refused_and_a_missing_grant_is_not() {
629        let v = scope_refusal(Some("42501"), "new row violates row-level security policy for table \"projects\"").unwrap();
630        assert_eq!((v.path.as_str(), v.code.as_str()), ("", "scope_refused"));
631        assert!(!v.message.contains("projects") && !v.message.contains("policy"), "{}", v.message);
632        assert!(scope_refusal(Some("42501"), "permission denied for table projects").is_none());
633        assert!(scope_refusal(Some("23505"), "row-level security policy").is_none());
634    }
635
636    #[test]
637    fn an_error_that_names_no_rule_keeps_its_old_answer() {
638        assert!(
639            constraint_violation(ErrorKind::Other, Some("42P01"), None, "", None, None).is_none()
640        );
641        // A not-null error without a column says nothing a person could act on.
642        assert!(constraint_violation(
643            ErrorKind::NotNullViolation,
644            Some("23502"),
645            None,
646            "",
647            None,
648            None
649        )
650        .is_none());
651    }
652}
653
654// ============================================================
655// Entity Traits
656// ============================================================
657
658/// A foreign key one of an entity's fields holds: `field` (the entity's serialized
659/// field, which is also its column) names a row of `table` by its `column`.
660///
661/// The generic service checks, before a write that sets the field, that the row it
662/// names is one the caller can see (see [`PersistentEntity::references`]): Postgres
663/// checks a foreign key without row-level security, so without this a request scoped
664/// to one company could point a record at another company's row.
665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666pub struct Reference {
667    /// The referencing field, by its serialized (snake_case) name; also the column.
668    pub field: &'static str,
669    /// The referenced table, bare (resolved in the referencing table's schema first)
670    /// or schema-qualified.
671    pub table: &'static str,
672    /// The referenced column, usually `id`.
673    pub column: &'static str,
674}
675
676impl Reference {
677    /// A reference from `field` to `table`.`column`.
678    pub const fn new(field: &'static str, table: &'static str, column: &'static str) -> Self {
679        Self { field, table, column }
680    }
681}
682
683/// Trait for entities that can be persisted.
684///
685/// This trait defines the common fields and behavior required by all entities
686/// that can be stored and retrieved from a repository.
687pub trait PersistentEntity: Clone + Send + Sync + Debug + Serialize + DeserializeOwned + 'static {
688    /// Get the entity's unique identifier
689    fn entity_id(&self) -> String;
690
691    /// Set the entity's unique identifier
692    fn set_entity_id(&mut self, id: String);
693
694    /// Get creation timestamp
695    fn created_at(&self) -> Option<DateTime<Utc>>;
696
697    /// Set creation timestamp
698    fn set_created_at(&mut self, ts: DateTime<Utc>);
699
700    /// Get last update timestamp
701    fn updated_at(&self) -> Option<DateTime<Utc>>;
702
703    /// Set last update timestamp
704    fn set_updated_at(&mut self, ts: DateTime<Utc>);
705
706    /// Get soft delete timestamp (None if not deleted)
707    fn deleted_at(&self) -> Option<DateTime<Utc>>;
708
709    /// Set soft delete timestamp
710    fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>);
711
712    /// Check if entity is soft-deleted
713    fn is_deleted(&self) -> bool {
714        self.deleted_at().is_some()
715    }
716
717    /// Mark entity as deleted (soft delete)
718    fn mark_deleted(&mut self) {
719        self.set_deleted_at(Some(Utc::now()));
720        self.set_updated_at(Utc::now());
721    }
722
723    /// Restore a soft-deleted entity
724    fn restore(&mut self) {
725        self.set_deleted_at(None);
726        self.set_updated_at(Utc::now());
727    }
728
729    /// Touch the entity (update timestamp)
730    fn touch(&mut self) {
731        self.set_updated_at(Utc::now());
732    }
733
734    /// Generate a new ID for this entity type
735    fn generate_id() -> String {
736        uuid::Uuid::new_v4().to_string()
737    }
738
739    /// Fields that a generic write (PUT, PATCH and their bulk forms) may not
740    /// change, by their serialized name.
741    ///
742    /// The generic service refuses a write that would give one of these a new
743    /// value, and still accepts a write that carries the stored value
744    /// unchanged, so a form that sends the whole record keeps working. Code
745    /// that owns the field — a verb, a write service — changes it through its
746    /// own path. `id` and `metadata` are refused for every entity regardless
747    /// of this list. The schema generator fills it from the fields the schema
748    /// declares as not freely writable; the default protects nothing more.
749    fn write_protected_fields() -> &'static [&'static str] {
750        &[]
751    }
752
753    /// Fields a state machine owns, by their serialized name: they move only through the
754    /// machine's verbs.
755    ///
756    /// A state field is usually also in [`Self::write_protected_fields`], which refuses it to
757    /// every generic write. A verb's own write (one running inside
758    /// [`as_transition`](crate::write_guard::as_transition)) may still move these fields — and
759    /// only these: every other protected field stays refused inside a transition too. The
760    /// schema generator fills it from the entity's state machines; the default is none, which
761    /// leaves a protected state field refused to verbs as well.
762    fn state_fields() -> &'static [&'static str] {
763        &[]
764    }
765
766    /// The foreign keys this entity's fields hold.
767    ///
768    /// Before a generic create, PUT, PATCH or bulk write stores a value in one of these
769    /// fields (a create, or a write that changes it), the generic service asks the
770    /// repository whether the row it names is visible to the caller, on the request's own
771    /// connection and scope ([`CrudRepository::reference_visible`]). A row the caller
772    /// cannot see — missing, or fenced off by row-level security — refuses the write with
773    /// a `reference_missing` violation on the field, the same answer a missing row gets, so
774    /// the refusal never tells that the row exists elsewhere. The schema generator fills it
775    /// from the entity's relations as a `const` slice; the default checks nothing beyond
776    /// the database's key.
777    fn references() -> &'static [Reference] {
778        &[]
779    }
780}
781
782/// Trait for entities that support partial updates via field map
783pub trait PartialUpdatable: PersistentEntity {
784    /// Apply partial updates from a field map
785    fn apply_partial_update(&mut self, fields: &HashMap<String, serde_json::Value>) -> Result<(), RepositoryError>;
786}
787
788/// Trait for entities with version/optimistic locking
789pub trait Versioned {
790    fn version(&self) -> u64;
791    fn set_version(&mut self, version: u64);
792    fn increment_version(&mut self) {
793        self.set_version(self.version() + 1);
794    }
795}
796
797// ============================================================
798// Repository Traits
799// ============================================================
800
801/// Core CRUD repository trait
802///
803/// This trait defines the basic CRUD operations that all repositories must implement.
804/// It's designed to work with the `CrudService` trait from the HTTP layer.
805#[async_trait]
806pub trait CrudRepository<E>: Send + Sync
807where
808    E: PersistentEntity,
809{
810    /// Create a new entity
811    async fn create(&self, entity: E) -> Result<E, RepositoryError>;
812
813    /// Find entity by ID (excluding soft-deleted)
814    async fn find_by_id(&self, id: &str) -> Result<Option<E>, RepositoryError>;
815
816    /// Find entity by ID (including soft-deleted, for trash operations)
817    async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<E>, RepositoryError>;
818
819    /// Update an existing entity
820    async fn update(&self, entity: E) -> Result<E, RepositoryError>;
821
822    /// Update an entity from `before` (the row as read) to `after`, writing only the fields
823    /// whose value differs between the two. `Ok(None)` when the row no longer exists or was
824    /// soft-deleted since it was read — the write must never revive it.
825    ///
826    /// The path every generic read-modify-write (PUT, PATCH) takes: a field the caller did not
827    /// change keeps whatever value the database holds now, so a concurrent write to it (a verb
828    /// moving a status) is not overwritten with the stale value read before it. The generated
829    /// Postgres repositories override this with a changed-columns UPDATE guarded by the
830    /// not-deleted predicate. The default falls back to the full [`Self::update`], which a
831    /// repository with no partial write path has to accept.
832    async fn update_changed(&self, before: &E, after: E) -> Result<Option<E>, RepositoryError> {
833        let _ = before;
834        match self.update(after).await {
835            Ok(saved) => Ok(Some(saved)),
836            Err(RepositoryError::NotFound) => Ok(None),
837            Err(e) => Err(e),
838        }
839    }
840
841    /// Soft delete an entity
842    async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError>;
843
844    /// Restore a soft-deleted entity
845    async fn restore(&self, id: &str) -> Result<Option<E>, RepositoryError>;
846
847    /// Permanently delete an entity
848    async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError>;
849
850    /// List entities with pagination (excluding soft-deleted)
851    async fn list(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
852
853    /// List soft-deleted entities with pagination
854    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
855
856    /// List soft-deleted entities with pagination, filters and search — the trash twin of
857    /// [`list_filtered`](Self::list_filtered).
858    ///
859    /// Default implementation ignores filters and delegates to `list_deleted()`. The generated
860    /// repositories override it with the same filter pushdown the active list uses.
861    async fn list_deleted_filtered(
862        &self,
863        page: u32,
864        limit: u32,
865        filters: HashMap<String, String>,
866    ) -> Result<(Vec<E>, u64), RepositoryError> {
867        let _ = filters;
868        self.list_deleted(page, limit).await
869    }
870
871    /// Count all entities (excluding soft-deleted)
872    async fn count(&self) -> Result<u64, RepositoryError>;
873
874    /// Count soft-deleted entities
875    async fn count_deleted(&self) -> Result<u64, RepositoryError>;
876
877    /// Bulk create entities
878    async fn bulk_create(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError>;
879
880    /// Permanently delete all soft-deleted entities
881    async fn empty_trash(&self) -> Result<u64, RepositoryError>;
882
883    /// List entities with pagination and filters (excluding soft-deleted)
884    ///
885    /// Default implementation ignores filters and delegates to `list()`.
886    /// Override in repository implementations that support filter pushdown.
887    async fn list_filtered(
888        &self,
889        page: u32,
890        limit: u32,
891        filters: HashMap<String, String>,
892    ) -> Result<(Vec<E>, u64), RepositoryError> {
893        let _ = filters; // ignored by default
894        self.list(page, limit).await
895    }
896
897    /// `list_filtered`, carrying the pagination info (cursor positions
898    /// included) so the HTTP layer can surface keyset paging. Default: the
899    /// tuple form with the cursor fields absent; the generated Postgres
900    /// repositories override this with the real keyset walk.
901    async fn list_filtered_with_info(
902        &self,
903        page: u32,
904        limit: u32,
905        filters: HashMap<String, String>,
906    ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), RepositoryError> {
907        let (rows, total) = self.list_filtered(page, limit, filters).await?;
908        Ok((
909            rows,
910            backbone_orm::repository::PaginationInfo::new(page, limit, total),
911        ))
912    }
913
914    /// Group and reduce entities under the same filters as `list_filtered`.
915    ///
916    /// There is deliberately no useful default. A repository that cannot group
917    /// must SAY so: an honest error tells the caller its chart is unavailable,
918    /// whereas a default returning zeros would render an empty chart that looks
919    /// like a real answer about a real, empty table. The Postgres-backed
920    /// generated repositories override this via `impl_crud_repository!`.
921    /// The schema-qualified table this repository reads, when it knows it.
922    ///
923    /// The Postgres-backed generated repositories override this; anything that
924    /// cannot name a table returns `None` and simply has no history.
925    fn table_name(&self) -> Option<&str> {
926        None
927    }
928
929    /// The pool this repository writes through, when it has one.
930    ///
931    /// The generic service opens one write transaction on it around each write
932    /// ([`backbone_orm::in_write_tx`]), so the write, its hooks and the events they stage commit
933    /// or roll back together. `None` (the default: an in-memory repository, a mock) leaves the
934    /// write without a transaction of its own. The Postgres-backed generated repositories
935    /// override this via `impl_crud_repository!`.
936    fn write_pool(&self) -> Option<&backbone_orm::PgPool> {
937        None
938    }
939
940    async fn aggregate_filtered(
941        &self,
942        spec: &backbone_orm::repository::AggregateSpec,
943        filters: HashMap<String, String>,
944    ) -> Result<backbone_orm::repository::AggregateResult, RepositoryError> {
945        let _ = (spec, filters);
946        Err(RepositoryError::DatabaseError(
947            "aggregate is not supported by this repository".to_string(),
948        ))
949    }
950
951    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON
952    /// (raw `row_to_json`). `table` comes from `EntityRepoMeta::relations()`
953    /// (generator-emitted), never client input. Default: no expansion. The
954    /// Postgres-backed generated repos override this via `impl_crud_repository!`.
955    async fn fetch_related_json(
956        &self,
957        _table: &str,
958        _ids: &[String],
959    ) -> Vec<serde_json::Value> {
960        Vec::new()
961    }
962
963    /// Whether the row of `table` whose `column` holds `value` is visible to the caller:
964    /// read on the request's own connection and scope, so a row row-level security hides
965    /// counts as absent. `table` comes from [`PersistentEntity::references`]
966    /// (generator-emitted), never from client input.
967    ///
968    /// `Ok(None)` means this repository cannot tell; the write is then left to the
969    /// database's own key. That is the default. The Postgres-backed generated repositories
970    /// override it via `impl_crud_repository!`.
971    async fn reference_visible(
972        &self,
973        table: &str,
974        column: &str,
975        value: &serde_json::Value,
976    ) -> Result<Option<bool>, RepositoryError> {
977        let _ = (table, column, value);
978        Ok(None)
979    }
980
981    /// Check if an entity exists by ID
982    async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
983        Ok(self.find_by_id(id).await?.is_some())
984    }
985
986    // ── Atomic batch operations ───────────────────────────────────────────────
987    //
988    // The default implementations loop over the single-row methods and are NOT
989    // transactional — they exist so non-Postgres implementors (mocks, custom
990    // adapters) keep compiling. The macro-generated repositories override these
991    // (via `impl_crud_repository!`) with truly atomic, single-transaction
992    // versions backed by `GenericCrudRepository`.
993
994    /// Soft-delete many entities by id. Returns the number affected.
995    async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
996        let mut n = 0;
997        for id in ids {
998            if self.soft_delete(id).await? {
999                n += 1;
1000            }
1001        }
1002        Ok(n)
1003    }
1004
1005    /// Restore many soft-deleted entities by id. Returns the restored entities.
1006    async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<E>, RepositoryError> {
1007        let mut out = Vec::with_capacity(ids.len());
1008        for id in ids {
1009            if let Some(e) = self.restore(id).await? {
1010                out.push(e);
1011            }
1012        }
1013        Ok(out)
1014    }
1015
1016    /// Permanently delete many entities by id. Returns the number affected.
1017    async fn bulk_hard_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
1018        let mut n = 0;
1019        for id in ids {
1020            if self.hard_delete(id).await? {
1021                n += 1;
1022            }
1023        }
1024        Ok(n)
1025    }
1026
1027    /// Restore every soft-deleted entity. Returns the restored entities.
1028    async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
1029        let mut restored = Vec::new();
1030        // Restoring removes rows from the deleted set, so page 1 always returns
1031        // the next batch of still-deleted rows until none remain.
1032        loop {
1033            let (batch, _) = self.list_deleted(1, 500).await?;
1034            if batch.is_empty() {
1035                break;
1036            }
1037            for entity in &batch {
1038                if let Some(e) = self.restore(&entity.entity_id()).await? {
1039                    restored.push(e);
1040                }
1041            }
1042        }
1043        Ok(restored)
1044    }
1045
1046    /// Update many entities atomically. Returns the updated entities.
1047    async fn bulk_update(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError> {
1048        let mut out = Vec::with_capacity(entities.len());
1049        for entity in entities {
1050            out.push(self.update(entity).await?);
1051        }
1052        Ok(out)
1053    }
1054
1055    /// Update many entities from `(before, after)` pairs, writing for each only the fields that
1056    /// changed — the batch twin of [`update_changed`](Self::update_changed), and the path the
1057    /// generic bulk PUT and bulk PATCH take. A row missing or deleted since it was read fails
1058    /// the batch, with the error placed on that item.
1059    ///
1060    /// The generated Postgres repositories override this with one transaction. The default
1061    /// loops over `update_changed` and is not transactional.
1062    async fn bulk_update_changed(&self, pairs: Vec<(E, E)>) -> Result<Vec<E>, RepositoryError> {
1063        let mut out = Vec::with_capacity(pairs.len());
1064        for (index, (before, after)) in pairs.into_iter().enumerate() {
1065            match self.update_changed(&before, after).await {
1066                Ok(Some(saved)) => out.push(saved),
1067                Ok(None) => return Err(RepositoryError::NotFound.at_batch_index(index)),
1068                Err(e) => return Err(e.at_batch_index(index)),
1069            }
1070        }
1071        Ok(out)
1072    }
1073}
1074
1075/// Extended repository with search/filter capabilities
1076#[async_trait]
1077pub trait SearchableRepository<E>: CrudRepository<E>
1078where
1079    E: PersistentEntity,
1080{
1081    /// Search entities with filters
1082    async fn search(
1083        &self,
1084        filters: HashMap<String, String>,
1085        page: u32,
1086        limit: u32,
1087    ) -> Result<(Vec<E>, u64), RepositoryError>;
1088
1089    /// Find by a specific field value
1090    async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
1091
1092    /// Find all by a specific field value
1093    async fn find_all_by_field(
1094        &self,
1095        field: &str,
1096        value: &str,
1097        page: u32,
1098        limit: u32,
1099    ) -> Result<(Vec<E>, u64), RepositoryError>;
1100}
1101
1102// ============================================================
1103// PostgreSQL-Specific Traits
1104// ============================================================
1105
1106#[cfg(feature = "postgres")]
1107pub use postgres_traits::*;
1108
1109#[cfg(feature = "postgres")]
1110mod postgres_traits {
1111    use super::*;
1112    use sqlx::postgres::PgRow;
1113    use sqlx::FromRow;
1114
1115    /// Trait for mapping entities to/from PostgreSQL rows
1116    ///
1117    /// Implement this trait to enable automatic PostgreSQL persistence for your entity.
1118    ///
1119    /// # Example
1120    ///
1121    /// ```ignore
1122    /// use backbone_core::persistence::{PostgresEntity, PersistentEntity};
1123    /// use sqlx::FromRow;
1124    ///
1125    /// #[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
1126    /// struct User {
1127    ///     id: String,
1128    ///     name: String,
1129    ///     email: String,
1130    ///     created_at: Option<DateTime<Utc>>,
1131    ///     updated_at: Option<DateTime<Utc>>,
1132    ///     deleted_at: Option<DateTime<Utc>>,
1133    /// }
1134    ///
1135    /// impl PostgresEntity for User {
1136    ///     fn table_name() -> &'static str { "users" }
1137    ///     fn select_columns() -> &'static [&'static str] {
1138    ///         &["id", "name", "email", "created_at", "updated_at", "deleted_at"]
1139    ///     }
1140    ///     fn insert_columns() -> &'static [&'static str] {
1141    ///         &["id", "name", "email", "created_at", "updated_at"]
1142    ///     }
1143    ///     fn update_columns() -> &'static [&'static str] {
1144    ///         &["name", "email", "updated_at"]
1145    ///     }
1146    ///     fn bind_for_insert(entity: &Self, query: Query<'_, ...>) -> Query<'_, ...> {
1147    ///         query.bind(&entity.id).bind(&entity.name).bind(&entity.email)
1148    ///             .bind(&entity.created_at).bind(&entity.updated_at)
1149    ///     }
1150    /// }
1151    /// ```
1152    pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
1153        /// Table name for this entity
1154        fn table_name() -> &'static str;
1155
1156        /// Primary key column name (default: "id")
1157        fn id_column() -> &'static str {
1158            "id"
1159        }
1160
1161        /// Column names for SELECT queries (excluding computed columns)
1162        fn select_columns() -> &'static [&'static str];
1163
1164        /// Column names for INSERT (all columns that should be inserted)
1165        fn insert_columns() -> &'static [&'static str];
1166
1167        /// Column names for UPDATE (columns that can be updated, excluding id)
1168        fn update_columns() -> &'static [&'static str] {
1169            Self::insert_columns()
1170        }
1171
1172        /// Bind entity values to a query for INSERT
1173        ///
1174        /// The order must match insert_columns()
1175        fn bind_for_insert<'q>(
1176            entity: &'q Self,
1177            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
1178        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
1179
1180        /// Bind entity values to a query for UPDATE
1181        ///
1182        /// The order must match update_columns()
1183        fn bind_for_update<'q>(
1184            entity: &'q Self,
1185            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
1186        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
1187            // Default: same as insert (override if different)
1188            Self::bind_for_insert(entity, query)
1189        }
1190
1191        /// Build SELECT by ID query
1192        fn select_by_id_query() -> String {
1193            format!(
1194                "SELECT {} FROM {} WHERE {} = $1 AND deleted_at IS NULL",
1195                Self::select_columns().join(", "),
1196                Self::table_name(),
1197                Self::id_column()
1198            )
1199        }
1200
1201        /// Build SELECT by ID including deleted
1202        fn select_by_id_including_deleted_query() -> String {
1203            format!(
1204                "SELECT {} FROM {} WHERE {} = $1",
1205                Self::select_columns().join(", "),
1206                Self::table_name(),
1207                Self::id_column()
1208            )
1209        }
1210
1211        /// Build paginated list query
1212        fn list_query() -> String {
1213            format!(
1214                "SELECT {} FROM {} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
1215                Self::select_columns().join(", "),
1216                Self::table_name()
1217            )
1218        }
1219
1220        /// Build count query
1221        fn count_query() -> String {
1222            format!(
1223                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
1224                Self::table_name()
1225            )
1226        }
1227
1228        /// Build list deleted (trash) query
1229        fn list_deleted_query() -> String {
1230            format!(
1231                "SELECT {} FROM {} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2",
1232                Self::select_columns().join(", "),
1233                Self::table_name()
1234            )
1235        }
1236
1237        /// Build count deleted query
1238        fn count_deleted_query() -> String {
1239            format!(
1240                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NOT NULL",
1241                Self::table_name()
1242            )
1243        }
1244
1245        /// Build soft delete query
1246        fn soft_delete_query() -> String {
1247            format!(
1248                "UPDATE {} SET deleted_at = NOW(), updated_at = NOW() WHERE {} = $1 AND deleted_at IS NULL",
1249                Self::table_name(),
1250                Self::id_column()
1251            )
1252        }
1253
1254        /// Build restore query
1255        fn restore_query() -> String {
1256            format!(
1257                "UPDATE {} SET deleted_at = NULL, updated_at = NOW() WHERE {} = $1 AND deleted_at IS NOT NULL RETURNING {}",
1258                Self::table_name(),
1259                Self::id_column(),
1260                Self::select_columns().join(", ")
1261            )
1262        }
1263
1264        /// Build hard delete query
1265        fn hard_delete_query() -> String {
1266            format!(
1267                "DELETE FROM {} WHERE {} = $1",
1268                Self::table_name(),
1269                Self::id_column()
1270            )
1271        }
1272
1273        /// Build empty trash query
1274        fn empty_trash_query() -> String {
1275            format!(
1276                "DELETE FROM {} WHERE deleted_at IS NOT NULL",
1277                Self::table_name()
1278            )
1279        }
1280    }
1281}