Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/nvisy-postgres/src/query/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions crates/nvisy-postgres/src/query/search.rs
Original file line number Diff line number Diff line change
@@ -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(""), "%%");
}
}
7 changes: 6 additions & 1 deletion crates/nvisy-postgres/src/query/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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)
Expand Down
28 changes: 24 additions & 4 deletions crates/nvisy-postgres/src/query/workspace_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Apply the extension constraint. A present-but-empty set matches
Expand Down Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions crates/nvisy-postgres/src/query/workspace_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -99,7 +100,8 @@ pub trait WorkspacePipelineRepository {
status: PipelineStatus,
) -> impl Future<Output = PgResult<i64>> + 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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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)
Expand Down