Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commite254d6e

Browse files
committed
add searchquery support for provisioner jobs
1 parent0565f0a commite254d6e

File tree

2 files changed

+305
-10
lines changed

2 files changed

+305
-10
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package searchquery_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"strings"
8+
9+
"github.com/google/uuid"
10+
11+
"github.com/coder/coder/v2/coderd/database"
12+
"github.com/coder/coder/v2/coderd/database/dbgen"
13+
"github.com/coder/coder/v2/coderd/database/dbtestutil"
14+
"github.com/coder/coder/v2/coderd/searchquery"
15+
"github.com/coder/coder/v2/codersdk"
16+
)
17+
18+
funcTestProvisionerJobs(t*testing.T) {
19+
t.Parallel()
20+
21+
ctx:=context.Background()
22+
db,_:=dbtestutil.NewDB(t)
23+
page:= codersdk.Pagination{Limit:10}
24+
25+
// Create test data
26+
org:=dbgen.Organization(t,db, database.Organization{})
27+
user:=dbgen.User(t,db, database.User{})
28+
_=dbgen.OrganizationMember(t,db, database.OrganizationMember{
29+
UserID:user.ID,
30+
OrganizationID:org.ID,
31+
})
32+
33+
testCases:= []struct {
34+
namestring
35+
querystring
36+
expectedErrors []string// Expected error messages (empty slice means no errors expected)
37+
validateFilterfunc(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams)
38+
}{
39+
{
40+
name:"EmptyQuery",
41+
query:"",
42+
expectedErrors:nil,// No errors expected
43+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
44+
iffilter.OrganizationID!=uuid.Nil {
45+
t.Errorf("Expected empty organization ID, got: %v",filter.OrganizationID)
46+
}
47+
},
48+
},
49+
{
50+
name:"SingleStatus",
51+
query:"status:running",
52+
expectedErrors:nil,// No errors expected
53+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
54+
iflen(filter.Status)!=1 {
55+
t.Errorf("Expected 1 status, got: %d",len(filter.Status))
56+
}
57+
iffilter.Status[0]!=database.ProvisionerJobStatusRunning {
58+
t.Errorf("Expected status 'running', got: %v",filter.Status[0])
59+
}
60+
},
61+
},
62+
{
63+
name:"MultipleStatuses",
64+
query:"status:running status:pending",
65+
expectedErrors:nil,// No errors expected
66+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
67+
iflen(filter.Status)!=2 {
68+
t.Errorf("Expected 2 statuses, got: %d",len(filter.Status))
69+
}
70+
},
71+
},
72+
{
73+
name:"InitiatorFilter",
74+
query:"initiator:"+user.Username,
75+
expectedErrors:nil,// No errors expected
76+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
77+
iffilter.InitiatorID!=user.ID {
78+
t.Errorf("Expected initiator ID %v, got: %v",user.ID,filter.InitiatorID)
79+
}
80+
},
81+
},
82+
{
83+
name:"ComplexQuery",
84+
query:"status:running status:pending initiator:"+user.Username+" organization:"+org.Name,
85+
expectedErrors:nil,// No errors expected
86+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
87+
iflen(filter.Status)!=2 {
88+
t.Errorf("Expected 2 statuses, got: %d",len(filter.Status))
89+
}
90+
iffilter.OrganizationID!=org.ID {
91+
t.Errorf("Expected organization ID %v, got: %v",org.ID,filter.OrganizationID)
92+
}
93+
iffilter.InitiatorID!=user.ID {
94+
t.Errorf("Expected initiator ID %v, got: %v",user.ID,filter.InitiatorID)
95+
}
96+
},
97+
},
98+
{
99+
name:"InvalidQuery",
100+
query:"invalid:format:with:too:many:colons",
101+
expectedErrors: []string{"can only contain 1 ':'"},
102+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
103+
// No validation needed for invalid queries
104+
},
105+
},
106+
{
107+
name:"InvalidUser",
108+
query:"initiator:nonexistentuser",
109+
expectedErrors: []string{"either does not exist, or you are unauthorized to view them"},
110+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
111+
// No validation needed for invalid queries
112+
},
113+
},
114+
{
115+
name:"InvalidOrganization",
116+
query:"organization:nonexistentorg",
117+
expectedErrors: []string{"either does not exist, or you are unauthorized to view it"},
118+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
119+
// No validation needed for invalid queries
120+
},
121+
},
122+
{
123+
name:"FreeFormSearchNotSupported",
124+
query:"some random search term",
125+
expectedErrors: []string{"Free-form search terms are not supported for provisioner jobs"},
126+
validateFilter:func(t*testing.T,filter database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams) {
127+
// No validation needed for invalid queries
128+
},
129+
},
130+
}
131+
132+
for_,tc:=rangetestCases {
133+
tc:=tc
134+
t.Run(tc.name,func(t*testing.T) {
135+
t.Parallel()
136+
137+
filter,errors:=searchquery.ProvisionerJobs(ctx,db,tc.query,page)
138+
139+
iflen(tc.expectedErrors)==0 {
140+
// No errors expected
141+
iflen(errors)>0 {
142+
t.Fatalf("Expected no errors for query %q, got: %v",tc.query,errors)
143+
}
144+
tc.validateFilter(t,filter)
145+
}else {
146+
// Specific errors expected
147+
iflen(errors)==0 {
148+
t.Fatalf("Expected errors for query %q, but got none",tc.query)
149+
}
150+
151+
// Check that we got the expected number of errors
152+
iflen(errors)!=len(tc.expectedErrors) {
153+
t.Errorf("Expected %d errors, got %d: %v",len(tc.expectedErrors),len(errors),errors)
154+
}
155+
156+
// Check that each expected error message is contained in the actual errors
157+
errorMessages:=make([]string,len(errors))
158+
fori,err:=rangeerrors {
159+
errorMessages[i]=err.Detail
160+
}
161+
162+
fori,expectedError:=rangetc.expectedErrors {
163+
ifi>=len(errorMessages) {
164+
t.Errorf("Expected error %d: %q, but got no error",i,expectedError)
165+
continue
166+
}
167+
if!strings.Contains(errorMessages[i],expectedError) {
168+
t.Errorf("Expected error %d to contain %q, got: %q",i,expectedError,errorMessages[i])
169+
}
170+
}
171+
}
172+
})
173+
}
174+
}

