fix: resolve all clippy warnings and enforce cargo fmt

- display_helpers: convert module doc-comments to regular comments,
  merge identical text/markdown + text/ branches
- search_service: replace needless range loops with slice-based pagination
- folder_repository, folder_db_repository: collapse nested if statements
- favorites_pg_repository: remove unnecessary borrow on generic arg
- file_blob_read_repository: collapse 6 nested if-let blocks
- file_blob_write_repository: collapse nested if for dedup ref decrement
- chunked_upload_service: use div_ceil(), collapse 2 nested if blocks
- folder_handler: collapse nested if-let for owner check
- webdav_handler: replace 7x io::Error::new(ErrorKind::Other, ..) with
  io::Error::other(..)
- cargo fmt applied to all files

Passes: cargo clippy --all-targets --all-features -- -D warnings
This commit is contained in:
Dionisio
2026-02-25 10:28:34 +01:00
parent 093400ce72
commit 97cf6402e2
34 changed files with 769 additions and 761 deletions
@@ -222,7 +222,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
let mut q = sqlx::query(&query);
for (item_id, item_type) in chunk {
q = q.bind(&user_uuid).bind(item_id).bind(item_type);
q = q.bind(user_uuid).bind(item_id).bind(item_type);
}
let result = q.execute(&mut *tx).await.map_err(|e| {
@@ -441,10 +441,7 @@ impl FileReadPort for FileBlobReadRepository {
///
/// Single GiST-indexed query via ltree `<@`.
/// Ordered by `(fo.path, fi.name)` so callers iterate in directory order.
async fn list_files_in_subtree(
&self,
folder_id: &str,
) -> Result<Vec<File>, DomainError> {
async fn list_files_in_subtree(&self, folder_id: &str) -> Result<Vec<File>, DomainError> {
let rows: Vec<(
String,
String,
@@ -472,9 +469,7 @@ impl FileReadPort for FileBlobReadRepository {
.bind(folder_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree files: {e}"))
})?;
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("subtree files: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
@@ -520,11 +515,11 @@ impl FileReadPort for FileBlobReadRepository {
conditions.push(format!("fi.folder_id = ${bind_idx}::uuid"));
}
if let Some(name) = &criteria.name_contains {
if !name.is_empty() {
bind_idx += 1;
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
}
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
{
bind_idx += 1;
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
}
let where_clause = conditions.join(" AND ");
@@ -546,19 +541,30 @@ impl FileReadPort for FileBlobReadRepository {
);
// ── Bind parameters dynamically ──────────────────────────────────
let mut query = sqlx::query_as::<_, (
String, String, Option<String>, Option<String>,
i64, String, i64, i64, Option<String>, i64,
)>(&sql)
.bind(user_id);
let mut query = sqlx::query_as::<
_,
(
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
i64,
),
>(&sql)
.bind(user_id);
if let Some(fid) = folder_id {
query = query.bind(fid);
}
if let Some(name) = &criteria.name_contains {
if !name.is_empty() {
query = query.bind(format!("%{}%", name.to_lowercase()));
}
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
{
query = query.bind(format!("%{}%", name.to_lowercase()));
}
query = query.bind(limit).bind(offset);
@@ -600,9 +606,7 @@ impl FileReadPort for FileBlobReadRepository {
// When no root folder specified, delegate to existing paginated search
let root_id = match root_folder_id {
None => {
return self
.search_files_paginated(None, criteria, user_id)
.await;
return self.search_files_paginated(None, criteria, user_id).await;
}
Some(id) => id,
};
@@ -631,19 +635,19 @@ impl FileReadPort for FileBlobReadRepository {
"fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid)".to_string(),
);
if let Some(name) = &criteria.name_contains {
if !name.is_empty() {
bind_idx += 1;
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
}
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
{
bind_idx += 1;
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
}
if let Some(types) = &criteria.file_types {
if !types.is_empty() {
bind_idx += 1;
conditions.push(format!(
"LOWER(SUBSTRING(fi.name FROM '\\.([^.]+)$')) = ANY(${bind_idx})"
));
}
if let Some(types) = &criteria.file_types
&& !types.is_empty()
{
bind_idx += 1;
conditions.push(format!(
"LOWER(SUBSTRING(fi.name FROM '\\.([^.]+)$')) = ANY(${bind_idx})"
));
}
if criteria.created_after.is_some() {
bind_idx += 1;
@@ -698,24 +702,34 @@ impl FileReadPort for FileBlobReadRepository {
);
// ── Bind parameters dynamically ──
let mut query = sqlx::query_as::<_, (
String, String, Option<String>, Option<String>,
i64, String, i64, i64, Option<String>, i64,
)>(&sql)
.bind(user_id)
.bind(root_id);
let mut query = sqlx::query_as::<
_,
(
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
i64,
),
>(&sql)
.bind(user_id)
.bind(root_id);
if let Some(name) = &criteria.name_contains {
if !name.is_empty() {
query = query.bind(format!("%{}%", name.to_lowercase()));
}
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
{
query = query.bind(format!("%{}%", name.to_lowercase()));
}
if let Some(types) = &criteria.file_types {
if !types.is_empty() {
let lower_types: Vec<String> =
types.iter().map(|t| t.to_lowercase()).collect();
query = query.bind(lower_types);
}
if let Some(types) = &criteria.file_types
&& !types.is_empty()
{
let lower_types: Vec<String> = types.iter().map(|t| t.to_lowercase()).collect();
query = query.bind(lower_types);
}
if let Some(v) = criteria.created_after {
query = query.bind(v as i64);
@@ -739,12 +753,9 @@ impl FileReadPort for FileBlobReadRepository {
query = query.bind(limit).bind(offset);
// ── Execute single query ──
let rows = query
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
})?;
let rows = query.fetch_all(self.pool.as_ref()).await.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
})?;
let total_count = rows.first().map_or(0, |r| r.9) as usize;
@@ -973,12 +984,9 @@ mod tests {
.build(),
};
repo.hash_cache
.insert("a".to_string(), "ha".to_string());
repo.hash_cache
.insert("b".to_string(), "hb".to_string());
repo.hash_cache
.insert("c".to_string(), "hc".to_string());
repo.hash_cache.insert("a".to_string(), "ha".to_string());
repo.hash_cache.insert("b".to_string(), "hb".to_string());
repo.hash_cache.insert("c".to_string(), "hc".to_string());
// Force moka to run pending eviction tasks
repo.hash_cache.run_pending_tasks();
@@ -1016,7 +1024,8 @@ mod tests {
}
for h in handles {
h.join().expect("Thread must not panic — no poison possible with moka");
h.join()
.expect("Thread must not panic — no poison possible with moka");
}
}
}
@@ -166,14 +166,14 @@ impl FileBlobWriteRepository {
};
// Decrement old blob ref (only if hash changed, best-effort)
if old_hash != new_hash {
if let Err(e) = self.dedup.remove_reference(&old_hash).await {
tracing::warn!(
"Failed to decrement old blob ref {}: {}",
&old_hash[..12],
e
);
}
if old_hash != new_hash
&& let Err(e) = self.dedup.remove_reference(&old_hash).await
{
tracing::warn!(
"Failed to decrement old blob ref {}: {}",
&old_hash[..12],
e
);
}
Ok(())
@@ -701,10 +701,7 @@ impl FileWritePort for FileBlobWriteRepository {
);
}
}
DomainError::internal_error(
"FileBlobWrite",
format!("copy_folder_tree: {e}"),
)
DomainError::internal_error("FileBlobWrite", format!("copy_folder_tree: {e}"))
})?;
tracing::info!(
@@ -685,10 +685,7 @@ impl FolderRepository for FolderDbRepository {
///
/// Single GiST-indexed query: `fo.lpath <@ (root's lpath)`.
/// Ordered by `fo.path` so callers can iterate in directory order.
async fn list_subtree_folders(
&self,
folder_id: &str,
) -> Result<Vec<Folder>, DomainError> {
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id::text, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
@@ -698,14 +695,21 @@ impl FolderRepository for FolderDbRepository {
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
ORDER BY fo.path";
let rows: Vec<(String, String, String, Option<String>, Option<String>, i64, i64)> =
sqlx::query_as(sql)
.bind(folder_id)
.fetch_all(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("subtree folders: {e}"))
})?;
let rows: Vec<(
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = sqlx::query_as(sql)
.bind(folder_id)
.fetch_all(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("subtree folders: {e}"))
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
@@ -728,10 +732,10 @@ impl FolderRepository for FolderDbRepository {
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder scope → existing optimised ltree scan
if recursive {
if let Some(fid) = parent_id {
return self.list_descendant_folders(fid, name_contains, user_id).await;
}
if recursive && let Some(fid) = parent_id {
return self
.list_descendant_folders(fid, name_contains, user_id)
.await;
}
// Build optional name filter
@@ -761,22 +765,27 @@ impl FolderRepository for FolderDbRepository {
ORDER BY fo.name"
);
let rows: Vec<(String, String, String, Option<String>, Option<String>, i64, i64)> =
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("search_folders: {e}"))
})?;
let rows: Vec<(
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
return rows
.into_iter()
@@ -820,37 +829,42 @@ impl FolderRepository for FolderDbRepository {
)
};
let rows: Vec<(String, String, String, Option<String>, Option<String>, i64, i64)> =
if let Some(pid) = parent_id {
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(pid)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(pid)
.bind(user_id)
.fetch_all(self.pool())
.await
}
} else if let Some(ref pattern) = name_pattern {
let rows: Vec<(
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = if let Some(pid) = parent_id {
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(pid)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(pid)
.bind(user_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("search_folders: {e}"))
})?;
} else if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
@@ -870,9 +884,10 @@ impl FolderRepository for FolderDbRepository {
user_id: &str,
) -> Result<Vec<Folder>, DomainError> {
let (where_extra, name_pattern) = match name_contains {
Some(name) if !name.is_empty() => {
(" AND LOWER(fo.name) LIKE $3", Some(format!("%{}%", name.to_lowercase())))
}
Some(name) if !name.is_empty() => (
" AND LOWER(fo.name) LIKE $3",
Some(format!("%{}%", name.to_lowercase())),
),
_ => ("", None),
};
@@ -890,24 +905,29 @@ impl FolderRepository for FolderDbRepository {
ORDER BY fo.name"
);
let rows: Vec<(String, String, String, Option<String>, Option<String>, i64, i64)> =
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(folder_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(folder_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("descendant search: {e}"))
})?;
let rows: Vec<(
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
)> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(folder_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(folder_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
@@ -1,308 +1,307 @@
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use std::sync::Arc;
use crate::{
application::ports::share_ports::ShareStoragePort,
common::errors::DomainError,
domain::entities::share::{Share, ShareItemType, SharePermissions},
};
/// PostgreSQL implementation of [`ShareStoragePort`].
///
/// Replaces the legacy file-based `ShareFsRepository` that read/wrote the
/// entire `shares.json` on every operation. Each method now issues a single
/// indexed SQL statement — O(1) lookups, ACID transactions, and no data-race
/// risk.
pub struct SharePgRepository {
db_pool: Arc<PgPool>,
}
impl SharePgRepository {
pub fn new(db_pool: Arc<PgPool>) -> Self {
Self { db_pool }
}
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
let id: String = row.try_get("id").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read id: {e}"))
})?;
let item_id: String = row.try_get("item_id").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read item_id: {e}"))
})?;
let item_name: Option<String> = row.try_get("item_name").unwrap_or(None);
let item_type_str: String = row.try_get("item_type").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read item_type: {e}"))
})?;
let token: String = row.try_get("token").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
})?;
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
let permissions_read: bool = row.try_get("permissions_read").unwrap_or(true);
let permissions_write: bool = row.try_get("permissions_write").unwrap_or(false);
let permissions_reshare: bool = row.try_get("permissions_reshare").unwrap_or(false);
let created_at: i64 = row.try_get("created_at").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
})?;
let created_by: String = row.try_get("created_by").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_by: {e}"))
})?;
let access_count: i64 = row.try_get("access_count").unwrap_or(0);
let item_type =
ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File);
let permissions = SharePermissions::new(permissions_read, permissions_write, permissions_reshare);
Ok(Share::from_raw(
id,
item_id,
item_name,
item_type,
token,
password_hash,
expires_at.map(|v| v as u64),
permissions,
created_at as u64,
created_by,
access_count as u64,
))
}
}
#[async_trait]
impl ShareStoragePort for SharePgRepository {
async fn save_share(&self, share: &Share) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
INSERT INTO storage.shares
(id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count)
VALUES
($1::UUID, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13)
ON CONFLICT (id) DO UPDATE SET
item_name = EXCLUDED.item_name,
password_hash = EXCLUDED.password_hash,
expires_at = EXCLUDED.expires_at,
permissions_read = EXCLUDED.permissions_read,
permissions_write = EXCLUDED.permissions_write,
permissions_reshare = EXCLUDED.permissions_reshare,
access_count = EXCLUDED.access_count
RETURNING
id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
"#,
)
.bind(share.id())
.bind(share.item_id())
.bind(share.item_name())
.bind(share.item_type().to_string())
.bind(share.token())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.created_at() as i64)
.bind(share.created_by())
.bind(share.access_count() as i64)
.fetch_one(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error saving share: {}", e);
DomainError::internal_error("Share", format!("Failed to save share: {e}"))
})?;
Self::row_to_entity(&row)
}
async fn find_share_by_id(&self, id: &str) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE id = $1::UUID
"#,
)
.bind(id)
.fetch_optional(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding share by id: {}", e);
DomainError::internal_error("Share", format!("Failed to find share: {e}"))
})?;
match row {
Some(r) => Self::row_to_entity(&r),
None => Err(DomainError::not_found(
"Share",
format!("Share with ID {id} not found"),
)),
}
}
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE token = $1
"#,
)
.bind(token)
.fetch_optional(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding share by token: {}", e);
DomainError::internal_error("Share", format!("Failed to find share by token: {e}"))
})?;
match row {
Some(r) => Self::row_to_entity(&r),
None => Err(DomainError::not_found(
"Share",
format!("Share with token {token} not found"),
)),
}
}
async fn find_shares_by_item(
&self,
item_id: &str,
item_type: &ShareItemType,
) -> Result<Vec<Share>, DomainError> {
let rows = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE item_id = $1 AND item_type = $2
ORDER BY created_at DESC
"#,
)
.bind(item_id)
.bind(item_type.to_string())
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding shares by item: {}", e);
DomainError::internal_error("Share", format!("Failed to find shares by item: {e}"))
})?;
rows.iter().map(Self::row_to_entity).collect()
}
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
UPDATE storage.shares SET
item_name = $2,
password_hash = $3,
expires_at = $4,
permissions_read = $5,
permissions_write = $6,
permissions_reshare = $7,
access_count = $8
WHERE id = $1::UUID
RETURNING
id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
"#,
)
.bind(share.id())
.bind(share.item_name())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.access_count() as i64)
.fetch_optional(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error updating share: {}", e);
DomainError::internal_error("Share", format!("Failed to update share: {e}"))
})?;
match row {
Some(r) => Self::row_to_entity(&r),
None => Err(DomainError::not_found(
"Share",
format!("Share with ID {} not found for update", share.id()),
)),
}
}
async fn delete_share(&self, id: &str) -> Result<(), DomainError> {
let result = sqlx::query(
"DELETE FROM storage.shares WHERE id = $1::UUID",
)
.bind(id)
.execute(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error deleting share: {}", e);
DomainError::internal_error("Share", format!("Failed to delete share: {e}"))
})?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found(
"Share",
format!("Share with ID {id} not found for deletion"),
));
}
Ok(())
}
async fn find_shares_by_user(
&self,
user_id: &str,
offset: usize,
limit: usize,
) -> Result<(Vec<Share>, usize), DomainError> {
// Single query with window function — count + rows in one roundtrip
let rows = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count,
COUNT(*) OVER() AS total_count
FROM storage.shares
WHERE created_by = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding shares by user: {}", e);
DomainError::internal_error("Share", format!("Failed to find shares by user: {e}"))
})?;
let total: usize = rows
.first()
.and_then(|r| r.try_get::<i64, _>("total_count").ok())
.unwrap_or(0) as usize;
let shares: Result<Vec<Share>, DomainError> =
rows.iter().map(Self::row_to_entity).collect();
Ok((shares?, total))
}
}
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use std::sync::Arc;
use crate::{
application::ports::share_ports::ShareStoragePort,
common::errors::DomainError,
domain::entities::share::{Share, ShareItemType, SharePermissions},
};
/// PostgreSQL implementation of [`ShareStoragePort`].
///
/// Replaces the legacy file-based `ShareFsRepository` that read/wrote the
/// entire `shares.json` on every operation. Each method now issues a single
/// indexed SQL statement — O(1) lookups, ACID transactions, and no data-race
/// risk.
pub struct SharePgRepository {
db_pool: Arc<PgPool>,
}
impl SharePgRepository {
pub fn new(db_pool: Arc<PgPool>) -> Self {
Self { db_pool }
}
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
let id: String = row
.try_get("id")
.map_err(|e| DomainError::internal_error("Share", format!("Failed to read id: {e}")))?;
let item_id: String = row.try_get("item_id").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read item_id: {e}"))
})?;
let item_name: Option<String> = row.try_get("item_name").unwrap_or(None);
let item_type_str: String = row.try_get("item_type").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read item_type: {e}"))
})?;
let token: String = row.try_get("token").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
})?;
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
let permissions_read: bool = row.try_get("permissions_read").unwrap_or(true);
let permissions_write: bool = row.try_get("permissions_write").unwrap_or(false);
let permissions_reshare: bool = row.try_get("permissions_reshare").unwrap_or(false);
let created_at: i64 = row.try_get("created_at").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
})?;
let created_by: String = row.try_get("created_by").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_by: {e}"))
})?;
let access_count: i64 = row.try_get("access_count").unwrap_or(0);
let item_type =
ShareItemType::try_from(item_type_str.as_str()).unwrap_or(ShareItemType::File);
let permissions =
SharePermissions::new(permissions_read, permissions_write, permissions_reshare);
Ok(Share::from_raw(
id,
item_id,
item_name,
item_type,
token,
password_hash,
expires_at.map(|v| v as u64),
permissions,
created_at as u64,
created_by,
access_count as u64,
))
}
}
#[async_trait]
impl ShareStoragePort for SharePgRepository {
async fn save_share(&self, share: &Share) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
INSERT INTO storage.shares
(id, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count)
VALUES
($1::UUID, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13)
ON CONFLICT (id) DO UPDATE SET
item_name = EXCLUDED.item_name,
password_hash = EXCLUDED.password_hash,
expires_at = EXCLUDED.expires_at,
permissions_read = EXCLUDED.permissions_read,
permissions_write = EXCLUDED.permissions_write,
permissions_reshare = EXCLUDED.permissions_reshare,
access_count = EXCLUDED.access_count
RETURNING
id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
"#,
)
.bind(share.id())
.bind(share.item_id())
.bind(share.item_name())
.bind(share.item_type().to_string())
.bind(share.token())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.created_at() as i64)
.bind(share.created_by())
.bind(share.access_count() as i64)
.fetch_one(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error saving share: {}", e);
DomainError::internal_error("Share", format!("Failed to save share: {e}"))
})?;
Self::row_to_entity(&row)
}
async fn find_share_by_id(&self, id: &str) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE id = $1::UUID
"#,
)
.bind(id)
.fetch_optional(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding share by id: {}", e);
DomainError::internal_error("Share", format!("Failed to find share: {e}"))
})?;
match row {
Some(r) => Self::row_to_entity(&r),
None => Err(DomainError::not_found(
"Share",
format!("Share with ID {id} not found"),
)),
}
}
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE token = $1
"#,
)
.bind(token)
.fetch_optional(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding share by token: {}", e);
DomainError::internal_error("Share", format!("Failed to find share by token: {e}"))
})?;
match row {
Some(r) => Self::row_to_entity(&r),
None => Err(DomainError::not_found(
"Share",
format!("Share with token {token} not found"),
)),
}
}
async fn find_shares_by_item(
&self,
item_id: &str,
item_type: &ShareItemType,
) -> Result<Vec<Share>, DomainError> {
let rows = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
FROM storage.shares
WHERE item_id = $1 AND item_type = $2
ORDER BY created_at DESC
"#,
)
.bind(item_id)
.bind(item_type.to_string())
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding shares by item: {}", e);
DomainError::internal_error("Share", format!("Failed to find shares by item: {e}"))
})?;
rows.iter().map(Self::row_to_entity).collect()
}
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
let row = sqlx::query(
r#"
UPDATE storage.shares SET
item_name = $2,
password_hash = $3,
expires_at = $4,
permissions_read = $5,
permissions_write = $6,
permissions_reshare = $7,
access_count = $8
WHERE id = $1::UUID
RETURNING
id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count
"#,
)
.bind(share.id())
.bind(share.item_name())
.bind(share.password_hash())
.bind(share.expires_at().map(|v| v as i64))
.bind(share.permissions().read())
.bind(share.permissions().write())
.bind(share.permissions().reshare())
.bind(share.access_count() as i64)
.fetch_optional(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error updating share: {}", e);
DomainError::internal_error("Share", format!("Failed to update share: {e}"))
})?;
match row {
Some(r) => Self::row_to_entity(&r),
None => Err(DomainError::not_found(
"Share",
format!("Share with ID {} not found for update", share.id()),
)),
}
}
async fn delete_share(&self, id: &str) -> Result<(), DomainError> {
let result = sqlx::query("DELETE FROM storage.shares WHERE id = $1::UUID")
.bind(id)
.execute(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error deleting share: {}", e);
DomainError::internal_error("Share", format!("Failed to delete share: {e}"))
})?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found(
"Share",
format!("Share with ID {id} not found for deletion"),
));
}
Ok(())
}
async fn find_shares_by_user(
&self,
user_id: &str,
offset: usize,
limit: usize,
) -> Result<(Vec<Share>, usize), DomainError> {
// Single query with window function — count + rows in one roundtrip
let rows = sqlx::query(
r#"
SELECT id::TEXT, item_id, item_name, item_type, token, password_hash,
expires_at, permissions_read, permissions_write, permissions_reshare,
created_at, created_by, access_count,
COUNT(*) OVER() AS total_count
FROM storage.shares
WHERE created_by = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
tracing::error!("Database error finding shares by user: {}", e);
DomainError::internal_error("Share", format!("Failed to find shares by user: {e}"))
})?;
let total: usize = rows
.first()
.and_then(|r| r.try_get::<i64, _>("total_count").ok())
.unwrap_or(0) as usize;
let shares: Result<Vec<Share>, DomainError> =
rows.iter().map(Self::row_to_entity).collect();
Ok((shares?, total))
}
}