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:
@@ -125,6 +125,29 @@ pub trait FileReadPort: Send + Sync + 'static {
|
|||||||
criteria: &SearchCriteriaDto,
|
criteria: &SearchCriteriaDto,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<usize, DomainError>;
|
) -> Result<usize, DomainError>;
|
||||||
|
|
||||||
|
/// 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<Vec<File>, DomainError> {
|
||||||
|
let all = self.list_files(folder_id).await?;
|
||||||
|
let q = query.to_lowercase();
|
||||||
|
let mut matched: Vec<File> = all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.name().to_lowercase().contains(&q))
|
||||||
|
.collect();
|
||||||
|
matched.truncate(limit);
|
||||||
|
Ok(matched)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -187,7 +187,8 @@ impl SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Quick suggestions search — returns up to `limit` name suggestions
|
/// 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(
|
pub async fn suggest(
|
||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -195,50 +196,49 @@ impl SearchService {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<SearchSuggestionsDto> {
|
) -> Result<SearchSuggestionsDto> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let query_lower = query.to_lowercase();
|
|
||||||
|
|
||||||
let mut suggestions: Vec<SearchSuggestionItem> = 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 mut suggestions: Vec<SearchSuggestionItem> =
|
||||||
let files = self.file_repository.list_files(folder_id).await?;
|
Vec::with_capacity(files.len() + folders.len());
|
||||||
for file in files {
|
|
||||||
let file_dto = FileDto::from(file);
|
for file in &files {
|
||||||
if file_dto.name.to_lowercase().contains(&query_lower) {
|
let file_dto = FileDto::from(file.clone());
|
||||||
let score = compute_relevance(&file_dto.name, query);
|
let score = compute_relevance(&file_dto.name, query);
|
||||||
suggestions.push(SearchSuggestionItem {
|
suggestions.push(SearchSuggestionItem {
|
||||||
name: file_dto.name.clone(),
|
name: file_dto.name.clone(),
|
||||||
item_type: "file".to_string(),
|
item_type: "file".to_string(),
|
||||||
id: file_dto.id.clone(),
|
id: file_dto.id.clone(),
|
||||||
path: file_dto.path.clone(),
|
path: file_dto.path.clone(),
|
||||||
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
|
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),
|
icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type),
|
||||||
relevance_score: score,
|
relevance_score: score,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if suggestions.len() >= limit * 2 {
|
|
||||||
break; // Collect enough candidates
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// List folders
|
for folder in &folders {
|
||||||
let folders = self.folder_repository.list_folders(folder_id).await?;
|
let folder_dto = FolderDto::from(folder.clone());
|
||||||
for folder in folders {
|
let score = compute_relevance(&folder_dto.name, query);
|
||||||
let folder_dto = FolderDto::from(folder);
|
suggestions.push(SearchSuggestionItem {
|
||||||
if folder_dto.name.to_lowercase().contains(&query_lower) {
|
name: folder_dto.name.clone(),
|
||||||
let score = compute_relevance(&folder_dto.name, query);
|
item_type: "folder".to_string(),
|
||||||
suggestions.push(SearchSuggestionItem {
|
id: folder_dto.id.clone(),
|
||||||
name: folder_dto.name.clone(),
|
path: folder_dto.path.clone(),
|
||||||
item_type: "folder".to_string(),
|
icon_class: "fas fa-folder".to_string(),
|
||||||
id: folder_dto.id.clone(),
|
icon_special_class: "folder-icon".to_string(),
|
||||||
path: folder_dto.path.clone(),
|
relevance_score: score,
|
||||||
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.sort_by(|a, b| b.relevance_score.cmp(&a.relevance_score));
|
||||||
suggestions.truncate(limit);
|
suggestions.truncate(limit);
|
||||||
|
|
||||||
|
|||||||
@@ -120,4 +120,27 @@ pub trait FolderRepository: Send + Sync + 'static {
|
|||||||
let _ = (folder_id, name_contains, user_id);
|
let _ = (folder_id, name_contains, user_id);
|
||||||
Ok(Vec::new())
|
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<Vec<Folder>, DomainError> {
|
||||||
|
let all = self.list_folders(parent_id).await?;
|
||||||
|
let q = query.to_lowercase();
|
||||||
|
let mut matched: Vec<Folder> = all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.name().to_lowercase().contains(&q))
|
||||||
|
.collect();
|
||||||
|
matched.truncate(limit);
|
||||||
|
Ok(matched)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -826,6 +826,91 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(count)
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -734,6 +734,76 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
})
|
})
|
||||||
.collect()
|
.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 ──
|
// ── Extra helpers for blob-storage bootstrap ──
|
||||||
|
|||||||
Reference in New Issue
Block a user