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