diff --git a/crates/nvisy-postgres/src/query/mod.rs b/crates/nvisy-postgres/src/query/mod.rs index 09703c8..80e3cb2 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 0000000..bf274c1 --- /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 58e287e..206b04a 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 8a75950..6ebeca0 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, @@ -604,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() { @@ -651,9 +662,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 +701,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 7c38f06..f3d1e77 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)