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 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 async fn fetch_related_json(
945 &self,
946 _table: &str,
947 _ids: &[String],
948 ) -> Vec<serde_json::Value> {
949 Vec::new()
950 }
951
952 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 async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
972 Ok(self.find_by_id(id).await?.is_some())
973 }
974
975 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 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 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 async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
1018 let mut restored = Vec::new();
1019 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 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 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#[async_trait]
1066pub trait SearchableRepository<E>: CrudRepository<E>
1067where
1068 E: PersistentEntity,
1069{
1070 async fn search(
1072 &self,
1073 filters: HashMap<String, String>,
1074 page: u32,
1075 limit: u32,
1076 ) -> Result<(Vec<E>, u64), RepositoryError>;
1077
1078 async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
1080
1081 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#[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 pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
1142 fn table_name() -> &'static str;
1144
1145 fn id_column() -> &'static str {
1147 "id"
1148 }
1149
1150 fn select_columns() -> &'static [&'static str];
1152
1153 fn insert_columns() -> &'static [&'static str];
1155
1156 fn update_columns() -> &'static [&'static str] {
1158 Self::insert_columns()
1159 }
1160
1161 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 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 Self::bind_for_insert(entity, query)
1178 }
1179
1180 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 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 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 fn count_query() -> String {
1211 format!(
1212 "SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
1213 Self::table_name()
1214 )
1215 }
1216
1217 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 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 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 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 fn hard_delete_query() -> String {
1255 format!(
1256 "DELETE FROM {} WHERE {} = $1",
1257 Self::table_name(),
1258 Self::id_column()
1259 )
1260 }
1261
1262 fn empty_trash_query() -> String {
1264 format!(
1265 "DELETE FROM {} WHERE deleted_at IS NOT NULL",
1266 Self::table_name()
1267 )
1268 }
1269 }
1270}