‎coderd/searchquery/search.go‎

Lines changed: 131 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,59 @@
1+
// Package searchquery provides a unified search interface for Coder entities.
2+
//
3+
// The package parses human-readable search queries into structured database
4+
// parameters that can be used to filter database queries efficiently.
5+
//
6+
// # Search Query Format
7+
//
8+
// The package supports two types of search terms:
9+
//
10+
// 1. Key-Value Pairs (with colon):
11+
// - "owner:prebuilds" → filters by owner username
12+
// - "template:my-template" → filters by template name
13+
// - "status:running" → filters by status
14+
//
15+
// 2. Free-form Terms (without colon):
16+
// - "my workspace" → uses entity-specific default search field
17+
// - For workspaces: searches by workspace name
18+
// - For templates: searches by template display name
19+
// - For provisioner jobs: searches by job type
20+
//
21+
// # Query Processing
22+
//
23+
// The searchTerms() function is the core parser that:
24+
// - Splits queries by spaces while preserving quoted strings
25+
// - Groups non-field terms together for free-form search
26+
// - Validates query syntax and returns clear error messages
27+
// - Converts parsed terms into url.Values for further processing
28+
//
29+
// Each entity type (Workspaces, Templates, ProvisionerJobs, etc.) has its
30+
// own search function that:
31+
// - Uses the generic searchTerms() parser
32+
// - Maps search parameters to database filter structs
33+
// - Handles entity-specific logic (like "me" → current user)
34+
// - Returns database parameters for SQL queries
35+
//
36+
// # Performance
37+
//
38+
// The searchquery package only parses queries and generates database parameters.
39+
// No in-memory filtering is performed.
40+
//
41+
// Example Usage
42+
//
43+
//// Parse workspace search
44+
//filter, errors := searchquery.Workspaces(ctx, db, "owner:prebuilds template:my-template", page, timeout)
45+
//if len(errors) > 0 {
46+
// return errors
47+
//}
48+
//
49+
//// Use filter in database query
50+
//workspaces, err := db.GetWorkspaces(ctx, filter)
151
package searchquery
252

