fix(search): stop dropping the name filter for short queries
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Blocked by required conditions
CI / changes (push) Waiting to run
CI / Plugins — fixtures + runtime tests (push) Blocked by required conditions
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Blocked by required conditions
CI / Migration ordering (new migrations postdate target branch) (push) Blocked by required conditions
CI / Rustfmt (push) Blocked by required conditions
CI / Clippy (push) Blocked by required conditions
CI / Wasm — fmt + clippy (push) Blocked by required conditions
CI / Wasm — release tests (push) Blocked by required conditions
CI / Server Unit and Functionnal Tests (push) Blocked by required conditions
CI / Security Audit (push) Blocked by required conditions
CI / Build (push) Waiting to run
CI / API, WebDAV & OIDC tests (push) Blocked by required conditions
CI / Bundled-assets binary — embed + SPA-serve integration (push) Blocked by required conditions
CI / WebDAV RFC 4918 — litmus (59/59) (push) Blocked by required conditions
CI / CalDAV + CardDAV — python-caldav (push) Blocked by required conditions
CI / Frontend end-to-end tests (via Playwright) (push) Blocked by required conditions
Docker Build and Test / Build and Test Docker Image (push) Waiting to run
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Waiting to run
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Blocked by required conditions
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Blocked by required conditions
CI / changes (push) Waiting to run
CI / Plugins — fixtures + runtime tests (push) Blocked by required conditions
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Blocked by required conditions
CI / Migration ordering (new migrations postdate target branch) (push) Blocked by required conditions
CI / Rustfmt (push) Blocked by required conditions
CI / Clippy (push) Blocked by required conditions
CI / Wasm — fmt + clippy (push) Blocked by required conditions
CI / Wasm — release tests (push) Blocked by required conditions
CI / Server Unit and Functionnal Tests (push) Blocked by required conditions
CI / Security Audit (push) Blocked by required conditions
CI / Build (push) Waiting to run
CI / API, WebDAV & OIDC tests (push) Blocked by required conditions
CI / Bundled-assets binary — embed + SPA-serve integration (push) Blocked by required conditions
CI / WebDAV RFC 4918 — litmus (59/59) (push) Blocked by required conditions
CI / CalDAV + CardDAV — python-caldav (push) Blocked by required conditions
CI / Frontend end-to-end tests (via Playwright) (push) Blocked by required conditions
Docker Build and Test / Build and Test Docker Image (push) Waiting to run
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Waiting to run
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Blocked by required conditions
The SQL repositories gated the name ILIKE condition on name.len() >= 3 *bytes*, so a 1-2 character search (e.g. "ab") silently returned an arbitrary page of the caller's files instead of matches — while the suggest dropdown (which never had the gate) still found them, making the top-bar search feel broken. The gate existed because the pg_trgm GIN index cannot accelerate sub-trigram patterns, but wrong-but-indexed is never acceptable: caller/folder scoping still bounds the scanned set and result pages are LIMIT-bound. Add a shared name_filter_active() predicate next to like_escape() (any non-blank query filters) and use it at all 7 gate sites — 4 in file_blob_read_repository (condition/bind pairs kept in lockstep so bind indices stay aligned), 3 match guards in folder_db_repository. The Tantivy >= 2 gate on the content index is deliberately left alone: it means "too-short tokens don't enter the full-text index", not a correctness gate. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1247,7 +1247,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
}
|
||||
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& name.len() >= 3
|
||||
&& super::name_filter_active(name)
|
||||
{
|
||||
bind_idx += 1;
|
||||
conditions.push(format!("fi.name ILIKE ${bind_idx}"));
|
||||
@@ -1319,7 +1319,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
query = query.bind(fid);
|
||||
}
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& name.len() >= 3
|
||||
&& super::name_filter_active(name)
|
||||
{
|
||||
query = query.bind(super::like_escape(name));
|
||||
}
|
||||
@@ -1412,7 +1412,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
);
|
||||
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& name.len() >= 3
|
||||
&& super::name_filter_active(name)
|
||||
{
|
||||
bind_idx += 1;
|
||||
conditions.push(format!("fi.name ILIKE ${bind_idx}"));
|
||||
@@ -1477,7 +1477,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.bind(root_id);
|
||||
|
||||
if let Some(name) = &criteria.name_contains
|
||||
&& name.len() >= 3
|
||||
&& super::name_filter_active(name)
|
||||
{
|
||||
query = query.bind(super::like_escape(name));
|
||||
}
|
||||
|
||||
@@ -1147,7 +1147,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
// Build optional name filter — use ILIKE (case-insensitive) so the
|
||||
// GIN trigram index idx_folders_name_trgm is used instead of a seq scan.
|
||||
let (name_clause, name_pattern) = match name_contains {
|
||||
Some(name) if name.len() >= 3 => (
|
||||
Some(name) if super::name_filter_active(name) => (
|
||||
if recursive {
|
||||
" AND fo.name ILIKE $2"
|
||||
} else {
|
||||
@@ -1228,7 +1228,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
// Root folders: parent_id IS NULL, params ($1=caller_id, $2=pattern)
|
||||
let name_clause_root = match name_contains {
|
||||
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2",
|
||||
Some(name) if super::name_filter_active(name) => " AND fo.name ILIKE $2",
|
||||
_ => "",
|
||||
};
|
||||
format!(
|
||||
@@ -1295,7 +1295,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let (where_extra, name_pattern) = match name_contains {
|
||||
Some(name) if name.len() >= 3 => {
|
||||
Some(name) if super::name_filter_active(name) => {
|
||||
(" AND fo.name ILIKE $3", Some(super::like_escape(name)))
|
||||
}
|
||||
_ => ("", None),
|
||||
|
||||
@@ -77,3 +77,46 @@ pub fn like_escape(raw: &str) -> String {
|
||||
.replace('_', "\\_");
|
||||
format!("%{escaped}%")
|
||||
}
|
||||
|
||||
/// Whether a search's `name_contains` value should produce an `ILIKE`
|
||||
/// predicate: any non-blank query filters, including 1–2 character ones.
|
||||
///
|
||||
/// The historical `name.len() >= 3` bytes gate existed because the pg_trgm
|
||||
/// GIN index cannot accelerate sub-trigram patterns — but gating on it here
|
||||
/// *dropped the name condition entirely* for shorter queries, so a search
|
||||
/// for `ab` returned an arbitrary page of the caller's files instead of
|
||||
/// matches (and disagreed with `suggest_files_by_name`, which never had the
|
||||
/// gate). Wrong-but-indexed is never acceptable in a storage product: the
|
||||
/// caller/folder scoping in every caller of this predicate still bounds the
|
||||
/// scanned set, and result pages are `LIMIT`-bound.
|
||||
#[inline]
|
||||
pub fn name_filter_active(name: &str) -> bool {
|
||||
!name.trim().is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod name_filter_tests {
|
||||
use super::name_filter_active;
|
||||
|
||||
#[test]
|
||||
fn one_and_two_char_queries_still_filter() {
|
||||
// The old `len() >= 3` bytes gate silently dropped the name
|
||||
// condition for these — this test is the regression guard.
|
||||
assert!(name_filter_active("a"));
|
||||
assert!(name_filter_active("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_cjk_char_filters() {
|
||||
// 3 bytes in UTF-8: already passed the old gate, must keep passing.
|
||||
assert!(name_filter_active("合"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_queries_do_not_filter() {
|
||||
// Blank means "match everything" — no ILIKE predicate is emitted.
|
||||
assert!(!name_filter_active(""));
|
||||
assert!(!name_filter_active(" "));
|
||||
assert!(!name_filter_active("\t\n"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user