Skip to content

Commit 475dcf9

Browse files
committed
feat: Implement pagination for template versions
1 parent dc115b8 commit 475dcf9

File tree

10 files changed

+335
-11
lines changed

10 files changed

+335
-11
lines changed

coderd/database/databasefake/databasefake.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -621,20 +621,59 @@ func (q *fakeQuerier) GetTemplateByOrganizationAndName(_ context.Context, arg da
621621
return database.Template{}, sql.ErrNoRows
622622
}
623623

624-
func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, templateID uuid.UUID) ([]database.TemplateVersion, error) {
624+
func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg database.GetTemplateVersionsByTemplateIDParams) (version []database.TemplateVersion, err error) {
625625
q.mutex.RLock()
626626
defer q.mutex.RUnlock()
627627

628-
version := make([]database.TemplateVersion, 0)
629628
for _, templateVersion := range q.templateVersions {
630-
if templateVersion.TemplateID.UUID.String() != templateID.String() {
629+
if templateVersion.TemplateID.UUID.String() != arg.TemplateID.String() {
631630
continue
632631
}
633632
version = append(version, templateVersion)
634633
}
634+
635+
// Database orders by created_at
636+
sort.Slice(version, func(i, j int) bool {
637+
if version[i].CreatedAt.Equal(version[j].CreatedAt) {
638+
// Technically the postgres database also orders by uuid. So match
639+
// that behavior
640+
return version[i].ID.String() < version[j].ID.String()
641+
}
642+
return version[i].CreatedAt.Before(version[j].CreatedAt)
643+
})
644+
645+
if arg.AfterID != uuid.Nil {
646+
found := false
647+
for i, v := range version {
648+
if v.ID == arg.AfterID {
649+
version = version[i+1:]
650+
found = true
651+
break
652+
}
653+
}
654+
if !found {
655+
return nil, sql.ErrNoRows
656+
}
657+
}
658+
659+
if arg.OffsetOpt > 0 {
660+
if int(arg.OffsetOpt) > len(version)-1 {
661+
return nil, sql.ErrNoRows
662+
}
663+
version = version[arg.OffsetOpt:]
664+
}
665+
666+
if arg.LimitOpt > 0 {
667+
if int(arg.LimitOpt) > len(version) {
668+
arg.LimitOpt = int32(len(version))
669+
}
670+
version = version[:arg.LimitOpt]
671+
}
672+
635673
if len(version) == 0 {
636674
return nil, sql.ErrNoRows
637675
}
676+
638677
return version, nil
639678
}
640679

coderd/database/querier.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 43 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/templateversions.sql

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,36 @@ SELECT
44
FROM
55
template_versions
66
WHERE
7-
template_id = $1 :: uuid;
7+
template_id = @template_id :: uuid
8+
AND CASE
9+
-- This allows using the last element on a page as effectively a cursor.
10+
-- This is an important option for scripts that need to paginate without
11+
-- duplicating or missing data.
12+
WHEN @after_id :: uuid != '00000000-00000000-00000000-00000000' THEN (
13+
-- The pagination cursor is the last user of the previous page.
14+
-- The query is ordered by the created_at field, so select all
15+
-- users after the cursor. We also want to include any users
16+
-- that share the created_at (super rare).
17+
created_at >= (
18+
SELECT
19+
created_at
20+
FROM
21+
template_versions
22+
WHERE
23+
id = @after_id
24+
)
25+
-- Omit the cursor from the final.
26+
AND id != @after_id
27+
)
28+
ELSE true
29+
END
30+
ORDER BY
31+
-- Deterministic and consistent ordering of all users, even if they share
32+
-- a timestamp. This is to ensure consistent pagination.
33+
(created_at, id) ASC OFFSET @offset_opt
34+
LIMIT
35+
-- A null limit means "no limit", so -1 means return all
36+
NULLIF(@limit_opt :: int, -1);
837

938
-- name: GetTemplateVersionByJobID :one
1039
SELECT

coderd/httpapi/httpapi.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import (
88
"net/http"
99
"reflect"
1010
"regexp"
11+
"strconv"
1112
"strings"
1213

1314
"github.com/go-playground/validator/v10"
15+
"github.com/google/uuid"
16+
"golang.org/x/xerrors"
1417
)
1518

1619
var (
@@ -133,3 +136,42 @@ func WebsocketCloseSprintf(format string, vars ...any) string {
133136

134137
return msg
135138
}
139+
140+
type Pagination struct {
141+
AfterID uuid.UUID
142+
Limit int
143+
Offset int
144+
}
145+
146+
func ParsePagination(r *http.Request) (p Pagination, err error) {
147+
var (
148+
afterID = uuid.Nil
149+
limit = -1 // Default to no limit and return all results.
150+
offset = 0
151+
)
152+
153+
if s := r.URL.Query().Get("after_id"); s != "" {
154+
afterID, err = uuid.Parse(r.URL.Query().Get("after_id"))
155+
if err != nil {
156+
return p, xerrors.Errorf("after_id must be a valid uuid: %w", err.Error())
157+
}
158+
}
159+
if s := r.URL.Query().Get("limit"); s != "" {
160+
limit, err = strconv.Atoi(s)
161+
if err != nil {
162+
return p, xerrors.Errorf("limit must be an integer: %w", err.Error())
163+
}
164+
}
165+
if s := r.URL.Query().Get("offset"); s != "" {
166+
offset, err = strconv.Atoi(s)
167+
if err != nil {
168+
return p, xerrors.Errorf("offset must be an integer: %w", err.Error())
169+
}
170+
}
171+
172+
return Pagination{
173+
AfterID: afterID,
174+
Limit: limit,
175+
Offset: offset,
176+
}, nil
177+
}

coderd/templates.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,18 @@ func (api *api) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
7575
func (api *api) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Request) {
7676
template := httpmw.TemplateParam(r)
7777

78-
versions, err := api.Database.GetTemplateVersionsByTemplateID(r.Context(), template.ID)
78+
paginationParams, err := httpapi.ParsePagination(r)
79+
if err != nil {
80+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{Message: fmt.Sprintf("parse pagination request: %s", err.Error())})
81+
return
82+
}
83+
84+
versions, err := api.Database.GetTemplateVersionsByTemplateID(r.Context(), database.GetTemplateVersionsByTemplateIDParams{
85+
TemplateID: template.ID,
86+
AfterID: paginationParams.AfterID,
87+
LimitOpt: int32(paginationParams.Limit),
88+
OffsetOpt: int32(paginationParams.Offset),
89+
})
7990
if errors.Is(err, sql.ErrNoRows) {
8091
err = nil
8192
}

coderd/templates_test.go

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import (
66
"testing"
77

88
"github.com/google/uuid"
9+
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
1011

1112
"github.com/coder/coder/coderd/coderdtest"
13+
"github.com/coder/coder/coderd/database"
1214
"github.com/coder/coder/codersdk"
15+
"github.com/coder/coder/provisioner/echo"
1316
)
1417

1518
func TestTemplate(t *testing.T) {
@@ -63,7 +66,9 @@ func TestTemplateVersionsByTemplate(t *testing.T) {
6366
user := coderdtest.CreateFirstUser(t, client)
6467
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
6568
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
66-
versions, err := client.TemplateVersionsByTemplate(context.Background(), template.ID)
69+
versions, err := client.TemplateVersionsByTemplate(context.Background(), codersdk.TemplateVersionsByTemplateRequest{
70+
TemplateID: template.ID,
71+
})
6772
require.NoError(t, err)
6873
require.Len(t, versions, 1)
6974
})
@@ -137,3 +142,95 @@ func TestPatchActiveTemplateVersion(t *testing.T) {
137142
require.NoError(t, err)
138143
})
139144
}
145+
146+
// TestPaginatedTemplateVersions creates a list of template versions and paginate.
147+
func TestPaginatedTemplateVersions(t *testing.T) {
148+
t.Parallel()
149+
ctx := context.Background()
150+
151+
client := coderdtest.New(t, &coderdtest.Options{APIRateLimit: -1})
152+
// Prepare database.
153+
user := coderdtest.CreateFirstUser(t, client)
154+
coderdtest.NewProvisionerDaemon(t, client)
155+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
156+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
157+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
158+
159+
// Populate database with template versions.
160+
var templateVersions []codersdk.TemplateVersion
161+
total := 9
162+
for i := 0; i < total; i++ {
163+
data, err := echo.Tar(nil)
164+
require.NoError(t, err)
165+
file, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data)
166+
require.NoError(t, err)
167+
templateVersion, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{
168+
TemplateID: template.ID,
169+
StorageSource: file.Hash,
170+
StorageMethod: database.ProvisionerStorageMethodFile,
171+
Provisioner: database.ProvisionerTypeEcho,
172+
})
173+
require.NoError(t, err)
174+
175+
_ = coderdtest.AwaitTemplateVersionJob(t, client, templateVersion.ID)
176+
}
177+
178+
templateVersions, err := client.TemplateVersionsByTemplate(ctx,
179+
codersdk.TemplateVersionsByTemplateRequest{
180+
TemplateID: template.ID,
181+
},
182+
)
183+
require.NoError(t, err)
184+
require.Len(t, templateVersions, 10, "wrong number of template versions created")
185+
186+
type args struct {
187+
ctx context.Context
188+
pagination codersdk.Pagination
189+
}
190+
tests := []struct {
191+
name string
192+
args args
193+
want []codersdk.TemplateVersion
194+
}{
195+
{
196+
name: "Single result",
197+
args: args{ctx: ctx, pagination: codersdk.Pagination{Limit: 1}},
198+
want: templateVersions[:1],
199+
},
200+
{
201+
name: "Single result, second page",
202+
args: args{ctx: ctx, pagination: codersdk.Pagination{Limit: 1, Offset: 1}},
203+
want: templateVersions[1:2],
204+
},
205+
{
206+
name: "Last two results",
207+
args: args{ctx: ctx, pagination: codersdk.Pagination{Limit: 2, Offset: 8}},
208+
want: templateVersions[8:10],
209+
},
210+
{
211+
name: "AfterID returns next two results",
212+
args: args{ctx: ctx, pagination: codersdk.Pagination{Limit: 2, AfterID: templateVersions[1].ID}},
213+
want: templateVersions[2:4],
214+
},
215+
{
216+
name: "No result after last AfterID",
217+
args: args{ctx: ctx, pagination: codersdk.Pagination{Limit: 2, AfterID: templateVersions[9].ID}},
218+
want: []codersdk.TemplateVersion{},
219+
},
220+
{
221+
name: "No result after last Offset",
222+
args: args{ctx: ctx, pagination: codersdk.Pagination{Limit: 2, Offset: 10}},
223+
want: []codersdk.TemplateVersion{},
224+
},
225+
}
226+
for _, tt := range tests {
227+
t.Run(tt.name, func(t *testing.T) {
228+
got, err := client.TemplateVersionsByTemplate(tt.args.ctx, codersdk.TemplateVersionsByTemplateRequest{
229+
TemplateID: template.ID,
230+
Pagination: tt.args.pagination,
231+
})
232+
assert.NoError(t, err)
233+
assert.Equal(t, tt.want, got)
234+
})
235+
}
236+
}

0 commit comments

Comments
 (0)