From 5ae74a00fe84a454b54c562a6e9042f80cc8a4fa Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Tue, 26 Aug 2025 14:00:51 +0000 Subject: [PATCH 1/4] feat: support spaces in search and search by display name in templates --- cli/testdata/coder_users_list.golden | 4 +- .../coder_users_list_--output_json.golden | 37 +--------------- coderd/database/modelqueries.go | 2 + coderd/database/queries.sql.go | 44 +++++++++++++------ coderd/database/queries/templates.sql | 12 +++++ coderd/searchquery/search.go | 26 ++++++++++- coderd/searchquery/search_test.go | 43 +++++++++++++++++- 7 files changed, 112 insertions(+), 56 deletions(-) diff --git a/cli/testdata/coder_users_list.golden b/cli/testdata/coder_users_list.golden index 6aa417a969a4e..a329fabe9cc38 100644 --- a/cli/testdata/coder_users_list.golden +++ b/cli/testdata/coder_users_list.golden @@ -1,3 +1 @@ -USERNAME EMAIL CREATED AT STATUS -testuser testuser@coder.com ====[timestamp]===== active -testuser2 testuser2@coder.com ====[timestamp]===== dormant +USERNAME EMAIL CREATED AT STATUS diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index 7243200f6bdb1..fe51488c7066f 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -1,36 +1 @@ -[ - { - "id": "==========[first user ID]===========", - "username": "testuser", - "name": "Test User", - "email": "testuser@coder.com", - "created_at": "====[timestamp]=====", - "updated_at": "====[timestamp]=====", - "last_seen_at": "====[timestamp]=====", - "status": "active", - "login_type": "password", - "organization_ids": [ - "===========[first org ID]===========" - ], - "roles": [ - { - "name": "owner", - "display_name": "Owner" - } - ] - }, - { - "id": "==========[second user ID]==========", - "username": "testuser2", - "email": "testuser2@coder.com", - "created_at": "====[timestamp]=====", - "updated_at": "====[timestamp]=====", - "last_seen_at": "====[timestamp]=====", - "status": "dormant", - "login_type": "password", - "organization_ids": [ - "===========[first org ID]===========" - ], - "roles": [] - } -] +[] diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 69bea8d81adab..b558bba91efde 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -78,7 +78,9 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate arg.Deleted, arg.OrganizationID, arg.ExactName, + arg.ExactDisplayName, arg.FuzzyName, + arg.FuzzyDisplayName, pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3a41cf63c1630..55d0a758c5b6a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12184,23 +12184,35 @@ WHERE LOWER(t.name) = LOWER($3) ELSE true END - -- Filter by name, matching on substring + -- Filter by exact display name AND CASE WHEN $4 :: text != '' THEN - lower(t.name) ILIKE '%' || lower($4) || '%' + LOWER(t.display_name) = LOWER($4) + ELSE true + END + -- Filter by name, matching on substring + AND CASE + WHEN $5 :: text != '' THEN + lower(t.name) ILIKE '%' || lower($5) || '%' + ELSE true + END + -- Filter by display_name, matching on substring + AND CASE + WHEN $6 :: text != '' THEN + lower(t.display_name) ILIKE '%' || lower($6) || '%' ELSE true END -- Filter by ids AND CASE - WHEN array_length($5 :: uuid[], 1) > 0 THEN - t.id = ANY($5) + WHEN array_length($7 :: uuid[], 1) > 0 THEN + t.id = ANY($7) ELSE true END -- Filter by deprecated AND CASE - WHEN $6 :: boolean IS NOT NULL THEN + WHEN $8 :: boolean IS NOT NULL THEN CASE - WHEN $6 :: boolean THEN + WHEN $8 :: boolean THEN t.deprecated != '' ELSE t.deprecated = '' @@ -12209,27 +12221,27 @@ WHERE END -- Filter by has_ai_task in latest version AND CASE - WHEN $7 :: boolean IS NOT NULL THEN - tv.has_ai_task = $7 :: boolean + WHEN $9 :: boolean IS NOT NULL THEN + tv.has_ai_task = $9 :: boolean ELSE true END -- Filter by author_id AND CASE - WHEN $8 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - t.created_by = $8 + WHEN $10 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + t.created_by = $10 ELSE true END -- Filter by author_username AND CASE - WHEN $9 :: text != '' THEN - t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($9) AND deleted = false) + WHEN $11 :: text != '' THEN + t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($11) AND deleted = false) ELSE true END -- Filter by has_external_agent in latest version AND CASE - WHEN $10 :: boolean IS NOT NULL THEN - tv.has_external_agent = $10 :: boolean + WHEN $12 :: boolean IS NOT NULL THEN + tv.has_external_agent = $12 :: boolean ELSE true END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates @@ -12241,7 +12253,9 @@ type GetTemplatesWithFilterParams struct { Deleted bool `db:"deleted" json:"deleted"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` ExactName string `db:"exact_name" json:"exact_name"` + ExactDisplayName string `db:"exact_display_name" json:"exact_display_name"` FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` + FuzzyDisplayName string `db:"fuzzy_display_name" json:"fuzzy_display_name"` IDs []uuid.UUID `db:"ids" json:"ids"` Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` @@ -12255,7 +12269,9 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate arg.Deleted, arg.OrganizationID, arg.ExactName, + arg.ExactDisplayName, arg.FuzzyName, + arg.FuzzyDisplayName, pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 4bb70c6580503..41d212d5fd01d 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -30,12 +30,24 @@ WHERE LOWER(t.name) = LOWER(@exact_name) ELSE true END + -- Filter by exact display name + AND CASE + WHEN @exact_display_name :: text != '' THEN + LOWER(t.display_name) = LOWER(@exact_display_name) + ELSE true + END -- Filter by name, matching on substring AND CASE WHEN @fuzzy_name :: text != '' THEN lower(t.name) ILIKE '%' || lower(@fuzzy_name) || '%' ELSE true END + -- Filter by display_name, matching on substring + AND CASE + WHEN @fuzzy_display_name :: text != '' THEN + lower(t.display_name) ILIKE '%' || lower(@fuzzy_display_name) || '%' + ELSE true + END -- Filter by ids AND CASE WHEN array_length(@ids :: uuid[], 1) > 0 THEN diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 974872973606c..430a0a86259d8 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -268,8 +268,9 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query // Always lowercase for all searches. query = strings.ToLower(query) values, errors := searchTerms(query, func(term string, values url.Values) error { - // Default to the template name + // Default to the template name and display name values.Add("name", term) + values.Add("display_name", term) return nil }) if len(errors) > 0 { @@ -281,7 +282,9 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query Deleted: parser.Boolean(values, false, "deleted"), OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), ExactName: parser.String(values, "", "exact_name"), + ExactDisplayName: parser.String(values, "", "exact_display_name"), FuzzyName: parser.String(values, "", "name"), + FuzzyDisplayName: parser.String(values, "", "display_name"), IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), @@ -305,7 +308,8 @@ func searchTerms(query string, defaultKey func(term string, values url.Values) e // Because we do this in 2 passes, we want to maintain quotes on the first // pass. Further splitting occurs on the second pass and quotes will be // dropped. - elements := splitQueryParameterByDelimiter(query, ' ', true) + tokens := splitQueryParameterByDelimiter(query, ' ', true) + elements := processTokens(tokens) for _, element := range elements { if strings.HasPrefix(element, ":") || strings.HasSuffix(element, ":") { return nil, []codersdk.ValidationError{ @@ -385,3 +389,21 @@ func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes return parts } + +// processTokens takes the split tokens and groups them based on a delimiter. Tokens +// without a delimiter present are joined to support searching with spaces. +func processTokens(tokens []string) []string { + var results []string + var nonFieldTerms []string + for _, token := range tokens { + if strings.Contains(token, string(':')) { + results = append(results, token) + } else { + nonFieldTerms = append(nonFieldTerms, token) + } + } + if len(nonFieldTerms) > 0 { + results = append(results, strings.Join(nonFieldTerms, " ")) + } + return results +} diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 2a8f4cd6cbb56..ced0ea04a529e 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -686,7 +686,8 @@ func TestSearchTemplates(t *testing.T) { Name: "OnlyName", Query: "foobar", Expected: database.GetTemplatesWithFilterParams{ - FuzzyName: "foobar", + FuzzyName: "foobar", + FuzzyDisplayName: "foobar", }, }, { @@ -757,6 +758,46 @@ func TestSearchTemplates(t *testing.T) { AuthorID: userID, }, }, + { + Name: "SearchOnNameAndDisplayName", + Query: "test name", + Expected: database.GetTemplatesWithFilterParams{ + FuzzyName: "test name", + FuzzyDisplayName: "test name", + }, + }, + { + Name: "NameField", + Query: "name:testname", + Expected: database.GetTemplatesWithFilterParams{ + FuzzyName: "testname", + }, + }, + { + Name: "QuotedValue", + Query: `name:"test name"`, + Expected: database.GetTemplatesWithFilterParams{ + FuzzyName: "test name", + }, + }, + { + Name: "MultipleTerms", + Query: `foo bar exact_name:"test display name"`, + Expected: database.GetTemplatesWithFilterParams{ + ExactName: "test display name", + FuzzyName: "foo bar", + FuzzyDisplayName: "foo bar", + }, + }, + { + Name: "FieldAndSpaces", + Query: "deprecated:false test template", + Expected: database.GetTemplatesWithFilterParams{ + Deprecated: sql.NullBool{Bool: false, Valid: true}, + FuzzyName: "test template", + FuzzyDisplayName: "test template", + }, + }, } for _, c := range testCases { From caee61c19d026ee320d2853ecc9696ca6cedb2de Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Tue, 26 Aug 2025 15:16:41 +0000 Subject: [PATCH 2/4] Run make gen golden files --- cli/testdata/coder_users_list.golden | 4 +- .../coder_users_list_--output_json.golden | 37 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cli/testdata/coder_users_list.golden b/cli/testdata/coder_users_list.golden index a329fabe9cc38..6aa417a969a4e 100644 --- a/cli/testdata/coder_users_list.golden +++ b/cli/testdata/coder_users_list.golden @@ -1 +1,3 @@ -USERNAME EMAIL CREATED AT STATUS +USERNAME EMAIL CREATED AT STATUS +testuser testuser@coder.com ====[timestamp]===== active +testuser2 testuser2@coder.com ====[timestamp]===== dormant diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index fe51488c7066f..7243200f6bdb1 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -1 +1,36 @@ -[] +[ + { + "id": "==========[first user ID]===========", + "username": "testuser", + "name": "Test User", + "email": "testuser@coder.com", + "created_at": "====[timestamp]=====", + "updated_at": "====[timestamp]=====", + "last_seen_at": "====[timestamp]=====", + "status": "active", + "login_type": "password", + "organization_ids": [ + "===========[first org ID]===========" + ], + "roles": [ + { + "name": "owner", + "display_name": "Owner" + } + ] + }, + { + "id": "==========[second user ID]==========", + "username": "testuser2", + "email": "testuser2@coder.com", + "created_at": "====[timestamp]=====", + "updated_at": "====[timestamp]=====", + "last_seen_at": "====[timestamp]=====", + "status": "dormant", + "login_type": "password", + "organization_ids": [ + "===========[first org ID]===========" + ], + "roles": [] + } +] From 8c780dc5c7172e44b6239bc66723f110688bed43 Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Tue, 26 Aug 2025 23:11:20 +0000 Subject: [PATCH 3/4] search on display_name, default to name if display_name is empty --- coderd/database/queries.sql.go | 10 ++++++++-- coderd/database/queries/templates.sql | 10 ++++++++-- coderd/searchquery/search.go | 3 +-- coderd/searchquery/search_test.go | 6 +----- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 55d0a758c5b6a..cd9a8311cf62f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12196,10 +12196,16 @@ WHERE lower(t.name) ILIKE '%' || lower($5) || '%' ELSE true END - -- Filter by display_name, matching on substring + -- Filter by display_name, matching on substring (fallback to name if display_name is empty) AND CASE WHEN $6 :: text != '' THEN - lower(t.display_name) ILIKE '%' || lower($6) || '%' + CASE + WHEN t.display_name IS NOT NULL AND t.display_name != '' THEN + lower(t.display_name) ILIKE '%' || lower($6) || '%' + ELSE + -- Remove spaces if present since 't.name' cannot have any spaces + lower(t.name) ILIKE '%' || REPLACE(lower($6), ' ', '') || '%' + END ELSE true END -- Filter by ids diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 41d212d5fd01d..e819f46580cb0 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -42,10 +42,16 @@ WHERE lower(t.name) ILIKE '%' || lower(@fuzzy_name) || '%' ELSE true END - -- Filter by display_name, matching on substring + -- Filter by display_name, matching on substring (fallback to name if display_name is empty) AND CASE WHEN @fuzzy_display_name :: text != '' THEN - lower(t.display_name) ILIKE '%' || lower(@fuzzy_display_name) || '%' + CASE + WHEN t.display_name IS NOT NULL AND t.display_name != '' THEN + lower(t.display_name) ILIKE '%' || lower(@fuzzy_display_name) || '%' + ELSE + -- Remove spaces if present since 't.name' cannot have any spaces + lower(t.name) ILIKE '%' || REPLACE(lower(@fuzzy_display_name), ' ', '') || '%' + END ELSE true END -- Filter by ids diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 430a0a86259d8..adac2e3cfb981 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -268,8 +268,7 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query // Always lowercase for all searches. query = strings.ToLower(query) values, errors := searchTerms(query, func(term string, values url.Values) error { - // Default to the template name and display name - values.Add("name", term) + // Default to the display name values.Add("display_name", term) return nil }) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index ced0ea04a529e..5c52e1585164b 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -686,7 +686,6 @@ func TestSearchTemplates(t *testing.T) { Name: "OnlyName", Query: "foobar", Expected: database.GetTemplatesWithFilterParams{ - FuzzyName: "foobar", FuzzyDisplayName: "foobar", }, }, @@ -759,10 +758,9 @@ func TestSearchTemplates(t *testing.T) { }, }, { - Name: "SearchOnNameAndDisplayName", + Name: "SearchOnDisplayName", Query: "test name", Expected: database.GetTemplatesWithFilterParams{ - FuzzyName: "test name", FuzzyDisplayName: "test name", }, }, @@ -785,7 +783,6 @@ func TestSearchTemplates(t *testing.T) { Query: `foo bar exact_name:"test display name"`, Expected: database.GetTemplatesWithFilterParams{ ExactName: "test display name", - FuzzyName: "foo bar", FuzzyDisplayName: "foo bar", }, }, @@ -794,7 +791,6 @@ func TestSearchTemplates(t *testing.T) { Query: "deprecated:false test template", Expected: database.GetTemplatesWithFilterParams{ Deprecated: sql.NullBool{Bool: false, Valid: true}, - FuzzyName: "test template", FuzzyDisplayName: "test template", }, }, From d4c1392da1e31b55fccbea9ffcbf18922993cff8 Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Mon, 8 Sep 2025 21:54:33 +0000 Subject: [PATCH 4/4] add example input/output to processTokens function documentation --- coderd/searchquery/search.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index adac2e3cfb981..0ab700fbee501 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -389,8 +389,11 @@ func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes return parts } -// processTokens takes the split tokens and groups them based on a delimiter. Tokens -// without a delimiter present are joined to support searching with spaces. +// processTokens takes the split tokens and groups them based on a delimiter (':'). +// Tokens without a delimiter present are joined to support searching with spaces. +// +// Example Input: ['deprecated:false', 'test', 'template'] +// Example Output: ['deprecated:false', 'test template'] func processTokens(tokens []string) []string { var results []string var nonFieldTerms []string