From 63a8a302a33fe6f0a8b596e80c9125b8aeb536d5 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sat, 15 Aug 2026 01:04:37 +0200 Subject: [PATCH 1/2] Fix name search missing short queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name search (files, pipelines, workspaces) filtered by pg_trgm % similarity only. That operator is threshold-gated and needs ~3 chars of trigram overlap, so a short query like "sa" produced too few trigrams to clear the threshold and matched nothing — searching "sa" never found "sample.txt". Make search a hybrid: a case-insensitive ILIKE substring match OR the trigram similarity match, across all six search sites. ILIKE gives predictable substring/prefix matching that works for any query length; the trigram half keeps typo tolerance. Both are served by the existing gin_trgm_ops index, so no migration change is needed. Add query::search::ilike_contains, which wraps a term as a %term% pattern with its LIKE metacharacters (% _ \) escaped in a single pass so they match literally. The pattern is a bound parameter (guards against wildcard injection, not SQL injection). Unit-tested for wrapping, escaping, and the empty term. Co-Authored-By: Claude Opus 4.8 --- crates/nvisy-postgres/src/query/mod.rs | 1 + crates/nvisy-postgres/src/query/search.rs | 55 +++++++++++++++++++ crates/nvisy-postgres/src/query/workspace.rs | 7 ++- .../src/query/workspace_file.rs | 18 ++++-- .../src/query/workspace_pipeline.rs | 26 +++++++-- 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 crates/nvisy-postgres/src/query/search.rs diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 09703c82..80e3cb28 100644 --- a/crates/nvisy-postgres/src/query/mod.rs +++ b/crates/nvisy-postgres/src/query/mod.rs @@ -17,6 +17,7 @@ mod account; mod account_api_token; mod account_notification; mod pipeline_reference; +mod search; mod workspace; mod workspace_activity; mod workspace_connection; diff --git a/crates/nvisy-postgres/src/query/search.rs b/crates/nvisy-postgres/src/query/search.rs new file mode 100644 index 00000000..bf274c1b --- /dev/null +++ b/crates/nvisy-postgres/src/query/search.rs @@ -0,0 +1,55 @@ +//! Helpers for name search filters. +//! +//! Name search is a hybrid: a case-insensitive substring match (`ILIKE +//! '%term%'`) OR a trigram similarity match (`display_name % term`). The `ILIKE` +//! half gives predictable substring/prefix matching that works for short queries +//! (`"sa"` finds `"sample.txt"`), which trigram similarity alone misses because a +//! two-character query produces too few trigrams to clear the similarity +//! threshold. The trigram half adds typo tolerance (`"smaple"` still finds +//! `"sample"`). Both halves are served by the same `gin_trgm_ops` index. + +/// Wraps a user search term as an `ILIKE` "contains" pattern (`%term%`), with the +/// term's `LIKE` metacharacters neutralized so they match literally. +/// +/// A raw `%` or `_` in a search term would otherwise act as a wildcard — `"%"` +/// would match every row, `"a_b"` any three-char run — so each `%`, `_`, and the +/// `\` escape character itself is prefixed with `\` (Postgres's default `LIKE` +/// escape). The pattern is a bound parameter, never concatenated into SQL, so +/// this guards against wildcard injection, not SQL injection. +/// +/// Escaping is done in one pass — a `\` is emitted immediately before each +/// metacharacter — so there is no order-dependence to get wrong (unlike chained +/// `replace` calls, where the `\`-doubling step must run first). +pub(crate) fn ilike_contains(term: &str) -> String { + let mut pattern = String::with_capacity(term.len() + 2); + pattern.push('%'); + for ch in term.chars() { + if matches!(ch, '%' | '_' | '\\') { + pattern.push('\\'); + } + pattern.push(ch); + } + pattern.push('%'); + pattern +} + +#[cfg(test)] +mod tests { + use super::ilike_contains; + + #[test] + fn wraps_in_contains_wildcards() { + assert_eq!(ilike_contains("sa"), "%sa%"); + } + + #[test] + fn escapes_like_metacharacters() { + assert_eq!(ilike_contains("50%_off"), "%50\\%\\_off%"); + assert_eq!(ilike_contains("a\\b"), "%a\\\\b%"); + } + + #[test] + fn empty_term_matches_everything() { + assert_eq!(ilike_contains(""), "%%"); + } +} diff --git a/crates/nvisy-postgres/src/query/workspace.rs b/crates/nvisy-postgres/src/query/workspace.rs index 58e287ed..206b04a5 100644 --- a/crates/nvisy-postgres/src/query/workspace.rs +++ b/crates/nvisy-postgres/src/query/workspace.rs @@ -9,6 +9,7 @@ use pgtrgm::expression_methods::TrgmExpressionMethods; use uuid::Uuid; use crate::model::{NewWorkspace, UpdateWorkspace, Workspace}; +use crate::query::search::ilike_contains; use crate::types::{AccountRefRow, OffsetPagination, WithAccountRef}; use crate::{PgConnection, PgError, PgResult, schema}; @@ -183,7 +184,11 @@ impl WorkspaceRepository for PgConnection { let workspace_list = workspaces .filter(deleted_at.is_null()) - .filter(display_name.trgm_similar_to(search_query)) + .filter( + display_name + .ilike(ilike_contains(search_query)) + .or(display_name.trgm_similar_to(search_query)), + ) .select(Workspace::as_select()) .order(updated_at.desc()) .limit(pagination.limit) diff --git a/crates/nvisy-postgres/src/query/workspace_file.rs b/crates/nvisy-postgres/src/query/workspace_file.rs index 8a75950f..2a5f7f44 100644 --- a/crates/nvisy-postgres/src/query/workspace_file.rs +++ b/crates/nvisy-postgres/src/query/workspace_file.rs @@ -9,6 +9,7 @@ use pgtrgm::expression_methods::TrgmExpressionMethods; use uuid::Uuid; use crate::model::{NewWorkspaceFile, NewWorkspaceFileImport, UpdateWorkspaceFile, WorkspaceFile}; +use crate::query::search::ilike_contains; use crate::types::{ AccountRefRow, CursorPage, CursorPagination, FileFilter, FileKind, FileSortBy, FileSortField, OffsetPagination, PipelineRunStatus, SortOrder, WithAccountRef, @@ -651,9 +652,14 @@ impl WorkspaceFileRepository for PgConnection { .filter(dsl::file_kind.eq_any(FileKind::DOCUMENTS)) .into_boxed(); - // Apply trigram search filter (pg_trgm) + // Hybrid name search: ILIKE substring (works for short queries) OR + // trigram similarity (typo tolerance); both served by the trgm index. if let Some(ref term) = search_term { - base_query = base_query.filter(dsl::display_name.trgm_similar_to(term)); + base_query = base_query.filter( + dsl::display_name + .ilike(ilike_contains(term)) + .or(dsl::display_name.trgm_similar_to(term)), + ); } // Apply the extension constraint. A present-but-empty set matches @@ -685,9 +691,13 @@ impl WorkspaceFileRepository for PgConnection { .filter(dsl::file_kind.eq_any(FileKind::DOCUMENTS)) .into_boxed(); - // Apply trigram search filter (pg_trgm) + // Hybrid name search: ILIKE substring OR trigram similarity (see above). if let Some(ref term) = search_term { - query = query.filter(dsl::display_name.trgm_similar_to(term)); + query = query.filter( + dsl::display_name + .ilike(ilike_contains(term)) + .or(dsl::display_name.trgm_similar_to(term)), + ); } // Apply the extension constraint. A present-but-empty set matches diff --git a/crates/nvisy-postgres/src/query/workspace_pipeline.rs b/crates/nvisy-postgres/src/query/workspace_pipeline.rs index 7c38f06f..f3d1e773 100644 --- a/crates/nvisy-postgres/src/query/workspace_pipeline.rs +++ b/crates/nvisy-postgres/src/query/workspace_pipeline.rs @@ -8,6 +8,7 @@ use pgtrgm::expression_methods::TrgmExpressionMethods; use uuid::Uuid; use crate::model::{NewWorkspacePipeline, UpdateWorkspacePipeline, WorkspacePipeline}; +use crate::query::search::ilike_contains; use crate::types::{ AccountRefRow, CursorPage, CursorPagination, OffsetPagination, PipelineStatus, WithAccountRef, }; @@ -99,7 +100,8 @@ pub trait WorkspacePipelineRepository { status: PipelineStatus, ) -> impl Future> + Send; - /// Searches pipelines by name using trigram similarity. + /// Searches pipelines by name: a case-insensitive substring match or a + /// trigram-similarity match. fn search_pipelines_by_name( &mut self, workspace_id: Uuid, @@ -234,9 +236,14 @@ impl WorkspacePipelineRepository for PgConnection { base_query = base_query.filter(dsl::status.eq(status)); } - // Apply search filter + // Hybrid name search: ILIKE substring (works for short queries) OR + // trigram similarity (typo tolerance); both served by the trgm index. if let Some(term) = search_term { - base_query = base_query.filter(dsl::display_name.trgm_similar_to(term)); + base_query = base_query.filter( + dsl::display_name + .ilike(ilike_contains(term)) + .or(dsl::display_name.trgm_similar_to(term)), + ); } let total = if pagination.include_count { @@ -262,8 +269,13 @@ impl WorkspacePipelineRepository for PgConnection { query = query.filter(dsl::status.eq(status)); } + // Hybrid name search: ILIKE substring OR trigram similarity (see above). if let Some(term) = search_term { - query = query.filter(dsl::display_name.trgm_similar_to(term)); + query = query.filter( + dsl::display_name + .ilike(ilike_contains(term)) + .or(dsl::display_name.trgm_similar_to(term)), + ); } let limit = pagination.fetch_limit(); @@ -417,7 +429,11 @@ impl WorkspacePipelineRepository for PgConnection { let pipelines = workspace_pipelines::table .filter(dsl::workspace_id.eq(workspace_id)) - .filter(dsl::display_name.trgm_similar_to(search_term)) + .filter( + dsl::display_name + .ilike(ilike_contains(search_term)) + .or(dsl::display_name.trgm_similar_to(search_term)), + ) .filter(dsl::deleted_at.is_null()) .order(dsl::display_name.asc()) .limit(limit) From b89ce7f1f38e89622031622ee22d5044e07cc09a Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sat, 15 Aug 2026 01:18:01 +0200 Subject: [PATCH 2/2] Apply name search to offset file listing too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit offset_list_workspace_files takes a FileFilter but only honored extensions(), silently ignoring search_term(). No handler calls it today, so there was no live mismatch, but the method is part of the repository contract — apply the same hybrid ILIKE-or-trigram predicate so offset and cursor pagination search identically. Flagged in review of #231. Co-Authored-By: Claude Opus 4.8 --- crates/nvisy-postgres/src/query/workspace_file.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/nvisy-postgres/src/query/workspace_file.rs b/crates/nvisy-postgres/src/query/workspace_file.rs index 2a5f7f44..6ebeca0c 100644 --- a/crates/nvisy-postgres/src/query/workspace_file.rs +++ b/crates/nvisy-postgres/src/query/workspace_file.rs @@ -605,6 +605,16 @@ impl WorkspaceFileRepository for PgConnection { .filter(dsl::file_kind.eq_any(FileKind::DOCUMENTS)) .into_boxed(); + // Hybrid name search: ILIKE substring (works for short queries) OR + // trigram similarity (typo tolerance); both served by the trgm index. + if let Some(term) = filter.search_term() { + query = query.filter( + dsl::display_name + .ilike(ilike_contains(term)) + .or(dsl::display_name.trgm_similar_to(term)), + ); + } + // Apply the extension constraint. A present-but-empty set matches // nothing (an active facet with no members), so apply whenever `Some`. if let Some(extensions) = filter.extensions() {