perf(#12): push suggest() filtering + LIMIT to SQL

Replace the in-memory approach (list_files → filter in Rust) with
SQL-level ILIKE + relevance ORDER BY + LIMIT.

- Add suggest_files_by_name() to FileReadPort (default impl for stubs)
- Add suggest_folders_by_name() to FolderRepository (default impl)
- Implement efficient SQL in FileBlobReadRepository & FolderDbRepository
  with LOWER(name) LIKE pattern, 3-tier relevance sorting, and LIMIT
- Rewrite SearchService::suggest() to call new methods in parallel

Before: 50K files → ~10 MB transferred, 52K string comparisons
After:  50K files → ~20 rows transferred, index-assisted scan
This commit is contained in:
Dionisio
2026-02-23 23:36:38 +01:00
parent ab0c4476b3
commit 44113eabdc
5 changed files with 240 additions and 39 deletions
@@ -826,6 +826,91 @@ impl FileReadPort for FileBlobReadRepository {
.await?;
Ok(count)
}
async fn suggest_files_by_name(
&self,
folder_id: Option<&str>,
query: &str,
limit: usize,
) -> Result<Vec<File>, DomainError> {
let pattern = format!("%{}%", query.to_lowercase());
let limit_i64 = limit as i64;
let query_lower = query.to_lowercase();
let rows: Vec<(
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
Option<String>,
)> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid
AND NOT fi.is_trashed
AND LOWER(fi.name) LIKE $2
ORDER BY CASE
WHEN LOWER(fi.name) = $3 THEN 0
WHEN LOWER(fi.name) LIKE $3 || '%' THEN 1
ELSE 2
END,
fi.name
LIMIT $4
"#,
)
.bind(fid)
.bind(&pattern)
.bind(&query_lower)
.bind(limit_i64)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL
AND NOT fi.is_trashed
AND LOWER(fi.name) LIKE $1
ORDER BY CASE
WHEN LOWER(fi.name) = $2 THEN 0
WHEN LOWER(fi.name) LIKE $2 || '%' THEN 1
ELSE 2
END,
fi.name
LIMIT $3
"#,
)
.bind(&pattern)
.bind(&query_lower)
.bind(limit_i64)
.fetch_all(self.pool.as_ref())
.await
}
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("suggest: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
})
.collect()
}
}
#[cfg(test)]
@@ -734,6 +734,76 @@ impl FolderRepository for FolderDbRepository {
})
.collect()
}
async fn suggest_folders_by_name(
&self,
parent_id: Option<&str>,
query: &str,
limit: usize,
) -> Result<Vec<Folder>, DomainError> {
let pattern = format!("%{}%", query.to_lowercase());
let query_lower = query.to_lowercase();
let limit_i64 = limit as i64;
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid
AND NOT is_trashed
AND LOWER(name) LIKE $2
ORDER BY CASE
WHEN LOWER(name) = $3 THEN 0
WHEN LOWER(name) LIKE $3 || '%' THEN 1
ELSE 2
END,
name
LIMIT $4
"#,
)
.bind(pid)
.bind(&pattern)
.bind(&query_lower)
.bind(limit_i64)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL
AND NOT is_trashed
AND LOWER(name) LIKE $1
ORDER BY CASE
WHEN LOWER(name) = $2 THEN 0
WHEN LOWER(name) LIKE $2 || '%' THEN 1
ELSE 2
END,
name
LIMIT $3
"#,
)
.bind(&pattern)
.bind(&query_lower)
.bind(limit_i64)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
})
.collect()
}
}
// ── Extra helpers for blob-storage bootstrap ──