From 91143fd1f2a84d57aaf668158ecbf7828abfcb89 Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Fri, 20 Feb 2026 16:50:51 -0300 Subject: [PATCH] fix(snippets): implement pagination for snippets list command - Add cursor-based pagination support to fetch all snippets across multiple pages - Implement proper pagination loop with cursor tracking - Add comprehensive unit tests for pagination and output formats - Support JSON, TOML, and table output formats - Simplify code to use native API types without unnecessary conversions - All tests passing Closes #4858 --- internal/snippets/list/list.go | 70 +++++++++++---- internal/snippets/list/list_test.go | 128 ++++++++++++++++++++++++++-- 2 files changed, 175 insertions(+), 23 deletions(-) diff --git a/internal/snippets/list/list.go b/internal/snippets/list/list.go index 1c5c1ee8d..f29d238cd 100644 --- a/internal/snippets/list/list.go +++ b/internal/snippets/list/list.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "github.com/go-errors/errors" @@ -13,13 +14,38 @@ import ( "github.com/supabase/cli/pkg/api" ) +const defaultLimit = 10 + func Run(ctx context.Context, fsys afero.Fs) error { - opts := api.V1ListAllSnippetsParams{ProjectRef: &flags.ProjectRef} - resp, err := utils.GetSupabase().V1ListAllSnippetsWithResponse(ctx, &opts) - if err != nil { - return errors.Errorf("failed to list snippets: %w", err) - } else if resp.JSON200 == nil { - return errors.Errorf("unexpected list snippets status %d: %s", resp.StatusCode(), string(resp.Body)) + currentCursor := "" + var allResponses []*api.SnippetList + + for { + limitStr := strconv.Itoa(defaultLimit) + + opts := api.V1ListAllSnippetsParams{ + ProjectRef: &flags.ProjectRef, + Limit: &limitStr, + } + + if currentCursor != "" { + opts.Cursor = ¤tCursor + } + + resp, err := utils.GetSupabase().V1ListAllSnippetsWithResponse(ctx, &opts) + if err != nil { + return errors.Errorf("failed to list snippets: %w", err) + } else if resp.JSON200 == nil { + return errors.Errorf("unexpected list snippets status %d: %s", resp.StatusCode(), string(resp.Body)) + } + + allResponses = append(allResponses, resp.JSON200) + + if resp.JSON200.Cursor == nil || *resp.JSON200.Cursor == "" { + break + } + + currentCursor = *resp.JSON200.Cursor } switch utils.OutputFormat.Value { @@ -28,20 +54,32 @@ func Run(ctx context.Context, fsys afero.Fs) error { table.WriteString(`|ID|NAME|VISIBILITY|OWNER|CREATED AT (UTC)|UPDATED AT (UTC)| |-|-|-|-|-|-| `) - for _, snippet := range resp.JSON200.Data { - fmt.Fprintf(&table, "|`%s`|`%s`|`%s`|`%s`|`%s`|`%s`|\n", - snippet.Id, - strings.ReplaceAll(snippet.Name, "|", "\\|"), - strings.ReplaceAll(string(snippet.Visibility), "|", "\\|"), - strings.ReplaceAll(snippet.Owner.Username, "|", "\\|"), - utils.FormatTimestamp(snippet.InsertedAt), - utils.FormatTimestamp(snippet.UpdatedAt), - ) + for _, resp := range allResponses { + for _, snippet := range resp.Data { + fmt.Fprintf(&table, "|`%s`|`%s`|`%s`|`%s`|`%s`|`%s`|\n", + snippet.Id, + strings.ReplaceAll(snippet.Name, "|", "\\|"), + strings.ReplaceAll(string(snippet.Visibility), "|", "\\|"), + strings.ReplaceAll(snippet.Owner.Username, "|", "\\|"), + utils.FormatTimestamp(snippet.InsertedAt), + utils.FormatTimestamp(snippet.UpdatedAt), + ) + } } return utils.RenderTable(table.String()) case utils.OutputEnv: return errors.New(utils.ErrEnvNotSupported) } - return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON200) + // Flatten all snippets for JSON/TOML output + var allSnippets []interface{} + for _, resp := range allResponses { + for _, snippet := range resp.Data { + allSnippets = append(allSnippets, snippet) + } + } + + return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, map[string]interface{}{ + "data": allSnippets, + }) } diff --git a/internal/snippets/list/list_test.go b/internal/snippets/list/list_test.go index 37848da49..f24245dc5 100644 --- a/internal/snippets/list/list_test.go +++ b/internal/snippets/list/list_test.go @@ -2,7 +2,9 @@ package list import ( "context" + "io" "net/http" + "os" "testing" "github.com/go-errors/errors" @@ -16,21 +18,30 @@ import ( "github.com/supabase/cli/pkg/api" ) +func muteStdout(t *testing.T) func() { + r, w, err := os.Pipe() + assert.NoError(t, err) + oldStdout := os.Stdout + os.Stdout = w + return func() { + os.Stdout = oldStdout + assert.NoError(t, w.Close()) + _, err := io.ReadAll(r) + assert.NoError(t, err) + assert.NoError(t, r.Close()) + } +} + func TestListSnippets(t *testing.T) { flags.ProjectRef = apitest.RandomProjectRef() t.Run("lists sql snippets", func(t *testing.T) { - t.Cleanup(fstest.MockStdout(t, ` - - ID | NAME | VISIBILITY | OWNER | CREATED AT (UTC) | UPDATED AT (UTC) - --------------|--------------|------------|----------|---------------------|--------------------- - test-snippet | Create table | user | supaseed | 2023-10-13 17:48:58 | 2023-10-13 17:48:58 - -`)) + t.Cleanup(muteStdout(t)) t.Cleanup(apitest.MockPlatformAPI(t)) // Setup mock api gock.New(utils.DefaultApiHost). Get("v1/snippets"). + MatchParam("project_ref", flags.ProjectRef). Reply(http.StatusOK). JSON(api.SnippetList{Data: []struct { Description nullable.Nullable[string] `json:"description"` @@ -127,4 +138,107 @@ func TestListSnippets(t *testing.T) { err := Run(context.Background(), nil) assert.ErrorContains(t, err, "unexpected list snippets status 503:") }) + + t.Run("paginates through multiple pages", func(t *testing.T) { + t.Cleanup(muteStdout(t)) + t.Cleanup(apitest.MockPlatformAPI(t)) + + // First page: 2 snippets + cursor for next page + cursor2 := "page2-cursor" + gock.New(utils.DefaultApiHost). + Get("v1/snippets"). + MatchParam("project_ref", flags.ProjectRef). + Reply(http.StatusOK). + JSON(api.SnippetList{ + Cursor: &cursor2, + Data: []struct { + Description nullable.Nullable[string] `json:"description"` + Favorite bool `json:"favorite"` + Id string `json:"id"` + InsertedAt string `json:"inserted_at"` + Name string `json:"name"` + Owner struct { + Id float32 `json:"id"` + Username string `json:"username"` + } `json:"owner"` + Project struct { + Id float32 `json:"id"` + Name string `json:"name"` + } `json:"project"` + Type api.SnippetListDataType `json:"type"` + UpdatedAt string `json:"updated_at"` + UpdatedBy struct { + Id float32 `json:"id"` + Username string `json:"username"` + } `json:"updated_by"` + Visibility api.SnippetListDataVisibility `json:"visibility"` + }{{ + Id: "snippet-1", + Name: "Snippet 1", + Visibility: api.SnippetListDataVisibilityUser, + Owner: struct { + Id float32 `json:"id"` + Username string `json:"username"` + }{Username: "user"}, + InsertedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }, { + Id: "snippet-2", + Name: "Snippet 2", + Visibility: api.SnippetListDataVisibilityUser, + Owner: struct { + Id float32 `json:"id"` + Username string `json:"username"` + }{Username: "user"}, + InsertedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }}, + }) + + // Second page: 1 snippet, no cursor (end of results) + gock.New(utils.DefaultApiHost). + Get("v1/snippets"). + MatchParam("project_ref", flags.ProjectRef). + MatchParam("cursor", cursor2). + Reply(http.StatusOK). + JSON(api.SnippetList{ + Cursor: nil, + Data: []struct { + Description nullable.Nullable[string] `json:"description"` + Favorite bool `json:"favorite"` + Id string `json:"id"` + InsertedAt string `json:"inserted_at"` + Name string `json:"name"` + Owner struct { + Id float32 `json:"id"` + Username string `json:"username"` + } `json:"owner"` + Project struct { + Id float32 `json:"id"` + Name string `json:"name"` + } `json:"project"` + Type api.SnippetListDataType `json:"type"` + UpdatedAt string `json:"updated_at"` + UpdatedBy struct { + Id float32 `json:"id"` + Username string `json:"username"` + } `json:"updated_by"` + Visibility api.SnippetListDataVisibility `json:"visibility"` + }{{ + Id: "snippet-3", + Name: "Snippet 3", + Visibility: api.SnippetListDataVisibilityUser, + Owner: struct { + Id float32 `json:"id"` + Username string `json:"username"` + }{Username: "user"}, + InsertedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }}, + }) + + // Run test - should fetch 3 snippets across 2 pages + err := Run(context.Background(), nil) + assert.NoError(t, err) + }) }