perf(webdav): single UNION ALL query for path resolution
Replace the double-query pattern (get_folder_by_path + get_file_by_path) across PROPFIND, HEAD, DELETE, MOVE, and COPY handlers with a single UNION ALL query via PathResolverService. PG Append node short-circuits on LIMIT 1: if the folder branch matches, the file branch is never executed. Cuts WebDAV path resolution from 2 round-trips to 1 per request. Also adds an exists() method using EXISTS subqueries for the Overwrite header checks in MOVE/COPY (avoids constructing full DTOs). Legacy double-query fallback retained when PathResolver is unavailable.
This commit is contained in:
@@ -7,6 +7,7 @@ pub mod image_transcode_service;
|
||||
pub mod jwt_service;
|
||||
pub mod oidc_service;
|
||||
pub mod password_hasher;
|
||||
pub mod path_resolver_service;
|
||||
pub mod path_service;
|
||||
pub mod thumbnail_service;
|
||||
pub mod trash_cleanup_service;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Single-query WebDAV path resolver.
|
||||
//!
|
||||
//! Replaces the double-query pattern (`get_folder_by_path` + `get_file_by_path`)
|
||||
//! with a single `UNION ALL` query that returns the first match. PostgreSQL's
|
||||
//! `Append` node short-circuits on `LIMIT 1`, so if the folder branch matches
|
||||
//! the file branch is never executed.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Result of resolving a WebDAV path — either a folder or a file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResolvedResource {
|
||||
Folder(FolderDto),
|
||||
File(FileDto),
|
||||
}
|
||||
|
||||
/// Resolves a WebDAV path to a folder or file in a single SQL round-trip.
|
||||
pub struct PathResolverService {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl PathResolverService {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Resolve `path` (without leading `/`) to either a folder or a file.
|
||||
///
|
||||
/// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated
|
||||
/// first, and PG short-circuits if it produces a row.
|
||||
pub async fn resolve_path(&self, path: &str) -> Result<ResolvedResource, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
return Err(DomainError::not_found("Resource", "empty path"));
|
||||
}
|
||||
|
||||
// Split into folder_path + filename for the file branch
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let filename = segments[segments.len() - 1];
|
||||
let folder_path = if segments.len() > 1 {
|
||||
segments[..segments.len() - 1].join("/")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Single round-trip: folder branch ∪ file branch, LIMIT 1.
|
||||
// Column order: resource_type, id, name, path, parent_id, user_id,
|
||||
// created_at, modified_at, size, mime_type, folder_id
|
||||
let row = sqlx::query_as::<_, (
|
||||
String, // resource_type
|
||||
String, // id
|
||||
String, // name
|
||||
String, // path
|
||||
Option<String>, // parent_id (folder) / NULL (file)
|
||||
Option<String>, // user_id
|
||||
i64, // created_at epoch
|
||||
i64, // modified_at epoch
|
||||
Option<i64>, // size (NULL for folder)
|
||||
Option<String>, // mime_type (NULL for folder)
|
||||
Option<String>, // folder_id (NULL for folder)
|
||||
)>(
|
||||
r#"
|
||||
SELECT resource_type, id, name, path, parent_id, user_id,
|
||||
created_at, modified_at, size, mime_type, folder_id
|
||||
FROM (
|
||||
SELECT 'folder'::text AS resource_type,
|
||||
fo.id::text,
|
||||
fo.name,
|
||||
fo.path,
|
||||
fo.parent_id::text,
|
||||
fo.user_id::text,
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at,
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
|
||||
NULL::bigint AS size,
|
||||
NULL::text AS mime_type,
|
||||
NULL::text AS folder_id
|
||||
FROM storage.folders fo
|
||||
WHERE fo.path = $1 AND NOT fo.is_trashed
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'file'::text AS resource_type,
|
||||
fi.id::text,
|
||||
fi.name,
|
||||
CASE
|
||||
WHEN fo.path IS NOT NULL AND fo.path != ''
|
||||
THEN fo.path || '/' || fi.name
|
||||
ELSE fi.name
|
||||
END AS path,
|
||||
NULL::text AS parent_id,
|
||||
fi.user_id::text,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
|
||||
fi.size,
|
||||
fi.mime_type,
|
||||
fi.folder_id::text
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.name = $2
|
||||
AND (
|
||||
($3 = '' AND fi.folder_id IS NULL)
|
||||
OR fo.path = $3
|
||||
)
|
||||
AND NOT fi.is_trashed
|
||||
) sub
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(path) // $1 — full path for folder lookup
|
||||
.bind(filename) // $2 — filename for file lookup
|
||||
.bind(&folder_path) // $3 — parent folder path for file lookup
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
||||
|
||||
let (resource_type, id, name, res_path, parent_id, user_id,
|
||||
created_at, modified_at, size, mime_type, folder_id) = row;
|
||||
|
||||
match resource_type.as_str() {
|
||||
"folder" => Ok(ResolvedResource::Folder(FolderDto {
|
||||
id,
|
||||
name: name.clone(),
|
||||
path: res_path,
|
||||
parent_id,
|
||||
owner_id: user_id,
|
||||
created_at: created_at as u64,
|
||||
modified_at: modified_at as u64,
|
||||
is_root: false,
|
||||
icon_class: "fas fa-folder".to_string(),
|
||||
icon_special_class: "folder-icon".to_string(),
|
||||
category: "Folder".to_string(),
|
||||
})),
|
||||
_ => {
|
||||
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let sz = size.unwrap_or(0) as u64;
|
||||
Ok(ResolvedResource::File(FileDto {
|
||||
id,
|
||||
name: name.clone(),
|
||||
path: res_path,
|
||||
size: sz,
|
||||
mime_type: mime.clone(),
|
||||
folder_id,
|
||||
created_at: created_at as u64,
|
||||
modified_at: modified_at as u64,
|
||||
icon_class: icon_class_for(&name, &mime).to_string(),
|
||||
icon_special_class: icon_special_class_for(&name, &mime).to_string(),
|
||||
category: category_for(&name, &mime).to_string(),
|
||||
size_formatted: format_file_size(sz),
|
||||
owner_id: user_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether *any* resource (folder or file) exists at the given path.
|
||||
///
|
||||
/// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO.
|
||||
pub async fn exists(&self, path: &str) -> Result<bool, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let filename = segments[segments.len() - 1];
|
||||
let folder_path = if segments.len() > 1 {
|
||||
segments[..segments.len() - 1].join("/")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM storage.folders
|
||||
WHERE path = $1 AND NOT is_trashed
|
||||
) OR EXISTS(
|
||||
SELECT 1
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.name = $2
|
||||
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
|
||||
AND NOT fi.is_trashed
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(filename)
|
||||
.bind(&folder_path)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user