diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 5a652164d3..bdb4c8e0f0 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -695,6 +695,19 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. workspaces = append(workspaces, workspace) } + if arg.Offset > 0 { + if int(arg.Offset) > len(workspaces) { + return []database.Workspace{}, nil + } + workspaces = workspaces[arg.Offset:] + } + if arg.Limit > 0 { + if int(arg.Limit) > len(workspaces) { + return workspaces, nil + } + workspaces = workspaces[:arg.Limit] + } + return workspaces, nil } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 9fc6e37176..3383b6af96 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/lib/pq" @@ -164,8 +165,11 @@ type workspaceQuerier interface { // This code is copied from `GetWorkspaces` and adds the authorized filter WHERE // clause. func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) { + // In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the + // authorizedFilter between the end of the where clause and those statements. + filter := strings.Replace(getWorkspaces, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) // The name comment is for metric tracking - query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s AND %s", getWorkspaces, authorizedFilter.SQLString(rbac.NoACLConfig())) + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filter) rows, err := q.db.QueryContext(ctx, query, arg.Deleted, arg.Status, @@ -174,6 +178,8 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.TemplateName, pq.Array(arg.TemplateIds), arg.Name, + arg.Offset, + arg.Limit, ) if err != nil { return nil, xerrors.Errorf("get authorized workspaces: %w", err) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 16613ee7d5..2ff1805cd4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5553,6 +5553,17 @@ WHERE name ILIKE '%' || $7 || '%' ELSE true END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces + -- @authorize_filter +ORDER BY + last_used_at DESC +LIMIT + CASE + WHEN $9 :: integer > 0 THEN + $9 + END +OFFSET + $8 ` type GetWorkspacesParams struct { @@ -5563,6 +5574,8 @@ type GetWorkspacesParams struct { TemplateName string `db:"template_name" json:"template_name"` TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"` Name string `db:"name" json:"name"` + Offset int32 `db:"offset_" json:"offset_"` + Limit int32 `db:"limit_" json:"limit_"` } func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) { @@ -5574,6 +5587,8 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.TemplateName, pq.Array(arg.TemplateIds), arg.Name, + arg.Offset, + arg.Limit, ) if err != nil { return nil, err diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 2cd6743b68..6e7f0436db 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -132,6 +132,17 @@ WHERE name ILIKE '%' || @name || '%' ELSE true END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces + -- @authorize_filter +ORDER BY + last_used_at DESC +LIMIT + CASE + WHEN @limit_ :: integer > 0 THEN + @limit_ + END +OFFSET + @offset_ ; -- name: GetWorkspaceByOwnerIDAndName :one diff --git a/coderd/workspaces.go b/coderd/workspaces.go index fda034dc6e..c96967b126 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -98,8 +98,13 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + page, ok := parsePagination(rw, r) + if !ok { + return + } + queryStr := r.URL.Query().Get("q") - filter, errs := workspaceSearchQuery(queryStr) + filter, errs := workspaceSearchQuery(queryStr, page) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid workspace search query.", @@ -1072,11 +1077,15 @@ func validWorkspaceSchedule(s *string, min time.Duration) (sql.NullString, error // workspaceSearchQuery takes a query string and returns the workspace filter. // It also can return the list of validation errors to return to the api. -func workspaceSearchQuery(query string) (database.GetWorkspacesParams, []codersdk.ValidationError) { +func workspaceSearchQuery(query string, page codersdk.Pagination) (database.GetWorkspacesParams, []codersdk.ValidationError) { + filter := database.GetWorkspacesParams{ + Offset: int32(page.Offset), + Limit: int32(page.Limit), + } searchParams := make(url.Values) if query == "" { // No filter - return database.GetWorkspacesParams{}, nil + return filter, nil } query = strings.ToLower(query) // Because we do this in 2 passes, we want to maintain quotes on the first @@ -1112,13 +1121,10 @@ func workspaceSearchQuery(query string) (database.GetWorkspacesParams, []codersd // Using the query param parser here just returns consistent errors with // other parsing. parser := httpapi.NewQueryParamParser() - filter := database.GetWorkspacesParams{ - Deleted: false, - OwnerUsername: parser.String(searchParams, "", "owner"), - TemplateName: parser.String(searchParams, "", "template"), - Name: parser.String(searchParams, "", "name"), - Status: parser.String(searchParams, "", "status"), - } + filter.OwnerUsername = parser.String(searchParams, "", "owner") + filter.TemplateName = parser.String(searchParams, "", "template") + filter.Name = parser.String(searchParams, "", "name") + filter.Status = parser.String(searchParams, "", "status") return filter, parser.Errors } diff --git a/coderd/workspaces_internal_test.go b/coderd/workspaces_internal_test.go index f9617031f6..03a74b29f0 100644 --- a/coderd/workspaces_internal_test.go +++ b/coderd/workspaces_internal_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" "github.com/stretchr/testify/require" ) @@ -135,7 +136,7 @@ func TestSearchWorkspace(t *testing.T) { c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() - values, errs := workspaceSearchQuery(c.Query) + values, errs := workspaceSearchQuery(c.Query, codersdk.Pagination{}) if c.ExpectedErrorContains != "" { require.True(t, len(errs) > 0, "expect some errors") var s strings.Builder diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 648973d505..1026dcdd6d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -781,6 +781,47 @@ func TestWorkspaceFilterManual(t *testing.T) { }) } +func TestOffsetLimit(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + + // empty finds all workspaces + ws, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, ws, 3) + + // offset 1 finds 2 workspaces + ws, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Offset: 1, + }) + require.NoError(t, err) + require.Len(t, ws, 2) + + // offset 1 limit 1 finds 1 workspace + ws, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Offset: 1, + Limit: 1, + }) + require.NoError(t, err) + require.Len(t, ws, 1) + + // offset 3 finds no workspaces + ws, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Offset: 3, + }) + require.NoError(t, err) + require.Len(t, ws, 0) +} + func TestPostWorkspaceBuild(t *testing.T) { t.Parallel() t.Run("NoTemplateVersion", func(t *testing.T) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 7899fb33f8..a019504ad9 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -254,6 +254,10 @@ type WorkspaceFilter struct { Name string `json:"name,omitempty" typescript:"-"` // Status is a workspace status, which is really the status of the latest build Status string `json:"status,omitempty" typescript:"-"` + // Offset is the number of workspaces to skip before returning results. + Offset int `json:"offset,omitempty" typescript:"-"` + // Limit is a limit on the number of workspaces returned. + Limit int `json:"limit,omitempty" typescript:"-"` // FilterQuery supports a raw filter query string FilterQuery string `json:"q,omitempty"` } @@ -290,7 +294,11 @@ func (f WorkspaceFilter) asRequestOption() RequestOption { // Workspaces returns all workspaces the authenticated user has access to. func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces", nil, filter.asRequestOption()) + page := Pagination{ + Offset: filter.Offset, + Limit: filter.Limit, + } + res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces", nil, filter.asRequestOption(), page.asRequestOption()) if err != nil { return nil, err }