1use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use serde::{de::DeserializeOwned, Serialize};
9use std::collections::HashMap;
10use std::fmt::Debug;
11
12#[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 #[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 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 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 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 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#[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#[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#[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 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#[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#[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#[cfg(feature = "postgres")]
293const TENANT_KEYS: &[&str] = &["company_id", "tenant_id", "org_unit_id", "organization_id"];
294
295#[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#[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#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(constraint_violation(
643 ErrorKind::NotNullViolation,
644 Some("23502"),
645 None,
646 "",
647 None,
648 None
649 )
650 .is_none());
651 }
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666pub struct Reference {
667 pub field: &'static str,
669 pub table: &'static str,
672 pub column: &'static str,
674}
675
676impl Reference {
677 pub const fn new(field: &'static str, table: &'static str, column: &'static str) -> Self {
679 Self { field, table, column }
680 }
681}
682
683pub trait PersistentEntity: Clone + Send + Sync + Debug + Serialize + DeserializeOwned + 'static {
688 fn entity_id(&self) -> String;
690
691 fn set_entity_id(&mut self, id: String);
693
694 fn created_at(&self) -> Option<DateTime<Utc>>;
696
697 fn set_created_at(&mut self, ts: DateTime<Utc>);
699
700 fn updated_at(&self) -> Option<DateTime<Utc>>;
702
703 fn set_updated_at(&mut self, ts: DateTime<Utc>);
705
706 fn deleted_at(&self) -> Option<DateTime<Utc>>;
708
709 fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>);
711
712 fn is_deleted(&self) -> bool {
714 self.deleted_at().is_some()
715 }
716
717 fn mark_deleted(&mut self) {
719 self.set_deleted_at(Some(Utc::now()));
720 self.set_updated_at(Utc::now());
721 }
722
723 fn restore(&mut self) {
725 self.set_deleted_at(None);
726 self.set_updated_at(Utc::now());
727 }
728
729 fn touch(&mut self) {
731 self.set_updated_at(Utc::now());
732 }
733
734 fn generate_id() -> String {
736 uuid::Uuid::new_v4().to_string()
737 }
738
739 fn write_protected_fields() -> &'static [&'static str] {
750 &[]
751 }
752
753 fn state_fields() -> &'static [&'static str] {
763 &[]
764 }
765
766 fn references() -> &'static [Reference] {
778 &[]
779 }
780}
781
782pub trait PartialUpdatable: PersistentEntity {
784 fn apply_partial_update(&mut self, fields: &HashMap<String, serde_json::Value>) -> Result<(), RepositoryError>;
786}
787
788pub 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#[async_trait]
806pub trait CrudRepository<E>: Send + Sync
807where
808 E: PersistentEntity,
809{
810 async fn create(&self, entity: E) -> Result<E, RepositoryError>;
812
813 async fn find_by_id(&self, id: &str) -> Result<Option<E>, RepositoryError>;
815
816 async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<E>, RepositoryError>;
818
819 async fn update(&self, entity: E) -> Result<E, RepositoryError>;
821
822 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 async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError>;
843
844 async fn restore(&self, id: &str) -> Result<Option<E>, RepositoryError>;
846
847 async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError>;
849
850 async fn list(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
852
853 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
855
856 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 async fn count(&self) -> Result<u64, RepositoryError>;
873
874 async fn count_deleted(&self) -> Result<u64, RepositoryError>;
876
877 async fn bulk_create(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError>;
879
880 async fn empty_trash(&self) -> Result<u64, RepositoryError>;
882
883 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; self.list(page, limit).await
895 }
896
897 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 fn table_name(&self) -> Option<&str> {
926 None
927 }
928
929 fn write_pool(&self) -> Option<&backbone_orm::PgPool> {
937 None
938 }
939
940 async fn aggregate_filtered(
941 &self,
942 spec: &backbone_orm::repository::AggregateSpec,
943 filters: HashMap<String, String>,
944 ) -> Result<backbone_orm::repository::AggregateResult, RepositoryError> {
945 let _ = (spec, filters);
946 Err(RepositoryError::DatabaseError(
947 "aggregate is not supported by this repository".to_string(),
948 ))
949 }
950
951 async fn fetch_related_json(
956 &self,
957 _table: &str,
958 _ids: &[String],
959 ) -> Vec<serde_json::Value> {
960 Vec::new()
961 }
962
963 async fn reference_visible(
972 &self,
973 table: &str,
974 column: &str,
975 value: &serde_json::Value,
976 ) -> Result<Option<bool>, RepositoryError> {
977 let _ = (table, column, value);
978 Ok(None)
979 }
980
981 async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
983 Ok(self.find_by_id(id).await?.is_some())
984 }
985
986 async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
996 let mut n = 0;
997 for id in ids {
998 if self.soft_delete(id).await? {
999 n += 1;
1000 }
1001 }
1002 Ok(n)
1003 }
1004
1005 async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<E>, RepositoryError> {
1007 let mut out = Vec::with_capacity(ids.len());
1008 for id in ids {
1009 if let Some(e) = self.restore(id).await? {
1010 out.push(e);
1011 }
1012 }
1013 Ok(out)
1014 }
1015
1016 async fn bulk_hard_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
1018 let mut n = 0;
1019 for id in ids {
1020 if self.hard_delete(id).await? {
1021 n += 1;
1022 }
1023 }
1024 Ok(n)
1025 }
1026
1027 async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
1029 let mut restored = Vec::new();
1030 loop {
1033 let (batch, _) = self.list_deleted(1, 500).await?;
1034 if batch.is_empty() {
1035 break;
1036 }
1037 for entity in &batch {
1038 if let Some(e) = self.restore(&entity.entity_id()).await? {
1039 restored.push(e);
1040 }
1041 }
1042 }
1043 Ok(restored)
1044 }
1045
1046 async fn bulk_update(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError> {
1048 let mut out = Vec::with_capacity(entities.len());
1049 for entity in entities {
1050 out.push(self.update(entity).await?);
1051 }
1052 Ok(out)
1053 }
1054
1055 async fn bulk_update_changed(&self, pairs: Vec<(E, E)>) -> Result<Vec<E>, RepositoryError> {
1063 let mut out = Vec::with_capacity(pairs.len());
1064 for (index, (before, after)) in pairs.into_iter().enumerate() {
1065 match self.update_changed(&before, after).await {
1066 Ok(Some(saved)) => out.push(saved),
1067 Ok(None) => return Err(RepositoryError::NotFound.at_batch_index(index)),
1068 Err(e) => return Err(e.at_batch_index(index)),
1069 }
1070 }
1071 Ok(out)
1072 }
1073}
1074
1075#[async_trait]
1077pub trait SearchableRepository<E>: CrudRepository<E>
1078where
1079 E: PersistentEntity,
1080{
1081 async fn search(
1083 &self,
1084 filters: HashMap<String, String>,
1085 page: u32,
1086 limit: u32,
1087 ) -> Result<(Vec<E>, u64), RepositoryError>;
1088
1089 async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
1091
1092 async fn find_all_by_field(
1094 &self,
1095 field: &str,
1096 value: &str,
1097 page: u32,
1098 limit: u32,
1099 ) -> Result<(Vec<E>, u64), RepositoryError>;
1100}
1101
1102#[cfg(feature = "postgres")]
1107pub use postgres_traits::*;
1108
1109#[cfg(feature = "postgres")]
1110mod postgres_traits {
1111 use super::*;
1112 use sqlx::postgres::PgRow;
1113 use sqlx::FromRow;
1114
1115 pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
1153 fn table_name() -> &'static str;
1155
1156 fn id_column() -> &'static str {
1158 "id"
1159 }
1160
1161 fn select_columns() -> &'static [&'static str];
1163
1164 fn insert_columns() -> &'static [&'static str];
1166
1167 fn update_columns() -> &'static [&'static str] {
1169 Self::insert_columns()
1170 }
1171
1172 fn bind_for_insert<'q>(
1176 entity: &'q Self,
1177 query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
1178 ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
1179
1180 fn bind_for_update<'q>(
1184 entity: &'q Self,
1185 query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
1186 ) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
1187 Self::bind_for_insert(entity, query)
1189 }
1190
1191 fn select_by_id_query() -> String {
1193 format!(
1194 "SELECT {} FROM {} WHERE {} = $1 AND deleted_at IS NULL",
1195 Self::select_columns().join(", "),
1196 Self::table_name(),
1197 Self::id_column()
1198 )
1199 }
1200
1201 fn select_by_id_including_deleted_query() -> String {
1203 format!(
1204 "SELECT {} FROM {} WHERE {} = $1",
1205 Self::select_columns().join(", "),
1206 Self::table_name(),
1207 Self::id_column()
1208 )
1209 }
1210
1211 fn list_query() -> String {
1213 format!(
1214 "SELECT {} FROM {} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
1215 Self::select_columns().join(", "),
1216 Self::table_name()
1217 )
1218 }
1219
1220 fn count_query() -> String {
1222 format!(
1223 "SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
1224 Self::table_name()
1225 )
1226 }
1227
1228 fn list_deleted_query() -> String {
1230 format!(
1231 "SELECT {} FROM {} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2",
1232 Self::select_columns().join(", "),
1233 Self::table_name()
1234 )
1235 }
1236
1237 fn count_deleted_query() -> String {
1239 format!(
1240 "SELECT COUNT(*) FROM {} WHERE deleted_at IS NOT NULL",
1241 Self::table_name()
1242 )
1243 }
1244
1245 fn soft_delete_query() -> String {
1247 format!(
1248 "UPDATE {} SET deleted_at = NOW(), updated_at = NOW() WHERE {} = $1 AND deleted_at IS NULL",
1249 Self::table_name(),
1250 Self::id_column()
1251 )
1252 }
1253
1254 fn restore_query() -> String {
1256 format!(
1257 "UPDATE {} SET deleted_at = NULL, updated_at = NOW() WHERE {} = $1 AND deleted_at IS NOT NULL RETURNING {}",
1258 Self::table_name(),
1259 Self::id_column(),
1260 Self::select_columns().join(", ")
1261 )
1262 }
1263
1264 fn hard_delete_query() -> String {
1266 format!(
1267 "DELETE FROM {} WHERE {} = $1",
1268 Self::table_name(),
1269 Self::id_column()
1270 )
1271 }
1272
1273 fn empty_trash_query() -> String {
1275 format!(
1276 "DELETE FROM {} WHERE deleted_at IS NOT NULL",
1277 Self::table_name()
1278 )
1279 }
1280 }
1281}