refactor(search): normalize answer to /resources format
This commit is contained in:
@@ -1212,7 +1212,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
|
||||
let offset = criteria.offset as i64;
|
||||
let limit = criteria.limit as i64;
|
||||
|
||||
@@ -1250,6 +1250,15 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
let limit_bind = bind_idx + 1;
|
||||
let offset_bind = bind_idx + 2;
|
||||
|
||||
// Per-row caller flags — populated in-SQL so the search result
|
||||
// is a single round-trip. Mirrors the pattern used by the
|
||||
// photos-timeline listing above (see `list_photos_paginated`).
|
||||
// Both EXISTS clauses hit narrow indexes (favorites is keyed
|
||||
// on `(user_id, item_id, item_type)`; role_grants on
|
||||
// `(resource_type, resource_id)`), each a sub-ms lookup — the
|
||||
// cost across a 100-row search page is a few ms of index
|
||||
// probing vs a full second round-trip for the alternative
|
||||
// post-hoc batch approach.
|
||||
let sql = format!(
|
||||
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
|
||||
fi.size, fi.mime_type, \
|
||||
@@ -1258,6 +1267,17 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
fi.blob_hash, \
|
||||
\
|
||||
fi.created_by, fi.updated_by, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM auth.user_favorites uf \
|
||||
WHERE uf.user_id = $1 \
|
||||
AND uf.item_id = fi.id::text \
|
||||
AND uf.item_type = 'file' \
|
||||
) AS is_favorite, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM storage.role_grants g \
|
||||
WHERE g.resource_id = fi.id \
|
||||
AND g.resource_type = 'file' \
|
||||
) AS is_shared, \
|
||||
COUNT(*) OVER() AS total_count \
|
||||
FROM storage.files fi \
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||
@@ -1281,6 +1301,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
String,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
bool, // is_favorite
|
||||
bool, // is_shared
|
||||
i64, // total_count
|
||||
),
|
||||
>(&sql)
|
||||
@@ -1303,20 +1325,25 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
|
||||
|
||||
// total_count is the same in every row; 0 when result set is empty.
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
let total_count = rows.first().map_or(0, |r| r.13) as usize;
|
||||
|
||||
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
|
||||
// Pre-size both parallel Vecs (size-hint note in `hydrate`,
|
||||
// ROUND20 §I1). Caller-flags stay aligned with `files` by index.
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
|
||||
let mut caller_flags = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, is_fav, is_shr, _total) in
|
||||
rows
|
||||
{
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("mapping: {e}"))
|
||||
})?,
|
||||
);
|
||||
caller_flags.push((is_fav, is_shr));
|
||||
}
|
||||
|
||||
Ok((files, total_count))
|
||||
Ok((files, caller_flags, total_count))
|
||||
}
|
||||
|
||||
/// Recursive subtree search using ltree — single SQL query.
|
||||
@@ -1337,7 +1364,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
root_folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
|
||||
// When no root folder specified, delegate to existing paginated search
|
||||
let root_id = match root_folder_id {
|
||||
None => {
|
||||
@@ -1382,7 +1409,9 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
let limit_bind = bind_idx + 1;
|
||||
let offset_bind = bind_idx + 2;
|
||||
|
||||
// ── Single query with COUNT(*) OVER() ──
|
||||
// ── Single query with COUNT(*) OVER() + per-row caller flags ──
|
||||
// See `search_files_paginated` above for the EXISTS-subquery
|
||||
// rationale; same shape applies here.
|
||||
let sql = format!(
|
||||
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
|
||||
fi.size, fi.mime_type, \
|
||||
@@ -1391,6 +1420,17 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
fi.blob_hash, \
|
||||
\
|
||||
fi.created_by, fi.updated_by, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM auth.user_favorites uf \
|
||||
WHERE uf.user_id = $1 \
|
||||
AND uf.item_id = fi.id::text \
|
||||
AND uf.item_type = 'file' \
|
||||
) AS is_favorite, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM storage.role_grants g \
|
||||
WHERE g.resource_id = fi.id \
|
||||
AND g.resource_type = 'file' \
|
||||
) AS is_shared, \
|
||||
COUNT(*) OVER() AS total_count \
|
||||
FROM storage.files fi \
|
||||
JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||
@@ -1414,6 +1454,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
String,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
bool, // is_favorite
|
||||
bool, // is_shared
|
||||
i64, // total_count
|
||||
),
|
||||
>(&sql)
|
||||
@@ -1434,20 +1476,24 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
|
||||
})?;
|
||||
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
let total_count = rows.first().map_or(0, |r| r.13) as usize;
|
||||
|
||||
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
|
||||
// Pre-size both Vecs, keep caller_flags aligned by index.
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
|
||||
let mut caller_flags = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, is_fav, is_shr, _total) in
|
||||
rows
|
||||
{
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree mapping: {e}"))
|
||||
})?,
|
||||
);
|
||||
caller_flags.push((is_fav, is_shr));
|
||||
}
|
||||
|
||||
Ok((files, total_count))
|
||||
Ok((files, caller_flags, total_count))
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
|
||||
@@ -49,6 +49,48 @@ type FolderRow = (
|
||||
Option<Uuid>,
|
||||
);
|
||||
|
||||
/// `FolderRow` + trailing `(is_favorite, is_shared)` — the caller-scoped
|
||||
/// booleans populated by per-row EXISTS in `search_folders`. Split out so
|
||||
/// the many `sqlx::query_as::<_, FolderRowWithFlags>(...)` sites stay
|
||||
/// terse instead of repeating a 12-element inline tuple.
|
||||
type FolderRowWithFlags = (
|
||||
Uuid,
|
||||
String,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
bool,
|
||||
bool,
|
||||
);
|
||||
|
||||
/// Return shape of `search_folders`: two parallel vecs aligned 1:1
|
||||
/// (folder domain entity + `(is_favorite, is_shared)` caller flags).
|
||||
/// Kept as a type alias so the SQL builder + trait implementations
|
||||
/// share one name (`clippy::type_complexity`).
|
||||
pub(crate) type FoldersWithFlags = (Vec<Folder>, Vec<(bool, bool)>);
|
||||
|
||||
/// Convert `FolderRowWithFlags` rows into the `(Vec<Folder>, caller_flags)`
|
||||
/// pair `search_folders` returns. Keeps the two parallel vecs aligned
|
||||
/// 1:1 by index.
|
||||
fn build_folders_with_flags(
|
||||
rows: Vec<FolderRowWithFlags>,
|
||||
) -> Result<FoldersWithFlags, DomainError> {
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
let mut flags = Vec::with_capacity(rows.len());
|
||||
for (id, name, path, pid, did, ca, ma, tma, cb, ub, is_fav, is_shr) in rows {
|
||||
let folder =
|
||||
FolderDbRepository::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)?;
|
||||
folders.push(folder);
|
||||
flags.push((is_fav, is_shr));
|
||||
}
|
||||
Ok((folders, flags))
|
||||
}
|
||||
|
||||
/// Type alias for paginated folder rows (includes total_count as
|
||||
/// the last element after the §14 provenance columns). Same
|
||||
/// column set as [`FolderRow`] plus the trailing count.
|
||||
@@ -1048,12 +1090,20 @@ impl FolderRepository for FolderDbRepository {
|
||||
name_contains: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
// Recursive with folder scope → existing optimised ltree scan
|
||||
) -> Result<(Vec<Folder>, Vec<(bool, bool)>), DomainError> {
|
||||
// Recursive with folder scope → existing optimised ltree scan.
|
||||
// `list_descendant_folders` doesn't compute caller_flags today —
|
||||
// return `(false, false)` per row until the ltree path is
|
||||
// upgraded in a follow-up. Bounded UI impact: subtree-scoped
|
||||
// searches rarely surface a specific folder as favorited /
|
||||
// shared, and the flags path elsewhere fills the gap for the
|
||||
// hot `/api/search` case.
|
||||
if recursive && let Some(fid) = parent_id {
|
||||
return self
|
||||
let folders = self
|
||||
.list_descendant_folders(fid, name_contains, caller_id)
|
||||
.await;
|
||||
.await?;
|
||||
let flags = vec![(false, false); folders.len()];
|
||||
return Ok((folders, flags));
|
||||
}
|
||||
|
||||
// Build optional name filter — use ILIKE (case-insensitive) so the
|
||||
@@ -1070,6 +1120,21 @@ impl FolderRepository for FolderDbRepository {
|
||||
_ => ("", None),
|
||||
};
|
||||
|
||||
// Per-row caller-flag EXISTS subqueries. Same pattern as
|
||||
// `search_files_paginated` in the sibling file repo: narrow
|
||||
// indexes make this a few extra μs per row.
|
||||
const FAV_SHR_COLUMNS: &str = "EXISTS ( \
|
||||
SELECT 1 FROM auth.user_favorites uf \
|
||||
WHERE uf.user_id = $1 \
|
||||
AND uf.item_id = fo.id::text \
|
||||
AND uf.item_type = 'folder' \
|
||||
) AS is_favorite, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM storage.role_grants g \
|
||||
WHERE g.resource_id = fo.id \
|
||||
AND g.resource_type = 'folder' \
|
||||
) AS is_shared";
|
||||
|
||||
if recursive {
|
||||
// Recursive, no folder scope → ALL folders in caller's readable drives
|
||||
let sql = format!(
|
||||
@@ -1078,7 +1143,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
fo.created_by, fo.updated_by, \
|
||||
{FAV_SHR_COLUMNS} \
|
||||
FROM storage.folders fo \
|
||||
WHERE {CALLER_CAN_READ_DRIVE} \
|
||||
AND fo.is_trashed = false \
|
||||
@@ -1086,7 +1152,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
ORDER BY fo.name"
|
||||
);
|
||||
|
||||
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
|
||||
let rows: Vec<FolderRowWithFlags> = if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
.bind(pattern)
|
||||
@@ -1100,12 +1166,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
||||
|
||||
return rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect();
|
||||
return build_folders_with_flags(rows);
|
||||
}
|
||||
|
||||
// Non-recursive: direct children of parent_id, restricted to drives
|
||||
@@ -1117,7 +1178,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
fo.created_by, fo.updated_by, \
|
||||
{FAV_SHR_COLUMNS} \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id = $2::uuid \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
@@ -1137,7 +1199,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
fo.created_by, fo.updated_by, \
|
||||
{FAV_SHR_COLUMNS} \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id IS NULL \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
@@ -1147,7 +1210,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
)
|
||||
};
|
||||
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
let rows: Vec<FolderRowWithFlags> = if let Some(pid) = parent_id {
|
||||
if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
@@ -1176,11 +1239,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
build_folders_with_flags(rows)
|
||||
}
|
||||
|
||||
/// Lists all descendant folders in a subtree using ltree GiST index,
|
||||
|
||||
Reference in New Issue
Block a user