refactor(search): normalize answer to /resources format

This commit is contained in:
Edouard Vanbelle
2026-07-26 14:42:04 +02:00
parent 14da27db10
commit c22741bc7f
15 changed files with 950 additions and 346 deletions
@@ -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,