use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{de::DeserializeOwned, Serialize};
use std::collections::HashMap;
use std::fmt::Debug;
#[derive(Debug, thiserror::Error)]
pub enum RepositoryError {
#[error("Entity not found")]
NotFound,
#[error("Entity already exists: {0}")]
AlreadyExists(String),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Serialization error: {0}")]
SerializationError(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Internal error: {0}")]
InternalError(String),
#[error("validation failed: {}", .0.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; "))]
Violations(Vec<crate::violation::Violation>),
}
impl RepositoryError {
pub fn from_source(e: impl Into<anyhow::Error>) -> Self {
let e: anyhow::Error = e.into();
#[cfg(feature = "postgres")]
let e = match e.downcast::<sqlx::Error>() {
Ok(db) => return RepositoryError::from(db),
Err(e) => e,
};
RepositoryError::DatabaseError(e.to_string())
}
}
impl From<serde_json::Error> for RepositoryError {
fn from(e: serde_json::Error) -> Self {
RepositoryError::SerializationError(e.to_string())
}
}
#[cfg(feature = "postgres")]
impl From<sqlx::Error> for RepositoryError {
fn from(e: sqlx::Error) -> Self {
match e {
sqlx::Error::RowNotFound => RepositoryError::NotFound,
sqlx::Error::Database(db_err) => {
let pg = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>();
if let Some(v) = constraint_violation(
db_err.kind(),
db_err.code().as_deref(),
db_err.constraint(),
pg.and_then(|p| p.detail()).unwrap_or(""),
pg.and_then(|p| p.column()),
) {
return RepositoryError::Violations(vec![v]);
}
let msg = db_err.message().to_string();
if msg.contains("duplicate key") || msg.contains("unique constraint") {
RepositoryError::AlreadyExists(msg)
} else {
RepositoryError::DatabaseError(msg)
}
}
_ => RepositoryError::DatabaseError(e.to_string()),
}
}
}
#[cfg(feature = "postgres")]
fn constraint_violation(
kind: sqlx::error::ErrorKind,
sqlstate: Option<&str>,
constraint: Option<&str>,
detail: &str,
column: Option<&str>,
) -> Option<crate::violation::Violation> {
use crate::violation::{code_from_constraint, Violation};
use sqlx::error::ErrorKind;
let constraint = constraint?;
let path = column.map(str::to_string).or_else(|| key_column(detail)).unwrap_or_default();
let code = code_from_constraint(constraint);
let on = |path: &str| if path.is_empty() { String::new() } else { format!("`{path}` ") };
Some(match kind {
ErrorKind::ForeignKeyViolation if detail.contains("still referenced") => Violation::new(
"",
"still_referenced",
"This record is still referenced by another record, so it cannot be removed",
),
ErrorKind::ForeignKeyViolation => {
let message = format!("{}refers to a record that does not exist", on(&path));
Violation::new(path, "reference_missing", message)
}
ErrorKind::CheckViolation => {
let message = format!("{}breaks the rule `{code}`", on(&path));
Violation::new(path, code, message)
}
ErrorKind::UniqueViolation if constraint.starts_with("uq_") => {
let message = format!("{}is already taken under the rule `{code}`", on(&path));
Violation::new(path, code, message)
}
_ if sqlstate == Some("23P01") => {
let message = format!("The record overlaps another one under the rule `{code}`");
Violation::new("", code, message)
}
_ => return None,
})
}
#[cfg(feature = "postgres")]
fn key_column(detail: &str) -> Option<String> {
let rest = detail.strip_prefix("Key (")?;
let cols = &rest[..rest.find(")=")?];
(!cols.contains(',')).then(|| cols.trim().to_string())
}
#[cfg(all(test, feature = "postgres"))]
mod constraint_violation_tests {
use super::*;
use sqlx::error::ErrorKind;
#[test]
fn a_missing_reference_names_the_column_and_never_the_table() {
let v = constraint_violation(
ErrorKind::ForeignKeyViolation,
Some("23503"),
Some("fk_subdistricts_district_id"),
"Key (district_id)=(1484f475-14be-4abc-985b-7291543d32bc) is not present in table \"districts\".",
None,
)
.unwrap();
assert_eq!((v.path.as_str(), v.code.as_str()), ("district_id", "reference_missing"));
assert!(!v.message.contains("districts\"") && !v.message.contains("fk_"), "{}", v.message);
}
#[test]
fn removing_a_referenced_record_is_still_referenced() {
let v = constraint_violation(
ErrorKind::ForeignKeyViolation,
Some("23503"),
Some("fk_provinces_country_id"),
"Key (id)=(866f1b4e-a9f3-4626-95d1-13c183916b4b) is still referenced from table \"provinces\".",
None,
)
.unwrap();
assert_eq!((v.path.as_str(), v.code.as_str()), ("", "still_referenced"));
}
#[test]
fn a_rule_constraint_answers_its_rules_code() {
let check = constraint_violation(ErrorKind::CheckViolation, Some("23514"), Some("ck_countries_name__max_length"), "", Some("name")).unwrap();
assert_eq!((check.path.as_str(), check.code.as_str()), ("name", "max_length"));
let unique = constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("uq_accounts__account_code_taken"), "Key (company_id, code)=(a, b) already exists.", None).unwrap();
assert_eq!((unique.path.as_str(), unique.code.as_str()), ("", "account_code_taken"));
let overlap = constraint_violation(ErrorKind::Other, Some("23P01"), Some("ex_tax_rates__overlapping_validity"), "", None).unwrap();
assert_eq!(overlap.code, "overlapping_validity");
}
#[test]
fn a_database_error_inside_any_error_converts_as_the_database_error_does() {
let wrapped = anyhow::Error::new(sqlx::Error::RowNotFound).context("loading the row");
assert!(matches!(RepositoryError::from_source(wrapped), RepositoryError::NotFound));
assert!(matches!(RepositoryError::from_source(sqlx::Error::RowNotFound), RepositoryError::NotFound));
let other = RepositoryError::from_source(anyhow::anyhow!("pool closed"));
assert!(matches!(other, RepositoryError::DatabaseError(ref m) if m == "pool closed"), "{other:?}");
}
#[test]
fn an_error_that_names_no_rule_keeps_its_old_answer() {
assert!(constraint_violation(ErrorKind::UniqueViolation, Some("23505"), Some("users_email_key"), "", None).is_none());
assert!(constraint_violation(ErrorKind::Other, Some("42P01"), None, "", None).is_none());
assert!(constraint_violation(ErrorKind::NotNullViolation, Some("23502"), Some("x"), "", None).is_none());
}
}
pub trait PersistentEntity: Clone + Send + Sync + Debug + Serialize + DeserializeOwned + 'static {
fn entity_id(&self) -> String;
fn set_entity_id(&mut self, id: String);
fn created_at(&self) -> Option<DateTime<Utc>>;
fn set_created_at(&mut self, ts: DateTime<Utc>);
fn updated_at(&self) -> Option<DateTime<Utc>>;
fn set_updated_at(&mut self, ts: DateTime<Utc>);
fn deleted_at(&self) -> Option<DateTime<Utc>>;
fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>);
fn is_deleted(&self) -> bool {
self.deleted_at().is_some()
}
fn mark_deleted(&mut self) {
self.set_deleted_at(Some(Utc::now()));
self.set_updated_at(Utc::now());
}
fn restore(&mut self) {
self.set_deleted_at(None);
self.set_updated_at(Utc::now());
}
fn touch(&mut self) {
self.set_updated_at(Utc::now());
}
fn generate_id() -> String {
uuid::Uuid::new_v4().to_string()
}
fn write_protected_fields() -> &'static [&'static str] {
&[]
}
}
pub trait PartialUpdatable: PersistentEntity {
fn apply_partial_update(&mut self, fields: &HashMap<String, serde_json::Value>) -> Result<(), RepositoryError>;
}
pub trait Versioned {
fn version(&self) -> u64;
fn set_version(&mut self, version: u64);
fn increment_version(&mut self) {
self.set_version(self.version() + 1);
}
}
#[async_trait]
pub trait CrudRepository<E>: Send + Sync
where
E: PersistentEntity,
{
async fn create(&self, entity: E) -> Result<E, RepositoryError>;
async fn find_by_id(&self, id: &str) -> Result<Option<E>, RepositoryError>;
async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<E>, RepositoryError>;
async fn update(&self, entity: E) -> Result<E, RepositoryError>;
async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError>;
async fn restore(&self, id: &str) -> Result<Option<E>, RepositoryError>;
async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError>;
async fn list(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), RepositoryError>;
async fn count(&self) -> Result<u64, RepositoryError>;
async fn count_deleted(&self) -> Result<u64, RepositoryError>;
async fn bulk_create(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError>;
async fn empty_trash(&self) -> Result<u64, RepositoryError>;
async fn list_filtered(
&self,
page: u32,
limit: u32,
filters: HashMap<String, String>,
) -> Result<(Vec<E>, u64), RepositoryError> {
let _ = filters; self.list(page, limit).await
}
async fn list_filtered_with_info(
&self,
page: u32,
limit: u32,
filters: HashMap<String, String>,
) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), RepositoryError> {
let (rows, total) = self.list_filtered(page, limit, filters).await?;
Ok((
rows,
backbone_orm::repository::PaginationInfo::new(page, limit, total),
))
}
fn table_name(&self) -> Option<&str> {
None
}
async fn aggregate_filtered(
&self,
spec: &backbone_orm::repository::AggregateSpec,
filters: HashMap<String, String>,
) -> Result<backbone_orm::repository::AggregateResult, RepositoryError> {
let _ = (spec, filters);
Err(RepositoryError::DatabaseError(
"aggregate is not supported by this repository".to_string(),
))
}
async fn fetch_related_json(
&self,
_table: &str,
_ids: &[String],
) -> Vec<serde_json::Value> {
Vec::new()
}
async fn exists(&self, id: &str) -> Result<bool, RepositoryError> {
Ok(self.find_by_id(id).await?.is_some())
}
async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
let mut n = 0;
for id in ids {
if self.soft_delete(id).await? {
n += 1;
}
}
Ok(n)
}
async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<E>, RepositoryError> {
let mut out = Vec::with_capacity(ids.len());
for id in ids {
if let Some(e) = self.restore(id).await? {
out.push(e);
}
}
Ok(out)
}
async fn bulk_hard_delete(&self, ids: &[String]) -> Result<u64, RepositoryError> {
let mut n = 0;
for id in ids {
if self.hard_delete(id).await? {
n += 1;
}
}
Ok(n)
}
async fn restore_all(&self) -> Result<Vec<E>, RepositoryError> {
let mut restored = Vec::new();
loop {
let (batch, _) = self.list_deleted(1, 500).await?;
if batch.is_empty() {
break;
}
for entity in &batch {
if let Some(e) = self.restore(&entity.entity_id()).await? {
restored.push(e);
}
}
}
Ok(restored)
}
async fn bulk_update(&self, entities: Vec<E>) -> Result<Vec<E>, RepositoryError> {
let mut out = Vec::with_capacity(entities.len());
for entity in entities {
out.push(self.update(entity).await?);
}
Ok(out)
}
}
#[async_trait]
pub trait SearchableRepository<E>: CrudRepository<E>
where
E: PersistentEntity,
{
async fn search(
&self,
filters: HashMap<String, String>,
page: u32,
limit: u32,
) -> Result<(Vec<E>, u64), RepositoryError>;
async fn find_by_field(&self, field: &str, value: &str) -> Result<Option<E>, RepositoryError>;
async fn find_all_by_field(
&self,
field: &str,
value: &str,
page: u32,
limit: u32,
) -> Result<(Vec<E>, u64), RepositoryError>;
}
#[cfg(feature = "postgres")]
pub use postgres_traits::*;
#[cfg(feature = "postgres")]
mod postgres_traits {
use super::*;
use sqlx::postgres::PgRow;
use sqlx::FromRow;
pub trait PostgresEntity: PersistentEntity + for<'r> FromRow<'r, PgRow> + Unpin {
fn table_name() -> &'static str;
fn id_column() -> &'static str {
"id"
}
fn select_columns() -> &'static [&'static str];
fn insert_columns() -> &'static [&'static str];
fn update_columns() -> &'static [&'static str] {
Self::insert_columns()
}
fn bind_for_insert<'q>(
entity: &'q Self,
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
fn bind_for_update<'q>(
entity: &'q Self,
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
Self::bind_for_insert(entity, query)
}
fn select_by_id_query() -> String {
format!(
"SELECT {} FROM {} WHERE {} = $1 AND deleted_at IS NULL",
Self::select_columns().join(", "),
Self::table_name(),
Self::id_column()
)
}
fn select_by_id_including_deleted_query() -> String {
format!(
"SELECT {} FROM {} WHERE {} = $1",
Self::select_columns().join(", "),
Self::table_name(),
Self::id_column()
)
}
fn list_query() -> String {
format!(
"SELECT {} FROM {} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
Self::select_columns().join(", "),
Self::table_name()
)
}
fn count_query() -> String {
format!(
"SELECT COUNT(*) FROM {} WHERE deleted_at IS NULL",
Self::table_name()
)
}
fn list_deleted_query() -> String {
format!(
"SELECT {} FROM {} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2",
Self::select_columns().join(", "),
Self::table_name()
)
}
fn count_deleted_query() -> String {
format!(
"SELECT COUNT(*) FROM {} WHERE deleted_at IS NOT NULL",
Self::table_name()
)
}
fn soft_delete_query() -> String {
format!(
"UPDATE {} SET deleted_at = NOW(), updated_at = NOW() WHERE {} = $1 AND deleted_at IS NULL",
Self::table_name(),
Self::id_column()
)
}
fn restore_query() -> String {
format!(
"UPDATE {} SET deleted_at = NULL, updated_at = NOW() WHERE {} = $1 AND deleted_at IS NOT NULL RETURNING {}",
Self::table_name(),
Self::id_column(),
Self::select_columns().join(", ")
)
}
fn hard_delete_query() -> String {
format!(
"DELETE FROM {} WHERE {} = $1",
Self::table_name(),
Self::id_column()
)
}
fn empty_trash_query() -> String {
format!(
"DELETE FROM {} WHERE deleted_at IS NOT NULL",
Self::table_name()
)
}
}
}