From 44113eabdc87837844c47172e7f0d534a7bf6360 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Mon, 23 Feb 2026 23:36:38 +0100 Subject: [PATCH] perf(#12): push suggest() filtering + LIMIT to SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/application/ports/storage_ports.rs | 23 +++++ src/application/services/search_service.rs | 78 ++++++++--------- src/domain/repositories/folder_repository.rs | 23 +++++ .../pg/file_blob_read_repository.rs | 85 +++++++++++++++++++ .../repositories/pg/folder_db_repository.rs | 70 +++++++++++++++ 5 files changed, 240 insertions(+), 39 deletions(-) diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index d9a59d0c..2e4dc890 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -125,6 +125,29 @@ pub trait FileReadPort: Send + Sync + 'static { criteria: &SearchCriteriaDto, user_id: &str, ) -> Result; + + /// Return up to `limit` files whose name contains `query` (case-insensitive). + /// + /// Results are ordered by relevance (exact > starts-with > contains) so the + /// caller can use them directly for autocomplete suggestions. + /// + /// The default implementation falls back to `list_files` + in-memory filter + /// so that stubs and mocks compile without changes. + async fn suggest_files_by_name( + &self, + folder_id: Option<&str>, + query: &str, + limit: usize, + ) -> Result, DomainError> { + let all = self.list_files(folder_id).await?; + let q = query.to_lowercase(); + let mut matched: Vec = all + .into_iter() + .filter(|f| f.name().to_lowercase().contains(&q)) + .collect(); + matched.truncate(limit); + Ok(matched) + } } // ───────────────────────────────────────────────────── diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index 5845cf04..a7c28515 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -187,7 +187,8 @@ impl SearchService { } /// Quick suggestions search — returns up to `limit` name suggestions - /// matching the query prefix. Uses cache-friendly shallow search. + /// matching the query. Pushes filtering, relevance sort and LIMIT to SQL + /// so only a handful of rows cross the DB→app boundary. pub async fn suggest( &self, query: &str, @@ -195,50 +196,49 @@ impl SearchService { limit: usize, ) -> Result { let start = Instant::now(); - let query_lower = query.to_lowercase(); - let mut suggestions: Vec = Vec::new(); + // Ask SQL for at most `limit` best-matching files and folders + let (files, folders) = tokio::join!( + self.file_repository + .suggest_files_by_name(folder_id, query, limit), + self.folder_repository + .suggest_folders_by_name(folder_id, query, limit), + ); + let files = files?; + let folders = folders?; - // List files in the folder - let files = self.file_repository.list_files(folder_id).await?; - for file in files { - let file_dto = FileDto::from(file); - if file_dto.name.to_lowercase().contains(&query_lower) { - let score = compute_relevance(&file_dto.name, query); - suggestions.push(SearchSuggestionItem { - name: file_dto.name.clone(), - item_type: "file".to_string(), - id: file_dto.id.clone(), - path: file_dto.path.clone(), - icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type), - icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type), - relevance_score: score, - }); - } - if suggestions.len() >= limit * 2 { - break; // Collect enough candidates - } + let mut suggestions: Vec = + Vec::with_capacity(files.len() + folders.len()); + + for file in &files { + let file_dto = FileDto::from(file.clone()); + let score = compute_relevance(&file_dto.name, query); + suggestions.push(SearchSuggestionItem { + name: file_dto.name.clone(), + item_type: "file".to_string(), + id: file_dto.id.clone(), + path: file_dto.path.clone(), + icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type), + icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type), + relevance_score: score, + }); } - // List folders - let folders = self.folder_repository.list_folders(folder_id).await?; - for folder in folders { - let folder_dto = FolderDto::from(folder); - if folder_dto.name.to_lowercase().contains(&query_lower) { - let score = compute_relevance(&folder_dto.name, query); - suggestions.push(SearchSuggestionItem { - name: folder_dto.name.clone(), - item_type: "folder".to_string(), - id: folder_dto.id.clone(), - path: folder_dto.path.clone(), - icon_class: "fas fa-folder".to_string(), - icon_special_class: "folder-icon".to_string(), - relevance_score: score, - }); - } + for folder in &folders { + let folder_dto = FolderDto::from(folder.clone()); + let score = compute_relevance(&folder_dto.name, query); + suggestions.push(SearchSuggestionItem { + name: folder_dto.name.clone(), + item_type: "folder".to_string(), + id: folder_dto.id.clone(), + path: folder_dto.path.clone(), + icon_class: "fas fa-folder".to_string(), + icon_special_class: "folder-icon".to_string(), + relevance_score: score, + }); } - // Sort by relevance and truncate + // Merge files + folders by relevance and truncate to the final limit suggestions.sort_by(|a, b| b.relevance_score.cmp(&a.relevance_score)); suggestions.truncate(limit); diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index e1af8599..cd8ff26a 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -120,4 +120,27 @@ pub trait FolderRepository: Send + Sync + 'static { let _ = (folder_id, name_contains, user_id); Ok(Vec::new()) } + + /// Return up to `limit` folders whose name contains `query` (case-insensitive). + /// + /// Results are ordered by relevance (exact > starts-with > contains) for + /// autocomplete suggestions. + /// + /// The default implementation falls back to `list_folders` + in-memory + /// filter so that stubs and mocks compile without changes. + async fn suggest_folders_by_name( + &self, + parent_id: Option<&str>, + query: &str, + limit: usize, + ) -> Result, DomainError> { + let all = self.list_folders(parent_id).await?; + let q = query.to_lowercase(); + let mut matched: Vec = all + .into_iter() + .filter(|f| f.name().to_lowercase().contains(&q)) + .collect(); + matched.truncate(limit); + Ok(matched) + } } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index a0cab69b..0ac28b23 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -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, 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, + Option, + i64, + String, + i64, + i64, + Option, + )> = 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)] diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 17e51066..2b1d618f 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -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, 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, 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 ──