From 676cb1683186cb6d7f1e561c14dd0a35df8b9a44 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 6 Jul 2026 17:29:39 +0000 Subject: [PATCH 1/5] feat(coderd/database): add search parameter to GetChats --- coderd/database/modelqueries.go | 1 + coderd/database/querier_test.go | 233 ++++++++++++++++++++++++++++++ coderd/database/queries.sql.go | 50 ++++++- coderd/database/queries/chats.sql | 44 ++++++ 4 files changed, 326 insertions(+), 2 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index e5618d5564e41..09449aa5c9015 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -786,6 +786,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, arg.PrNumber, arg.RepoQuery, arg.PrTitleQuery, + arg.Search, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 3b5e2918ad674..434c6a6f4929c 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -9,6 +9,7 @@ import ( "net" "slices" "sort" + "strconv" "strings" "testing" "time" @@ -15240,6 +15241,238 @@ func TestGetChatsFilter(t *testing.T) { } } +func TestGetChatsSearch(t *testing.T) { + t.Parallel() + + store, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + ctx := context.Background() + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID}) + + provider := dbgen.AIProviderWithOptionalKey(t, store, database.AIProvider{ + Type: database.AIProviderTypeOpenai, + }, "test-key") + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + Model: "test-model-" + uuid.NewString(), + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + // --- helpers --- + + createRoot := func(title string) database.Chat { + t.Helper() + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: title, + }) + require.NoError(t, err) + return chat + } + + createChild := func(root database.Chat, title string) database.Chat { + t.Helper() + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: title, + ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true}, + RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true}, + }) + require.NoError(t, err) + return chat + } + + insertMsg := func(chatID uuid.UUID, role database.ChatMessageRole, visibility database.ChatMessageVisibility, text string) database.ChatMessage { + t.Helper() + msgs, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chatID, + CreatedBy: []uuid.UUID{user.ID}, + ModelConfigID: []uuid.UUID{modelCfg.ID}, + Role: []database.ChatMessageRole{role}, + Content: []string{`[{"type":"text","text":` + strconv.Quote(text) + `}]`}, + ContentVersion: []int16{1}, + Visibility: []database.ChatMessageVisibility{visibility}, + InputTokens: []int64{0}, + OutputTokens: []int64{0}, + TotalTokens: []int64{0}, + ReasoningTokens: []int64{0}, + CacheCreationTokens: []int64{0}, + CacheReadTokens: []int64{0}, + ContextLimit: []int64{0}, + Compressed: []bool{false}, + TotalCostMicros: []int64{0}, + RuntimeMs: []int64{0}, + }) + require.NoError(t, err) + require.Len(t, msgs, 1) + return msgs[0] + } + + linkPR := func(chatID uuid.UUID, url, state, prTitle string, prNumber int32, gitRemoteOrigin string) { + t.Helper() + now := time.Now() + _, err := store.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{ + ChatID: chatID, + Url: sql.NullString{String: url, Valid: true}, + GitBranch: "main", + GitRemoteOrigin: gitRemoteOrigin, + StaleAt: now.Add(time.Hour), + }) + require.NoError(t, err) + _, err = store.UpsertChatDiffStatus(ctx, database.UpsertChatDiffStatusParams{ + ChatID: chatID, + Url: sql.NullString{String: url, Valid: true}, + PullRequestState: sql.NullString{String: state, Valid: true}, + PullRequestTitle: prTitle, + PrNumber: sql.NullInt32{Int32: prNumber, Valid: prNumber > 0}, + Additions: 1, + Deletions: 1, + ChangedFiles: 1, + RefreshedAt: now, + StaleAt: now.Add(time.Hour), + }) + require.NoError(t, err) + } + + // --- fixtures --- + + titleChat := createRoot("deploy pipeline alpha") + + archivedChat := createRoot("deploy pipeline beta") + + prTitleChat := createRoot("widget work") + linkPR(prTitleChat.ID, "https://github.com/acme/widget/pull/42", "open", "Fix authentication bug", 42, "https://github.com/acme/widget.git") + + mergedChat := createRoot("other work") + linkPR(mergedChat.ID, "https://github.com/acme/other-repo/pull/7", "merged", "Fix authentication flow", 7, "https://github.com/acme/other-repo.git") + + msgChat := createRoot("plain one") + insertMsg(msgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "kubernetes cluster restart") + + deletedMsgChat := createRoot("plain two") + deletedMsg := insertMsg(deletedMsgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "terraform apply failure") + + childParent := createRoot("plain parent") + childChat := createChild(childParent, "plain child") + insertMsg(childChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityUser, "orchestrator saga") + + ineligibleChat := createRoot("plain three") + toolMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "forbidden secret token") + modelMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "forbidden secret token") + + // Backfill search_tsv through the real pipeline. Eligible rows above get + // indexed; ineligible rows keep search_tsv NULL. + _, err = store.BackfillChatMessagesSearchTsv(ctx, 1000) + require.NoError(t, err) + + // Deleting after backfill removes the row from search results even + // though its search_tsv is still populated. + err = store.SoftDeleteChatMessageByID(ctx, deletedMsg.ID) + require.NoError(t, err) + + // Inserted after backfill: search_tsv IS NULL, must match nothing. + pendingChat := createRoot("plain four") + insertMsg(pendingChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "elasticsearch indexing") + + // Force search_tsv onto ineligible rows to prove role/visibility + // predicates exclude them regardless of the vector's presence. + _, err = sqlDB.ExecContext(ctx, + `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'forbidden secret token') WHERE id = ANY($1)`, + pq.Array([]int64{toolMsg.ID, modelMsg.ID})) + require.NoError(t, err) + + _, err = store.ArchiveChatByID(ctx, archivedChat.ID) + require.NoError(t, err) + + allRootIDs := []uuid.UUID{ + titleChat.ID, archivedChat.ID, prTitleChat.ID, mergedChat.ID, + msgChat.ID, deletedMsgChat.ID, childParent.ID, ineligibleChat.ID, + pendingChat.ID, + } + + tests := []struct { + name string + params database.GetChatsParams + want []uuid.UUID + }{ + // 1. Chat title FTS: multi-word AND semantics, case-insensitive. + {"Title/Match", database.GetChatsParams{Search: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, + {"Title/CaseInsensitiveMultiWord", database.GetChatsParams{Search: "ALPHA DEPLOY"}, []uuid.UUID{titleChat.ID}}, + {"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, + // 2. PR title FTS. + {"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, + // 3. Message body FTS. + {"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}}, + // 4. PR number for all-digit searches. + {"PRNumber/Match", database.GetChatsParams{Search: "42"}, []uuid.UUID{prTitleChat.ID}}, + {"PRNumber/NonNumericNoMatch", database.GetChatsParams{Search: "42abc"}, nil}, + {"PRNumber/OversizedDigitsNoError", database.GetChatsParams{Search: "1111111111111111111111111"}, nil}, + // 5. No match anywhere. + {"NoMatch", database.GetChatsParams{Search: "zzzqqq"}, nil}, + // 6. Pending backfill (search_tsv IS NULL) matches nothing. + {"Message/PendingBackfillNoMatch", database.GetChatsParams{Search: "elasticsearch"}, nil}, + // 7. Deleted messages excluded even when backfilled. + {"Message/DeletedNoMatch", database.GetChatsParams{Search: "terraform"}, nil}, + // 8. Matching message in a child chat surfaces neither the child + // (root-only pagination) nor the parent (EXISTS is per-chat). + {"Message/ChildNotSurfaced", database.GetChatsParams{Search: "orchestrator saga"}, nil}, + // 9. Tool-role and model-only rows never match, even with a vector. + {"Message/IneligibleRolesNoMatch", database.GetChatsParams{Search: "forbidden secret"}, nil}, + // 10. Composition with other filters. + {"Composed/ArchivedDefaultIncludesAll", database.GetChatsParams{Search: "deploy pipeline"}, []uuid.UUID{titleChat.ID, archivedChat.ID}}, + {"Composed/ArchivedFalseExcludes", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: false, Valid: true}}, []uuid.UUID{titleChat.ID}}, + {"Composed/ArchivedTrueOnly", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{archivedChat.ID}}, + {"Composed/SearchAndRepo", database.GetChatsParams{Search: "authentication", RepoQuery: "acme/widget"}, []uuid.UUID{prTitleChat.ID}}, + {"Composed/SearchAndPRStatus", database.GetChatsParams{Search: "authentication", PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedChat.ID}}, + // 11. Regression: empty search leaves existing filters untouched. + {"EmptySearch/ReturnsAll", database.GetChatsParams{Search: ""}, allRootIDs}, + {"EmptySearch/TitleQueryStillWorks", database.GetChatsParams{Search: "", TitleQuery: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, + {"EmptySearch/PRTitleQueryStillWorks", database.GetChatsParams{Search: "", PrTitleQuery: "authentication bug"}, []uuid.UUID{prTitleChat.ID}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + params := tt.params + params.OwnedOnly = true + params.ViewerID = user.ID + + rows, err := store.GetChats(ctx, params) + require.NoError(t, err) + + got := make([]uuid.UUID, 0, len(rows)) + for _, row := range rows { + got = append(got, row.Chat.ID) + } + + if tt.want == nil { + require.Empty(t, got) + } else { + require.ElementsMatch(t, tt.want, got) + } + }) + } +} + func TestChatHasUnread(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index eb1fce9e6d53a..f7d1feb176d0f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8689,6 +8689,50 @@ WHERE ) ELSE true END + -- Free-text search across chat title, PR title, message bodies, and + -- PR number. websearch_to_tsquery gives AND-of-terms semantics and + -- case-insensitive matching. + AND CASE + WHEN $16::text != '' THEN ( + -- Chat title FTS (served by idx_chats_title_fts). + to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', $16) + -- PR title FTS (served by idx_chat_diff_statuses_pr_title_fts). + OR EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', $16) + ) + -- Message body FTS. The WHERE clause must repeat the partial + -- predicate of idx_chat_messages_search_tsv exactly so the + -- planner can use it. Rows pending backfill + -- (search_tsv IS NULL) match nothing by design. + OR EXISTS ( + SELECT 1 + FROM chat_messages cm + WHERE cm.chat_id = chats_expanded.id + AND cm.search_tsv IS NOT NULL + AND cm.deleted = false + AND cm.visibility IN ('user', 'both') + AND cm.role IN ('user', 'assistant') + AND cm.search_tsv @@ websearch_to_tsquery('simple', $16) + ) + -- PR number exact match for all-digit searches. The length + -- cap keeps the ::bigint cast from overflowing on absurd + -- digit strings; such searches simply match nothing. + OR ( + $16 ~ '^[0-9]{1,18}$' + AND EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND cds.pr_number IS NOT NULL + AND cds.pr_number::bigint = $16::bigint + ) + ) + ) + ELSE true + END -- Paginate over root chats only. Children are fetched -- separately via GetChildChatsByParentIDs and embedded under -- each parent. Other callers that need the full set should @@ -8705,11 +8749,11 @@ ORDER BY -chats_expanded.pin_order DESC, chats_expanded.updated_at DESC, chats_expanded.id DESC -OFFSET $16 +OFFSET $17 LIMIT -- The chat list is unbounded and expected to grow large. -- Default to 50 to prevent accidental excessively large queries. - COALESCE(NULLIF($17 :: int, 0), 50) + COALESCE(NULLIF($18 :: int, 0), 50) ` type GetChatsParams struct { @@ -8728,6 +8772,7 @@ type GetChatsParams struct { PrNumber int32 `db:"pr_number" json:"pr_number"` RepoQuery string `db:"repo_query" json:"repo_query"` PrTitleQuery string `db:"pr_title_query" json:"pr_title_query"` + Search string `db:"search" json:"search"` OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } @@ -8754,6 +8799,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha arg.PrNumber, arg.RepoQuery, arg.PrTitleQuery, + arg.Search, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 8740afb75e3d4..c7a4b2f3a0069 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -670,6 +670,50 @@ WHERE ) ELSE true END + -- Free-text search across chat title, PR title, message bodies, and + -- PR number. websearch_to_tsquery gives AND-of-terms semantics and + -- case-insensitive matching. + AND CASE + WHEN @search::text != '' THEN ( + -- Chat title FTS (served by idx_chats_title_fts). + to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search) + -- PR title FTS (served by idx_chat_diff_statuses_pr_title_fts). + OR EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search) + ) + -- Message body FTS. The WHERE clause must repeat the partial + -- predicate of idx_chat_messages_search_tsv exactly so the + -- planner can use it. Rows pending backfill + -- (search_tsv IS NULL) match nothing by design. + OR EXISTS ( + SELECT 1 + FROM chat_messages cm + WHERE cm.chat_id = chats_expanded.id + AND cm.search_tsv IS NOT NULL + AND cm.deleted = false + AND cm.visibility IN ('user', 'both') + AND cm.role IN ('user', 'assistant') + AND cm.search_tsv @@ websearch_to_tsquery('simple', @search) + ) + -- PR number exact match for all-digit searches. The length + -- cap keeps the ::bigint cast from overflowing on absurd + -- digit strings; such searches simply match nothing. + OR ( + @search ~ '^[0-9]{1,18}$' + AND EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND cds.pr_number IS NOT NULL + AND cds.pr_number::bigint = @search::bigint + ) + ) + ) + ELSE true + END -- Paginate over root chats only. Children are fetched -- separately via GetChildChatsByParentIDs and embedded under -- each parent. Other callers that need the full set should From 3e7d1d2caf4ba3f5d7f85917185a1bc0df0f2c2e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 6 Jul 2026 17:39:01 +0000 Subject: [PATCH 2/5] docs(coderd/database): tighten search comments --- coderd/database/querier_test.go | 8 ++++---- coderd/database/queries.sql.go | 6 +++--- coderd/database/queries/chats.sql | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 434c6a6f4929c..a29b28a676f96 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -15379,8 +15379,8 @@ func TestGetChatsSearch(t *testing.T) { toolMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "forbidden secret token") modelMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "forbidden secret token") - // Backfill search_tsv through the real pipeline. Eligible rows above get - // indexed; ineligible rows keep search_tsv NULL. + // Backfill search_tsv through the real pipeline. The backfill indexes + // eligible rows; ineligible rows keep search_tsv NULL. _, err = store.BackfillChatMessagesSearchTsv(ctx, 1000) require.NoError(t, err) @@ -15393,8 +15393,8 @@ func TestGetChatsSearch(t *testing.T) { pendingChat := createRoot("plain four") insertMsg(pendingChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "elasticsearch indexing") - // Force search_tsv onto ineligible rows to prove role/visibility - // predicates exclude them regardless of the vector's presence. + // Force search_tsv onto ineligible rows to prove the role and + // visibility predicates exclude them even when the vector is set. _, err = sqlDB.ExecContext(ctx, `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'forbidden secret token') WHERE id = ANY($1)`, pq.Array([]int64{toolMsg.ID, modelMsg.ID})) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f7d1feb176d0f..d4fb701923d4a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8706,7 +8706,7 @@ WHERE -- Message body FTS. The WHERE clause must repeat the partial -- predicate of idx_chat_messages_search_tsv exactly so the -- planner can use it. Rows pending backfill - -- (search_tsv IS NULL) match nothing by design. + -- (search_tsv IS NULL) match nothing. OR EXISTS ( SELECT 1 FROM chat_messages cm @@ -8718,8 +8718,8 @@ WHERE AND cm.search_tsv @@ websearch_to_tsquery('simple', $16) ) -- PR number exact match for all-digit searches. The length - -- cap keeps the ::bigint cast from overflowing on absurd - -- digit strings; such searches simply match nothing. + -- cap keeps the ::bigint cast from overflowing on oversized + -- digit strings; those searches match nothing. OR ( $16 ~ '^[0-9]{1,18}$' AND EXISTS ( diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index c7a4b2f3a0069..dab22e0bddfc0 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -687,7 +687,7 @@ WHERE -- Message body FTS. The WHERE clause must repeat the partial -- predicate of idx_chat_messages_search_tsv exactly so the -- planner can use it. Rows pending backfill - -- (search_tsv IS NULL) match nothing by design. + -- (search_tsv IS NULL) match nothing. OR EXISTS ( SELECT 1 FROM chat_messages cm @@ -699,8 +699,8 @@ WHERE AND cm.search_tsv @@ websearch_to_tsquery('simple', @search) ) -- PR number exact match for all-digit searches. The length - -- cap keeps the ::bigint cast from overflowing on absurd - -- digit strings; such searches simply match nothing. + -- cap keeps the ::bigint cast from overflowing on oversized + -- digit strings; those searches match nothing. OR ( @search ~ '^[0-9]{1,18}$' AND EXISTS ( From eb282b97b94d50d56731310fd9c3daff232bbff5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 8 Jul 2026 14:27:48 +0000 Subject: [PATCH 3/5] refactor(coderd/database): address review on GetChats search --- coderd/database/querier_test.go | 13 +------------ coderd/database/queries.sql.go | 29 ++++++++++++++--------------- coderd/database/queries/chats.sql | 29 ++++++++++++++--------------- 3 files changed, 29 insertions(+), 42 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index a29b28a676f96..474592ecdf23d 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -15414,36 +15414,25 @@ func TestGetChatsSearch(t *testing.T) { params database.GetChatsParams want []uuid.UUID }{ - // 1. Chat title FTS: multi-word AND semantics, case-insensitive. {"Title/Match", database.GetChatsParams{Search: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, {"Title/CaseInsensitiveMultiWord", database.GetChatsParams{Search: "ALPHA DEPLOY"}, []uuid.UUID{titleChat.ID}}, {"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, - // 2. PR title FTS. {"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, - // 3. Message body FTS. {"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}}, - // 4. PR number for all-digit searches. {"PRNumber/Match", database.GetChatsParams{Search: "42"}, []uuid.UUID{prTitleChat.ID}}, {"PRNumber/NonNumericNoMatch", database.GetChatsParams{Search: "42abc"}, nil}, {"PRNumber/OversizedDigitsNoError", database.GetChatsParams{Search: "1111111111111111111111111"}, nil}, - // 5. No match anywhere. {"NoMatch", database.GetChatsParams{Search: "zzzqqq"}, nil}, - // 6. Pending backfill (search_tsv IS NULL) matches nothing. {"Message/PendingBackfillNoMatch", database.GetChatsParams{Search: "elasticsearch"}, nil}, - // 7. Deleted messages excluded even when backfilled. {"Message/DeletedNoMatch", database.GetChatsParams{Search: "terraform"}, nil}, - // 8. Matching message in a child chat surfaces neither the child - // (root-only pagination) nor the parent (EXISTS is per-chat). + // Parent also excluded: EXISTS is per-chat, not per-tree. {"Message/ChildNotSurfaced", database.GetChatsParams{Search: "orchestrator saga"}, nil}, - // 9. Tool-role and model-only rows never match, even with a vector. {"Message/IneligibleRolesNoMatch", database.GetChatsParams{Search: "forbidden secret"}, nil}, - // 10. Composition with other filters. {"Composed/ArchivedDefaultIncludesAll", database.GetChatsParams{Search: "deploy pipeline"}, []uuid.UUID{titleChat.ID, archivedChat.ID}}, {"Composed/ArchivedFalseExcludes", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: false, Valid: true}}, []uuid.UUID{titleChat.ID}}, {"Composed/ArchivedTrueOnly", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{archivedChat.ID}}, {"Composed/SearchAndRepo", database.GetChatsParams{Search: "authentication", RepoQuery: "acme/widget"}, []uuid.UUID{prTitleChat.ID}}, {"Composed/SearchAndPRStatus", database.GetChatsParams{Search: "authentication", PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedChat.ID}}, - // 11. Regression: empty search leaves existing filters untouched. {"EmptySearch/ReturnsAll", database.GetChatsParams{Search: ""}, allRootIDs}, {"EmptySearch/TitleQueryStillWorks", database.GetChatsParams{Search: "", TitleQuery: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, {"EmptySearch/PRTitleQueryStillWorks", database.GetChatsParams{Search: "", PrTitleQuery: "authentication bug"}, []uuid.UUID{prTitleChat.ID}}, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d4fb701923d4a..b55e4b872a51f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8690,23 +8690,22 @@ WHERE ELSE true END -- Free-text search across chat title, PR title, message bodies, and - -- PR number. websearch_to_tsquery gives AND-of-terms semantics and - -- case-insensitive matching. + -- PR number. websearch_to_tsquery accepts quoted phrases, OR, and + -- -negation; the 'simple' config folds case and skips stemming. AND CASE WHEN $16::text != '' THEN ( - -- Chat title FTS (served by idx_chats_title_fts). + -- Served by idx_chats_title_fts. to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', $16) - -- PR title FTS (served by idx_chat_diff_statuses_pr_title_fts). + -- Served by idx_chat_diff_statuses_pr_title_fts. OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', $16) ) - -- Message body FTS. The WHERE clause must repeat the partial - -- predicate of idx_chat_messages_search_tsv exactly so the - -- planner can use it. Rows pending backfill - -- (search_tsv IS NULL) match nothing. + -- The WHERE clause must repeat the partial predicate of + -- idx_chat_messages_search_tsv exactly so the planner can use + -- it. Rows pending backfill (search_tsv IS NULL) match nothing. OR EXISTS ( SELECT 1 FROM chat_messages cm @@ -8717,19 +8716,19 @@ WHERE AND cm.role IN ('user', 'assistant') AND cm.search_tsv @@ websearch_to_tsquery('simple', $16) ) - -- PR number exact match for all-digit searches. The length - -- cap keeps the ::bigint cast from overflowing on oversized - -- digit strings; those searches match nothing. - OR ( - $16 ~ '^[0-9]{1,18}$' - AND EXISTS ( + -- CASE forces the digits guard before the ::bigint cast; AND + -- operand order is not guaranteed. The length cap prevents + -- overflow on oversized digit strings. + OR CASE + WHEN $16 ~ '^[0-9]{1,18}$' THEN EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id AND cds.pr_number IS NOT NULL AND cds.pr_number::bigint = $16::bigint ) - ) + ELSE false + END ) ELSE true END diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index dab22e0bddfc0..b8c06e0ed5bb2 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -671,23 +671,22 @@ WHERE ELSE true END -- Free-text search across chat title, PR title, message bodies, and - -- PR number. websearch_to_tsquery gives AND-of-terms semantics and - -- case-insensitive matching. + -- PR number. websearch_to_tsquery accepts quoted phrases, OR, and + -- -negation; the 'simple' config folds case and skips stemming. AND CASE WHEN @search::text != '' THEN ( - -- Chat title FTS (served by idx_chats_title_fts). + -- Served by idx_chats_title_fts. to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search) - -- PR title FTS (served by idx_chat_diff_statuses_pr_title_fts). + -- Served by idx_chat_diff_statuses_pr_title_fts. OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search) ) - -- Message body FTS. The WHERE clause must repeat the partial - -- predicate of idx_chat_messages_search_tsv exactly so the - -- planner can use it. Rows pending backfill - -- (search_tsv IS NULL) match nothing. + -- The WHERE clause must repeat the partial predicate of + -- idx_chat_messages_search_tsv exactly so the planner can use + -- it. Rows pending backfill (search_tsv IS NULL) match nothing. OR EXISTS ( SELECT 1 FROM chat_messages cm @@ -698,19 +697,19 @@ WHERE AND cm.role IN ('user', 'assistant') AND cm.search_tsv @@ websearch_to_tsquery('simple', @search) ) - -- PR number exact match for all-digit searches. The length - -- cap keeps the ::bigint cast from overflowing on oversized - -- digit strings; those searches match nothing. - OR ( - @search ~ '^[0-9]{1,18}$' - AND EXISTS ( + -- CASE forces the digits guard before the ::bigint cast; AND + -- operand order is not guaranteed. The length cap prevents + -- overflow on oversized digit strings. + OR CASE + WHEN @search ~ '^[0-9]{1,18}$' THEN EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id AND cds.pr_number IS NOT NULL AND cds.pr_number::bigint = @search::bigint ) - ) + ELSE false + END ) ELSE true END From 49047145851f335592a9b5232e5e22bd42387a61 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 8 Jul 2026 22:37:21 +0000 Subject: [PATCH 4/5] fix(coderd/database): treat whitespace-only GetChats search as empty --- coderd/database/querier_test.go | 12 ++++-------- coderd/database/queries.sql.go | 14 ++++++-------- coderd/database/queries/chats.sql | 14 ++++++-------- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 474592ecdf23d..9f5fdb253acd1 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -15269,8 +15269,6 @@ func TestGetChatsSearch(t *testing.T) { }) require.NoError(t, err) - // --- helpers --- - createRoot := func(title string) database.Chat { t.Helper() chat, err := store.InsertChat(ctx, database.InsertChatParams{ @@ -15353,8 +15351,6 @@ func TestGetChatsSearch(t *testing.T) { require.NoError(t, err) } - // --- fixtures --- - titleChat := createRoot("deploy pipeline alpha") archivedChat := createRoot("deploy pipeline beta") @@ -15379,13 +15375,12 @@ func TestGetChatsSearch(t *testing.T) { toolMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "forbidden secret token") modelMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "forbidden secret token") - // Backfill search_tsv through the real pipeline. The backfill indexes - // eligible rows; ineligible rows keep search_tsv NULL. + // Ineligible rows keep search_tsv NULL after backfill. _, err = store.BackfillChatMessagesSearchTsv(ctx, 1000) require.NoError(t, err) - // Deleting after backfill removes the row from search results even - // though its search_tsv is still populated. + // Soft-deleted rows stay excluded even though search_tsv remains + // populated. err = store.SoftDeleteChatMessageByID(ctx, deletedMsg.ID) require.NoError(t, err) @@ -15434,6 +15429,7 @@ func TestGetChatsSearch(t *testing.T) { {"Composed/SearchAndRepo", database.GetChatsParams{Search: "authentication", RepoQuery: "acme/widget"}, []uuid.UUID{prTitleChat.ID}}, {"Composed/SearchAndPRStatus", database.GetChatsParams{Search: "authentication", PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedChat.ID}}, {"EmptySearch/ReturnsAll", database.GetChatsParams{Search: ""}, allRootIDs}, + {"WhitespaceSearch/ReturnsAll", database.GetChatsParams{Search: " "}, allRootIDs}, {"EmptySearch/TitleQueryStillWorks", database.GetChatsParams{Search: "", TitleQuery: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, {"EmptySearch/PRTitleQueryStillWorks", database.GetChatsParams{Search: "", PrTitleQuery: "authentication bug"}, []uuid.UUID{prTitleChat.ID}}, } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b55e4b872a51f..dc96d5e6f1673 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8689,11 +8689,11 @@ WHERE ) ELSE true END - -- Free-text search across chat title, PR title, message bodies, and - -- PR number. websearch_to_tsquery accepts quoted phrases, OR, and - -- -negation; the 'simple' config folds case and skips stemming. + -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; + -- the 'simple' config folds case and skips stemming. btrim makes + -- whitespace-only search behave like empty. AND CASE - WHEN $16::text != '' THEN ( + WHEN btrim($16::text) != '' THEN ( -- Served by idx_chats_title_fts. to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', $16) -- Served by idx_chat_diff_statuses_pr_title_fts. @@ -8704,8 +8704,7 @@ WHERE AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', $16) ) -- The WHERE clause must repeat the partial predicate of - -- idx_chat_messages_search_tsv exactly so the planner can use - -- it. Rows pending backfill (search_tsv IS NULL) match nothing. + -- idx_chat_messages_search_tsv exactly so the planner can use it. OR EXISTS ( SELECT 1 FROM chat_messages cm @@ -8717,8 +8716,7 @@ WHERE AND cm.search_tsv @@ websearch_to_tsquery('simple', $16) ) -- CASE forces the digits guard before the ::bigint cast; AND - -- operand order is not guaranteed. The length cap prevents - -- overflow on oversized digit strings. + -- operand order is not guaranteed. OR CASE WHEN $16 ~ '^[0-9]{1,18}$' THEN EXISTS ( SELECT 1 diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index b8c06e0ed5bb2..8ee20dbe7364a 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -670,11 +670,11 @@ WHERE ) ELSE true END - -- Free-text search across chat title, PR title, message bodies, and - -- PR number. websearch_to_tsquery accepts quoted phrases, OR, and - -- -negation; the 'simple' config folds case and skips stemming. + -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; + -- the 'simple' config folds case and skips stemming. btrim makes + -- whitespace-only search behave like empty. AND CASE - WHEN @search::text != '' THEN ( + WHEN btrim(@search::text) != '' THEN ( -- Served by idx_chats_title_fts. to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search) -- Served by idx_chat_diff_statuses_pr_title_fts. @@ -685,8 +685,7 @@ WHERE AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search) ) -- The WHERE clause must repeat the partial predicate of - -- idx_chat_messages_search_tsv exactly so the planner can use - -- it. Rows pending backfill (search_tsv IS NULL) match nothing. + -- idx_chat_messages_search_tsv exactly so the planner can use it. OR EXISTS ( SELECT 1 FROM chat_messages cm @@ -698,8 +697,7 @@ WHERE AND cm.search_tsv @@ websearch_to_tsquery('simple', @search) ) -- CASE forces the digits guard before the ::bigint cast; AND - -- operand order is not guaranteed. The length cap prevents - -- overflow on oversized digit strings. + -- operand order is not guaranteed. OR CASE WHEN @search ~ '^[0-9]{1,18}$' THEN EXISTS ( SELECT 1 From 7b927dd35436ab9315f8265a11ae29bcb8b518b5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 9 Jul 2026 07:18:42 +0000 Subject: [PATCH 5/5] test(coderd/database): cover all eligible GetChats search message pairs --- coderd/database/querier_test.go | 28 +++++++++++++++++++++------- coderd/database/queries.sql.go | 5 ++--- coderd/database/queries/chats.sql | 5 ++--- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 9f5fdb253acd1..03247ec8378c9 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -15364,6 +15364,15 @@ func TestGetChatsSearch(t *testing.T) { msgChat := createRoot("plain one") insertMsg(msgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "kubernetes cluster restart") + assistantMsgChat := createRoot("plain assistant") + insertMsg(assistantMsgChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, "grafana dashboard tuning") + + userVisMsgChat := createRoot("plain uservis") + insertMsg(userVisMsgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityUser, "vault token rotation") + + assistantUserVisMsgChat := createRoot("plain assistant uservis") + insertMsg(assistantUserVisMsgChat.ID, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityUser, "redis eviction policy") + deletedMsgChat := createRoot("plain two") deletedMsg := insertMsg(deletedMsgChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "terraform apply failure") @@ -15373,7 +15382,7 @@ func TestGetChatsSearch(t *testing.T) { ineligibleChat := createRoot("plain three") toolMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "forbidden secret token") - modelMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "forbidden secret token") + modelOnlyMsg := insertMsg(ineligibleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "forbidden secret token") // Ineligible rows keep search_tsv NULL after backfill. _, err = store.BackfillChatMessagesSearchTsv(ctx, 1000) @@ -15388,11 +15397,11 @@ func TestGetChatsSearch(t *testing.T) { pendingChat := createRoot("plain four") insertMsg(pendingChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "elasticsearch indexing") - // Force search_tsv onto ineligible rows to prove the role and - // visibility predicates exclude them even when the vector is set. + // Prove role/visibility predicates exclude rows even when search_tsv + // is set. _, err = sqlDB.ExecContext(ctx, `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'forbidden secret token') WHERE id = ANY($1)`, - pq.Array([]int64{toolMsg.ID, modelMsg.ID})) + pq.Array([]int64{toolMsg.ID, modelOnlyMsg.ID})) require.NoError(t, err) _, err = store.ArchiveChatByID(ctx, archivedChat.ID) @@ -15400,8 +15409,9 @@ func TestGetChatsSearch(t *testing.T) { allRootIDs := []uuid.UUID{ titleChat.ID, archivedChat.ID, prTitleChat.ID, mergedChat.ID, - msgChat.ID, deletedMsgChat.ID, childParent.ID, ineligibleChat.ID, - pendingChat.ID, + msgChat.ID, assistantMsgChat.ID, userVisMsgChat.ID, + assistantUserVisMsgChat.ID, deletedMsgChat.ID, childParent.ID, + ineligibleChat.ID, pendingChat.ID, } tests := []struct { @@ -15414,6 +15424,9 @@ func TestGetChatsSearch(t *testing.T) { {"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, {"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, {"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}}, + {"Message/AssistantRoleMatch", database.GetChatsParams{Search: "grafana tuning"}, []uuid.UUID{assistantMsgChat.ID}}, + {"Message/UserVisibilityMatch", database.GetChatsParams{Search: "vault rotation"}, []uuid.UUID{userVisMsgChat.ID}}, + {"Message/AssistantUserVisibilityMatch", database.GetChatsParams{Search: "redis eviction"}, []uuid.UUID{assistantUserVisMsgChat.ID}}, {"PRNumber/Match", database.GetChatsParams{Search: "42"}, []uuid.UUID{prTitleChat.ID}}, {"PRNumber/NonNumericNoMatch", database.GetChatsParams{Search: "42abc"}, nil}, {"PRNumber/OversizedDigitsNoError", database.GetChatsParams{Search: "1111111111111111111111111"}, nil}, @@ -15422,7 +15435,7 @@ func TestGetChatsSearch(t *testing.T) { {"Message/DeletedNoMatch", database.GetChatsParams{Search: "terraform"}, nil}, // Parent also excluded: EXISTS is per-chat, not per-tree. {"Message/ChildNotSurfaced", database.GetChatsParams{Search: "orchestrator saga"}, nil}, - {"Message/IneligibleRolesNoMatch", database.GetChatsParams{Search: "forbidden secret"}, nil}, + {"Message/IneligibleMessagesNoMatch", database.GetChatsParams{Search: "forbidden secret"}, nil}, {"Composed/ArchivedDefaultIncludesAll", database.GetChatsParams{Search: "deploy pipeline"}, []uuid.UUID{titleChat.ID, archivedChat.ID}}, {"Composed/ArchivedFalseExcludes", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: false, Valid: true}}, []uuid.UUID{titleChat.ID}}, {"Composed/ArchivedTrueOnly", database.GetChatsParams{Search: "deploy pipeline", Archived: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{archivedChat.ID}}, @@ -15430,6 +15443,7 @@ func TestGetChatsSearch(t *testing.T) { {"Composed/SearchAndPRStatus", database.GetChatsParams{Search: "authentication", PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedChat.ID}}, {"EmptySearch/ReturnsAll", database.GetChatsParams{Search: ""}, allRootIDs}, {"WhitespaceSearch/ReturnsAll", database.GetChatsParams{Search: " "}, allRootIDs}, + {"TabOnlySearch/ReturnsAll", database.GetChatsParams{Search: "\t\t"}, allRootIDs}, {"EmptySearch/TitleQueryStillWorks", database.GetChatsParams{Search: "", TitleQuery: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, {"EmptySearch/PRTitleQueryStillWorks", database.GetChatsParams{Search: "", PrTitleQuery: "authentication bug"}, []uuid.UUID{prTitleChat.ID}}, } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index dc96d5e6f1673..c8c00a2bf6ae5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8690,10 +8690,9 @@ WHERE ELSE true END -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'simple' config folds case and skips stemming. btrim makes - -- whitespace-only search behave like empty. + -- the 'simple' config folds case and skips stemming. AND CASE - WHEN btrim($16::text) != '' THEN ( + WHEN btrim($16::text, E' \t\n\r') != '' THEN ( -- Served by idx_chats_title_fts. to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', $16) -- Served by idx_chat_diff_statuses_pr_title_fts. diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 8ee20dbe7364a..7a0879775711d 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -671,10 +671,9 @@ WHERE ELSE true END -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'simple' config folds case and skips stemming. btrim makes - -- whitespace-only search behave like empty. + -- the 'simple' config folds case and skips stemming. AND CASE - WHEN btrim(@search::text) != '' THEN ( + WHEN btrim(@search::text, E' \t\n\r') != '' THEN ( -- Served by idx_chats_title_fts. to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search) -- Served by idx_chat_diff_statuses_pr_title_fts.