use async_trait::async_trait;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::database::DbPool;
use crate::foundation::{DbError, DbResult};
#[async_trait]
pub trait Repository<T>: Send + Sync
where
T: Serialize + DeserializeOwned,
{
fn table(&self) -> &str;
async fn insert(&self, pool: &DbPool, entity: &T) -> DbResult<i64>;
async fn find_by_id(&self, pool: &DbPool, id: i64) -> DbResult<Option<T>>;
async fn find_all(&self, pool: &DbPool, limit: u64, offset: u64) -> DbResult<Vec<T>>;
async fn update(&self, pool: &DbPool, id: i64, entity: &T) -> DbResult<u64>;
async fn delete(&self, pool: &DbPool, id: i64) -> DbResult<u64>;
async fn count(&self, pool: &DbPool) -> DbResult<u64>;
}
pub(crate) fn is_safe_identifier(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& name.len() <= 64
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub(crate) fn sql_literal(value: &Value) -> DbResult<String> {
match value {
Value::Null => Ok("NULL".to_string()),
Value::Bool(b) => Ok(if *b { "TRUE" } else { "FALSE" }.to_string()),
Value::Number(n) => Ok(n.to_string()),
Value::String(s) => Ok(format!("'{}'", s.replace('\'', "''"))),
Value::Array(_) | Value::Object(_) => {
let json = serde_json::to_string(value).map_err(|e| {
DbError::Config(format!("entity nested value serialize failed: {e}"))
})?;
Ok(format!("'{}'", json.replace('\'', "''")))
}
}
}
pub struct JsonRepository {
table: String,
id_column: String,
role: String,
}
impl JsonRepository {
pub fn new(table: &str) -> DbResult<Self> {
if !is_safe_identifier(table) {
return Err(DbError::Config(format!(
"repository table name must be a safe identifier: '{table}'"
)));
}
Ok(Self {
table: table.to_string(),
id_column: "id".to_string(),
role: "admin".to_string(),
})
}
pub fn with_id_column(mut self, id_column: &str) -> DbResult<Self> {
if !is_safe_identifier(id_column) {
return Err(DbError::Config(format!(
"repository id column must be a safe identifier: '{id_column}'"
)));
}
self.id_column = id_column.to_string();
Ok(self)
}
pub fn with_role(mut self, role: &str) -> Self {
self.role = role.to_string();
self
}
fn entity_object<T: Serialize>(&self, entity: &T) -> DbResult<serde_json::Map<String, Value>> {
match serde_json::to_value(entity)
.map_err(|e| DbError::Config(format!("entity serialize failed: {e}")))?
{
Value::Object(map) => Ok(map),
_ => Err(DbError::Config(
"repository entity must serialize to a JSON object".to_string(),
)),
}
}
}
#[async_trait]
impl<T> Repository<T> for JsonRepository
where
T: Serialize + DeserializeOwned + Send + Sync,
{
fn table(&self) -> &str {
&self.table
}
async fn insert(&self, pool: &DbPool, entity: &T) -> DbResult<i64> {
let map = self.entity_object(entity)?;
if map.is_empty() {
return Err(DbError::Config(
"repository insert requires at least one column".to_string(),
));
}
let mut columns = Vec::with_capacity(map.len());
let mut values = Vec::with_capacity(map.len());
for (col, value) in &map {
if !is_safe_identifier(col) {
return Err(DbError::Config(format!(
"repository column must be a safe identifier: '{col}'"
)));
}
columns.push(col.clone());
values.push(sql_literal(value)?);
}
let sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
self.table,
columns.join(", "),
values.join(", ")
);
let session = pool.get_session(&self.role).await?;
let exec = session.execute_raw(&sql).await?;
Ok(exec.last_insert_id() as i64)
}
async fn find_by_id(&self, pool: &DbPool, id: i64) -> DbResult<Option<T>> {
let sql = format!(
"SELECT * FROM {} WHERE {} = {}",
self.table, self.id_column, id
);
let rows = pool.query_rows(&sql, &self.role).await?;
match rows.into_iter().next() {
Some(row) => Ok(Some(serde_json::from_value(row).map_err(|e| {
DbError::Config(format!("entity deserialize failed: {e}"))
})?)),
None => Ok(None),
}
}
async fn find_all(&self, pool: &DbPool, limit: u64, offset: u64) -> DbResult<Vec<T>> {
let sql = format!(
"SELECT * FROM {} ORDER BY {} LIMIT {} OFFSET {}",
self.table, self.id_column, limit, offset
);
let rows = pool.query_rows(&sql, &self.role).await?;
rows.into_iter()
.map(|row| {
serde_json::from_value(row)
.map_err(|e| DbError::Config(format!("entity deserialize failed: {e}")))
})
.collect()
}
async fn update(&self, pool: &DbPool, id: i64, entity: &T) -> DbResult<u64> {
let map = self.entity_object(entity)?;
if map.is_empty() {
return Err(DbError::Config(
"repository update requires at least one column".to_string(),
));
}
let mut assignments = Vec::with_capacity(map.len());
for (col, value) in &map {
if !is_safe_identifier(col) {
return Err(DbError::Config(format!(
"repository column must be a safe identifier: '{col}'"
)));
}
if col == &self.id_column {
continue; }
assignments.push(format!("{} = {}", col, sql_literal(value)?));
}
if assignments.is_empty() {
return Err(DbError::Config(
"repository update requires at least one non-id column".to_string(),
));
}
let sql = format!(
"UPDATE {} SET {} WHERE {} = {}",
self.table,
assignments.join(", "),
self.id_column,
id
);
let session = pool.get_session(&self.role).await?;
let exec = session.execute_raw(&sql).await?;
Ok(exec.rows_affected())
}
async fn delete(&self, pool: &DbPool, id: i64) -> DbResult<u64> {
let sql = format!(
"DELETE FROM {} WHERE {} = {}",
self.table, self.id_column, id
);
let session = pool.get_session(&self.role).await?;
let exec = session.execute_raw(&sql).await?;
Ok(exec.rows_affected())
}
async fn count(&self, pool: &DbPool) -> DbResult<u64> {
let sql = format!("SELECT {} FROM {}", self.id_column, self.table);
let rows = pool.query_rows(&sql, &self.role).await?;
Ok(rows.len() as u64)
}
}
#[macro_export]
macro_rules! impl_json_repository {
($repo:ident, $entity:ty, table = $table:expr $(, id = $id:expr)?) => {
#[async_trait::async_trait]
impl $crate::database::repository::Repository<$entity> for $repo {
fn table(&self) -> &str {
$table
}
async fn insert(
&self,
pool: &$crate::database::DbPool,
entity: &$entity,
) -> $crate::foundation::DbResult<i64> {
$crate::database::repository::Repository::<$entity>::insert(
&($crate::database::repository::JsonRepository::new($table)?
$(.with_id_column($id)?)?),
pool,
entity,
)
.await
}
async fn find_by_id(
&self,
pool: &$crate::database::DbPool,
id: i64,
) -> $crate::foundation::DbResult<Option<$entity>> {
$crate::database::repository::Repository::<$entity>::find_by_id(
&($crate::database::repository::JsonRepository::new($table)?
$(.with_id_column($id)?)?),
pool,
id,
)
.await
}
async fn find_all(
&self,
pool: &$crate::database::DbPool,
limit: u64,
offset: u64,
) -> $crate::foundation::DbResult<Vec<$entity>> {
$crate::database::repository::Repository::<$entity>::find_all(
&($crate::database::repository::JsonRepository::new($table)?
$(.with_id_column($id)?)?),
pool,
limit,
offset,
)
.await
}
async fn update(
&self,
pool: &$crate::database::DbPool,
id: i64,
entity: &$entity,
) -> $crate::foundation::DbResult<u64> {
$crate::database::repository::Repository::<$entity>::update(
&($crate::database::repository::JsonRepository::new($table)?
$(.with_id_column($id)?)?),
pool,
id,
entity,
)
.await
}
async fn delete(
&self,
pool: &$crate::database::DbPool,
id: i64,
) -> $crate::foundation::DbResult<u64> {
$crate::database::repository::Repository::<$entity>::delete(
&($crate::database::repository::JsonRepository::new($table)?
$(.with_id_column($id)?)?),
pool,
id,
)
.await
}
async fn count(
&self,
pool: &$crate::database::DbPool,
) -> $crate::foundation::DbResult<u64> {
$crate::database::repository::Repository::<$entity>::count(
&($crate::database::repository::JsonRepository::new($table)?
$(.with_id_column($id)?)?),
pool,
)
.await
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_safe_identifier() {
assert!(is_safe_identifier("users"));
assert!(is_safe_identifier("_private_tbl2"));
assert!(!is_safe_identifier("1abc"));
assert!(!is_safe_identifier("has space"));
assert!(!is_safe_identifier("a; DROP TABLE x"));
assert!(!is_safe_identifier(""));
assert!(!is_safe_identifier(&"a".repeat(65)));
}
#[test]
fn test_sql_literal_escaping() {
assert_eq!(sql_literal(&Value::Null).unwrap(), "NULL");
assert_eq!(sql_literal(&Value::Bool(true)).unwrap(), "TRUE");
assert_eq!(sql_literal(&serde_json::json!(42)).unwrap(), "42");
assert_eq!(
sql_literal(&serde_json::json!("O'Brien")).unwrap(),
"'O''Brien'"
);
let lit = sql_literal(&serde_json::json!([1, 2])).unwrap();
assert_eq!(lit, "'[1,2]'");
}
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)]
struct _MacroUser {
id: i64,
name: String,
}
#[derive(Default)]
struct _MacroUserRepo;
crate::impl_json_repository!(_MacroUserRepo, _MacroUser, table = "t418_users");
#[test]
fn test_macro_impl_type_inference_in_crate() {
let repo = _MacroUserRepo;
assert_eq!(repo.table(), "t418_users");
}
#[test]
fn test_json_repository_validates_identifiers() {
assert!(JsonRepository::new("users").is_ok());
assert!(JsonRepository::new("users; DROP TABLE x").is_err());
assert!(
JsonRepository::new("users")
.unwrap()
.with_id_column("key")
.is_ok()
);
assert!(
JsonRepository::new("users")
.unwrap()
.with_id_column("1bad")
.is_err()
);
}
}