Skip to content

feat: add pagination to getWorkspaces #4521

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Oct 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
8 changes: 7 additions & 1 deletion coderd/database/modelqueries.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/lib/pq"

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions coderd/database/queries/workspaces.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 16 additions & 10 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion coderd/workspaces_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"

"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion codersdk/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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
}
Expand Down