diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 69bea8d81a..b558bba91e 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 af975247f6..f96f5489c7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12230,23 +12230,41 @@ 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 (fallback to name if display_name is empty) + AND CASE + WHEN $6 :: text != '' THEN + 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 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 = '' @@ -12255,27 +12273,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 @@ -12287,7 +12305,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"` @@ -12301,7 +12321,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 05b663aca4..43f1aea6c5 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -30,12 +30,30 @@ 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 (fallback to name if display_name is empty) + AND CASE + WHEN @fuzzy_display_name :: text != '' THEN + 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 AND CASE WHEN array_length(@ids :: uuid[], 1) > 0 THEN diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 9748729736..0ab700fbee 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -268,8 +268,8 @@ 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 - values.Add("name", term) + // Default to the display name + values.Add("display_name", term) return nil }) if len(errors) > 0 { @@ -281,7 +281,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 +307,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 +388,24 @@ 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. +// +// Example Input: ['deprecated:false', 'test', 'template'] +// Example Output: ['deprecated:false', 'test template'] +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 2a8f4cd6cb..5c52e15851 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -686,7 +686,7 @@ func TestSearchTemplates(t *testing.T) { Name: "OnlyName", Query: "foobar", Expected: database.GetTemplatesWithFilterParams{ - FuzzyName: "foobar", + FuzzyDisplayName: "foobar", }, }, { @@ -757,6 +757,43 @@ func TestSearchTemplates(t *testing.T) { AuthorID: userID, }, }, + { + Name: "SearchOnDisplayName", + Query: "test name", + Expected: database.GetTemplatesWithFilterParams{ + 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", + FuzzyDisplayName: "foo bar", + }, + }, + { + Name: "FieldAndSpaces", + Query: "deprecated:false test template", + Expected: database.GetTemplatesWithFilterParams{ + Deprecated: sql.NullBool{Bool: false, Valid: true}, + FuzzyDisplayName: "test template", + }, + }, } for _, c := range testCases {