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    async fn aggregate_filtered(
930        &self,
931        spec: &backbone_orm::repository::AggregateSpec,
932        filters: HashMap<String, String>,
933    ) -> Result<backbone_orm::repository::AggregateResult, RepositoryError> {
934        let _ = (spec, filters);
935        Err(RepositoryError::DatabaseError(
936            "aggregate is not supported by this repository".to_string(),
937        ))
938    }
939
940    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON
941    /// (raw `row_to_json`). `table` comes from `EntityRepoMeta::relations()`
942    /// (generator-emitted), never client input. Default: no expansion. The
943    /// Postgres-backed generated repos override this via `impl_crud_repository!`.
944    async fn fetch_related_json(
945        &self,
946        _table: &str,
947        _ids: &[String],
948    ) -> Vec<serde_json::Value> {
949        Vec::new()
950    }
951
952    /// Whether the row of `table` whose `column` holds `value` is visible to the caller:
953    /// read on the request's own connection and scope, so a row row-level security hides
954    /// counts as absent. `table` comes from [`PersistentEntity::references`]
955    /// (generator-emitted), never from client input.
956    ///
957    /// `Ok(None)` means this repository cannot tell; the write is then left to the
958    /// database's own key. That is the default. The Postgres-backed generated repositories
959    /// override it via `impl_crud_repository!`.
960    async fn reference_visible(
961        &self,
962        table: &str,
963        column: &str,
964        value: &serde_json::Value,
965    ) -> Result<Option<bool>, RepositoryError> {
966        let _ = (table, column, value);
967        Ok(None)
968    }
969
970    /// Check if an entity exists by ID
971    async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
972        Ok(self.find_by_id(id).await?.is_some())
973    }
974
975    // ── Atomic batch operations ───────────────────────────────────────────────
976    //
977    // The default implementations loop over the single-row methods and are NOT
978    // transactional — they exist so non-Postgres implementors (mocks, custom
979    // adapters) keep compiling. The macro-generated repositories override these
980    // (via `impl_crud_repository!`) with truly atomic, single-transaction
981    // versions backed by `GenericCrudRepository`.
982
983    /// Soft-delete many entities by id. Returns the number affected.
984    async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
985        let mut n = 0;
986        for id in ids {
987            if self.soft_delete(id).await? {
988                n += 1;
989            }
990        }
991        Ok(n)
992    }
993
994    /// Restore many soft-deleted entities by id. Returns the restored entities.
995    async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<E>, RepositoryError> {
996        let mut out = Vec::with_capacity(ids.len());
997        for id in ids {
998            if let Some(e) = self.restore(id).await? {
999                out.push(e);
1000            }
1001        }
1002        Ok(out)
1003    }
1004
1005    /// Permanently delete many entities by id. Returns the number affected.
1006    async fn bulk_hard_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
1007        let mut n = 0;
1008        for id in ids {
1009            if self.hard_delete(id).await? {
1010                n += 1;
1011            }
1012        }
1013        Ok(n)
1014    }
1015
1016    /// Restore every soft-deleted entity. Returns the restored entities.
1017    async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
1018        let mut restored = Vec::new();
1019        // Restoring removes rows from the deleted set, so page 1 always returns
1020        // the next batch of still-deleted rows until none remain.
1021        loop {
1022            let (batch, _) = self.list_deleted(1, 500).await?;
1023            if batch.is_empty() {
1024                break;
1025            }
1026            for entity in &batch {
1027                if let Some(e) = self.restore(&entity.entity_id()).await? {
1028                    restored.push(e);
1029                }
1030            }
1031        }
1032        Ok(restored)
1033    }
1034
1035    /// Update many entities atomically. Returns the updated entities.
1036    async fn bulk_update(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError> {
1037        let mut out = Vec::with_capacity(entities.len());
1038        for entity in entities {
1039            out.push(self.update(entity).await?);
1040        }
1041        Ok(out)
1042    }
1043
1044    /// Update many entities from `(before, after)` pairs, writing for each only the fields that
1045    /// changed — the batch twin of [`update_changed`](Self::update_changed), and the path the
1046    /// generic bulk PUT and bulk PATCH take. A row missing or deleted since it was read fails
1047    /// the batch, with the error placed on that item.
1048    ///
1049    /// The generated Postgres repositories override this with one transaction. The default
1050    /// loops over `update_changed` and is not transactional.
1051    async fn bulk_update_changed(&self, pairs: Vec<(E, E)>) -> Result<Vec<E>, RepositoryError> {
1052        let mut out = Vec::with_capacity(pairs.len());
1053        for (index, (before, after)) in pairs.into_iter().enumerate() {
1054            match self.update_changed(&before, after).await {
1055                Ok(Some(saved)) => out.push(saved),
1056                Ok(None) => return Err(RepositoryError::NotFound.at_batch_index(index)),
1057                Err(e) => return Err(e.at_batch_index(index)),
1058            }
1059        }
1060        Ok(out)
1061    }
1062}
1063
1064/// Extended repository with search/filter capabilities
1065#[async_trait]
1066pub trait SearchableRepository<E>: CrudRepository<E>
1067where
1068    E: PersistentEntity,
1069{
1070    /// Search entities with filters
1071    async fn search(
1072        &self,
1073        filters: HashMap<String, String>,
1074        page: u32,
1075        limit: u32,
1076    ) -> Result<(Vec<E>, u64), RepositoryError>;
1077
1078    /// Find by a specific field value
1079    async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
1080
1081    /// Find all by a specific field value
1082    async fn find_all_by_field(
1083        &self,
1084        field: &str,
1085        value: &str,
1086        page: u32,
1087        limit: u32,
1088    ) -> Result<(Vec<E>, u64), RepositoryError>;
1089}
1090
1091// ============================================================
1092// PostgreSQL-Specific Traits
1093// ============================================================
1094
1095#[cfg(feature = "postgres")]
1096pub use postgres_traits::*;
1097
1098#[cfg(feature = "postgres")]
1099mod postgres_traits {
1100    use super::*;
1101    use sqlx::postgres::PgRow;
1102    use sqlx::FromRow;
1103
1104    /// Trait for mapping entities to/from PostgreSQL rows
1105    ///
1106    /// Implement this trait to enable automatic PostgreSQL persistence for your entity.
1107    ///
1108    /// # Example
1109    ///
1110    /// ```ignore
1111    /// use backbone_core::persistence::{PostgresEntity, PersistentEntity};
1112    /// use sqlx::FromRow;
1113    ///
1114    /// #[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
1115    /// struct User {
1116    ///     id: String,
1117    ///     name: String,
1118    ///     email: String,
1119    ///     created_at: Option<DateTime<Utc>>,
1120    ///     updated_at: Option<DateTime<Utc>>,
1121    ///     deleted_at: Option<DateTime<Utc>>,
1122    /// }
1123    ///
1124    /// impl PostgresEntity for User {
1125    ///     fn table_name() -> &'static str { "users" }
1126    ///     fn select_columns() -> &'static [&'static str] {
1127    ///         &["id", "name", "email", "created_at", "updated_at", "deleted_at"]
1128    ///     }
1129    ///     fn insert_columns() -> &'static [&'static str] {
1130    ///         &["id", "name", "email", "created_at", "updated_at"]
1131    ///     }
1132    ///     fn update_columns() -> &'static [&'static str] {
1133    ///         &["name", "email", "updated_at"]
1134    ///     }
1135    ///     fn bind_for_insert(entity: &Self, query: Query<'_, ...>) -> Query<'_, ...> {
1136    ///         query.bind(&entity.id).bind(&entity.name).bind(&entity.email)
1137    ///             .bind(&entity.created_at).bind(&entity.updated_at)
1138    ///     }
1139    /// }
1140    /// ```
1141    pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
1142        /// Table name for this entity
1143        fn table_name() -> &'static str;
1144
1145        /// Primary key column name (default: "id")
1146        fn id_column() -> &'static str {
1147            "id"
1148        }
1149
1150        /// Column names for SELECT queries (excluding computed columns)
1151        fn select_columns() -> &'static [&'static str];
1152
1153        /// Column names for INSERT (all columns that should be inserted)
1154        fn insert_columns() -> &'static [&'static str];
1155
1156        /// Column names for UPDATE (columns that can be updated, excluding id)
1157        fn update_columns() -> &'static [&'static str] {
1158            Self::insert_columns()
1159        }
1160
1161        /// Bind entity values to a query for INSERT
1162        ///
1163        /// The order must match insert_columns()
1164        fn bind_for_insert<'q>(
1165            entity: &'q Self,
1166            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
1167        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
1168
1169        /// Bind entity values to a query for UPDATE
1170        ///
1171        /// The order must match update_columns()
1172        fn bind_for_update<'q>(
1173            entity: &'q Self,
1174            query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
1175        ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
1176            // Default: same as insert (override if different)
1177            Self::bind_for_insert(entity, query)
1178        }
1179
1180        /// Build SELECT by ID query
1181        fn select_by_id_query() -> String {
1182            format!(
1183                "SELECT {} FROM {} WHERE {} = $1 AND deleted_at IS NULL",
1184                Self::select_columns().join(", "),
1185                Self::table_name(),
1186                Self::id_column()
1187            )
1188        }
1189
1190        /// Build SELECT by ID including deleted
1191        fn select_by_id_including_deleted_query() -> String {
1192            format!(
1193                "SELECT {} FROM {} WHERE {} = $1",
1194                Self::select_columns().join(", "),
1195                Self::table_name(),
1196                Self::id_column()
1197            )
1198        }
1199
1200        /// Build paginated list query
1201        fn list_query() -> String {
1202            format!(
1203                "SELECT {} FROM {} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
1204                Self::select_columns().join(", "),
1205                Self::table_name()
1206            )
1207        }
1208
1209        /// Build count query
1210        fn count_query() -> String {
1211            format!(
1212                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
1213                Self::table_name()
1214            )
1215        }
1216
1217        /// Build list deleted (trash) query
1218        fn list_deleted_query() -> String {
1219            format!(
1220                "SELECT {} FROM {} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2",
1221                Self::select_columns().join(", "),
1222                Self::table_name()
1223            )
1224        }
1225
1226        /// Build count deleted query
1227        fn count_deleted_query() -> String {
1228            format!(
1229                "SELECT COUNT(*) FROM {} WHERE deleted_at IS NOT NULL",
1230                Self::table_name()
1231            )
1232        }
1233
1234        /// Build soft delete query
1235        fn soft_delete_query() -> String {
1236            format!(
1237                "UPDATE {} SET deleted_at = NOW(), updated_at = NOW() WHERE {} = $1 AND deleted_at IS NULL",
1238                Self::table_name(),
1239                Self::id_column()
1240            )
1241        }
1242
1243        /// Build restore query
1244        fn restore_query() -> String {
1245            format!(
1246                "UPDATE {} SET deleted_at = NULL, updated_at = NOW() WHERE {} = $1 AND deleted_at IS NOT NULL RETURNING {}",
1247                Self::table_name(),
1248                Self::id_column(),
1249                Self::select_columns().join(", ")
1250            )
1251        }
1252
1253        /// Build hard delete query
1254        fn hard_delete_query() -> String {
1255            format!(
1256                "DELETE FROM {} WHERE {} = $1",
1257                Self::table_name(),
1258                Self::id_column()
1259            )
1260        }
1261
1262        /// Build empty trash query
1263        fn empty_trash_query() -> String {
1264            format!(
1265                "DELETE FROM {} WHERE deleted_at IS NOT NULL",
1266                Self::table_name()
1267            )
1268        }
1269    }
1270}