|
| 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) |
1 | 51 | package searchquery
|
2 | 52 |
|
3 | 53 | import (
|
4 | 54 | "context"
|
5 | 55 | "database/sql"
|
| 56 | +"encoding/json" |
6 | 57 | "fmt"
|
7 | 58 | "net/url"
|
8 | 59 | "strings"
|
@@ -36,8 +87,6 @@ import (
|
36 | 87 | funcAuditLogs(ctx context.Context,db database.Store,querystring) (database.GetAuditLogsOffsetParams,
|
37 | 88 | database.CountAuditLogsParams, []codersdk.ValidationError,
|
38 | 89 | ) {
|
39 |
| -// Always lowercase for all searches. |
40 |
| -query=strings.ToLower(query) |
41 | 90 | values,errors:=searchTerms(query,func(termstring,values url.Values)error {
|
42 | 91 | values.Add("resource_type",term)
|
43 | 92 | returnnil
|
@@ -87,8 +136,6 @@ func AuditLogs(ctx context.Context, db database.Store, query string) (database.G
|
87 | 136 | }
|
88 | 137 |
|
89 | 138 | 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) |
92 | 139 | values,errors:=searchTerms(query,func(termstring,values url.Values)error {
|
93 | 140 | values.Add("search",term)
|
94 | 141 | returnnil
|
@@ -144,8 +191,6 @@ func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey
|
144 | 191 | }
|
145 | 192 |
|
146 | 193 | funcUsers(querystring) (database.GetUsersParams, []codersdk.ValidationError) {
|
147 |
| -// Always lowercase for all searches. |
148 |
| -query=strings.ToLower(query) |
149 | 194 | values,errors:=searchTerms(query,func(termstring,values url.Values)error {
|
150 | 195 | values.Add("search",term)
|
151 | 196 | returnnil
|
@@ -184,8 +229,6 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
|
184 | 229 | returnfilter,nil
|
185 | 230 | }
|
186 | 231 |
|
187 |
| -// Always lowercase for all searches. |
188 |
| -query=strings.ToLower(query) |
189 | 232 | values,errors:=searchTerms(query,func(termstring,values url.Values)error {
|
190 | 233 | // It is a workspace name, and maybe includes an owner
|
191 | 234 | parts:=splitQueryParameterByDelimiter(term,'/',false)
|
@@ -269,8 +312,6 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
|
269 | 312 | }
|
270 | 313 |
|
271 | 314 | 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) |
274 | 315 | values,errors:=searchTerms(query,func(termstring,values url.Values)error {
|
275 | 316 | // Default to the display name
|
276 | 317 | values.Add("display_name",term)
|
@@ -345,7 +386,34 @@ func AIBridgeInterceptions(ctx context.Context, db database.Store, query string,
|
345 | 386 | returnfilter,parser.Errors
|
346 | 387 | }
|
347 | 388 |
|
| 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"]} |
348 | 414 | funcsearchTerms(querystring,defaultKeyfunc(termstring,values url.Values)error) (url.Values, []codersdk.ValidationError) {
|
| 415 | +// Always lowercase for all searches. |
| 416 | +query=strings.ToLower(query) |
349 | 417 | searchValues:=make(url.Values)
|
350 | 418 |
|
351 | 419 | // Because we do this in 2 passes, we want to maintain quotes on the first
|
@@ -535,3 +603,56 @@ func processTokens(tokens []string) []string {
|
535 | 603 | }
|
536 | 604 | returnresults
|
537 | 605 | }
|
| 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 | +} |