353
import (
454
"context"
555
"database/sql"
56+
"encoding/json"
657
"fmt"
758
"net/url"
859
"strings"
@@ -36,8 +87,6 @@ import (
3687
funcAuditLogs(ctx context.Context,db database.Store,querystring) (database.GetAuditLogsOffsetParams,
3788
database.CountAuditLogsParams, []codersdk.ValidationError,
3889
) {
39-
// Always lowercase for all searches.
40-
query=strings.ToLower(query)
4190
values,errors:=searchTerms(query,func(termstring,values url.Values)error {
4291
values.Add("resource_type",term)
4392
returnnil
@@ -87,8 +136,6 @@ func AuditLogs(ctx context.Context, db database.Store, query string) (database.G
87136
}
88137

89138
funcConnectionLogs(ctx context.Context,db database.Store,querystring,apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, database.CountConnectionLogsParams, []codersdk.ValidationError) {
90-
// Always lowercase for all searches.
91-
query=strings.ToLower(query)
92139
values,errors:=searchTerms(query,func(termstring,values url.Values)error {
93140
values.Add("search",term)
94141
returnnil
@@ -144,8 +191,6 @@ func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey
144191
}
145192

146193
funcUsers(querystring) (database.GetUsersParams, []codersdk.ValidationError) {
147-
// Always lowercase for all searches.
148-
query=strings.ToLower(query)
149194
values,errors:=searchTerms(query,func(termstring,values url.Values)error {
150195
values.Add("search",term)
151196
returnnil
@@ -184,8 +229,6 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
184229
returnfilter,nil
185230
}
186231

187-
// Always lowercase for all searches.
188-
query=strings.ToLower(query)
189232
values,errors:=searchTerms(query,func(termstring,values url.Values)error {
190233
// It is a workspace name, and maybe includes an owner
191234
parts:=splitQueryParameterByDelimiter(term,'/',false)
@@ -269,8 +312,6 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
269312
}
270313

271314
funcTemplates(ctx context.Context,db database.Store,actorID uuid.UUID,querystring) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) {
272-
// Always lowercase for all searches.
273-
query=strings.ToLower(query)
274315
values,errors:=searchTerms(query,func(termstring,values url.Values)error {
275316
// Default to the display name
276317
values.Add("display_name",term)
@@ -345,7 +386,34 @@ func AIBridgeInterceptions(ctx context.Context, db database.Store, query string,
345386
returnfilter,parser.Errors
346387
}
347388

389+
// searchTerms parses a search query string into structured key-value pairs.
390+
//
391+
// It handles two types of search terms:
392+
// - Key-value pairs: "owner:prebuilds" → {"owner": "prebuilds"}
393+
// - Free-form terms: "my workspace" → calls defaultKey("my workspace", values)
394+
//
395+
// The function uses a two-pass parsing approach:
396+
// 1. Split by spaces while preserving quoted strings
397+
// 2. Group non-field terms together for free-form search
398+
//
399+
// Parameters:
400+
// - query: The search query string to parse
401+
// - defaultKey: Function called for terms without colons to determine default field
402+
//
403+
// Returns:
404+
// - url.Values: Parsed key-value pairs
405+
// - []codersdk.ValidationError: Any parsing errors encountered
406+
//
407+
// Example:
408+
//
409+
//searchTerms("owner:prebuilds template:my-template", func(term, values) {
410+
// values.Add("name", term) // Default to searching by name
411+
// return nil
412+
//})
413+
//// Returns: {"owner": ["prebuilds"], "template": ["my-template"]}
348414
funcsearchTerms(querystring,defaultKeyfunc(termstring,values url.Values)error) (url.Values, []codersdk.ValidationError) {
415+
// Always lowercase for all searches.
416+
query=strings.ToLower(query)
349417
searchValues:=make(url.Values)
350418

351419
// Because we do this in 2 passes, we want to maintain quotes on the first
@@ -535,3 +603,56 @@ func processTokens(tokens []string) []string {
535603
}
536604
returnresults
537605
}
606+
607+
// ProvisionerJobs parses a search query for provisioner jobs and returns database filter parameters.
608+
//
609+
// Supported search parameters:
610+
// - status:<status> - Filter by job status (pending, running, succeeded, failed, etc.)
611+
// - initiator:<user> - Filter by user who initiated the job
612+
// - organization:<org> - Filter by organization
613+
// - tags:<json> - Filter by job tags (JSON format)
614+
//
615+
// Free-form terms (without colons) default to searching by job type.
616+
//
617+
// All filtering is performed in SQL using database indexes for optimal performance.
618+
//
619+
// Example queries:
620+
// - "status:running initiator:me"
621+
// - "status:pending status:running" (multiple statuses)
622+
// - "workspace_build" (searches by job type)
623+
funcProvisionerJobs(ctx context.Context,db database.Store,querystring,page codersdk.Pagination) (database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams, []codersdk.ValidationError) {
624+
filter:= database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{
625+
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
626+
Limit: sql.NullInt32{Int32:int32(page.Limit),Valid:page.Limit>0},
627+
}
628+
629+
ifquery=="" {
630+
returnfilter,nil
631+
}
632+
633+
values,errors:=searchTerms(query,func(_string,_ url.Values)error {
634+
// Provisioner jobs don't support free-form search terms
635+
// Users must specify search parameters like status:, initiator:, etc.
636+
returnxerrors.Errorf("Free-form search terms are not supported for provisioner jobs. Use specific search parameters like 'status:running', 'initiator:username', or 'organization:orgname'")
637+
})
638+
iflen(errors)>0 {
639+
returnfilter,errors
640+
}
641+
642+
parser:=httpapi.NewQueryParamParser()
643+
filter.OrganizationID=parseOrganization(ctx,db,parser,values,"organization")
644+
filter.Status=httpapi.ParseCustomList(parser,values, []database.ProvisionerJobStatus{},"status",httpapi.ParseEnum[database.ProvisionerJobStatus])
645+
filter.InitiatorID=parseUser(ctx,db,parser,values,"initiator",uuid.Nil)
646+
647+
// Parse tags as a map
648+
tagsStr:=parser.String(values,"","tags")
649+
iftagsStr!="" {
650+
vartagsmap[string]string
651+
iferr:=json.Unmarshal([]byte(tagsStr),&tags);err==nil {
652+
filter.Tags=database.StringMap(tags)
653+
}
654+
}
655+
656+
parser.ErrorExcessParams(values)
657+
returnfilter,parser.Errors
658+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp