Skip to content

Commit fd3b766

Browse files
authored
Merge pull request #1281 from planetscale/query-patterns
Add query-patterns download branch command
2 parents 5af072d + fe032e3 commit fd3b766

5 files changed

Lines changed: 309 additions & 3 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ require (
3030
github.com/mitchellh/go-homedir v1.1.0
3131
github.com/muesli/termenv v0.16.0
3232
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
33-
github.com/planetscale/planetscale-go v0.174.0
33+
github.com/planetscale/planetscale-go v0.175.0
3434
github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4
3535
github.com/planetscale/psdbproxy v0.0.0-20250728082226-3f4ea3a74ec7
3636
github.com/spf13/cobra v1.10.2

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
176176
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
177177
github.com/planetscale/noglog v0.2.1-0.20210421230640-bea75fcd2e8e h1:MZ8D+Z3m2vvqGZLvoQfpaGg/j1fNDr4j03s3PRz4rVY=
178178
github.com/planetscale/noglog v0.2.1-0.20210421230640-bea75fcd2e8e/go.mod h1:hwAsSPQdvPa3WcfKfzTXxtEq/HlqwLjQasfO6QbGo4Q=
179-
github.com/planetscale/planetscale-go v0.174.0 h1:u7UeL+XApeY2ZvettqecRIoemsVMBptAVV4i+2Dq3AA=
180-
github.com/planetscale/planetscale-go v0.174.0/go.mod h1:paQCI5SgquuoewvMQM7R+r1XJO868bdP6/ihGidYRM0=
179+
github.com/planetscale/planetscale-go v0.175.0 h1:ATvKI6Aa47bgc5Zu1kYLaMRbRYVk5VAVi1sAUXlunaQ=
180+
github.com/planetscale/planetscale-go v0.175.0/go.mod h1:paQCI5SgquuoewvMQM7R+r1XJO868bdP6/ihGidYRM0=
181181
github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4 h1:Xv5pj20Rhfty1Tv0OVcidg4ez4PvGrpKvb6rvUwQgDs=
182182
github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4/go.mod h1:M52h5IWxAcbdQ1hSZrLAGQC4ZXslxEsK/Wh9nu3wdWs=
183183
github.com/planetscale/psdbproxy v0.0.0-20250728082226-3f4ea3a74ec7 h1:aRd6vdE1fyuSI4RVj7oCr8lFmgqXvpnPUmN85VbZCp8=

internal/cmd/branch/branch.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func BranchCmd(ch *cmdutil.Helper) *cobra.Command {
3838
cmd.AddCommand(LintCmd(ch))
3939
cmd.AddCommand(ConnectionsCmd(ch))
4040
cmd.AddCommand(ProcesslistCmd(ch))
41+
cmd.AddCommand(QueryPatternsCmd(ch))
4142
cmd.AddCommand(vtctld.VtctldCmd(ch))
4243
cmd.AddCommand(InfraCmd(ch))
4344

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package branch
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"time"
8+
9+
"github.com/planetscale/cli/internal/cmdutil"
10+
"github.com/planetscale/cli/internal/printer"
11+
ps "github.com/planetscale/planetscale-go/planetscale"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// queryPatternsPollInterval is how often the download command polls the API
16+
// while a report is generating. It's a variable so tests can shorten it.
17+
var queryPatternsPollInterval = 2 * time.Second
18+
19+
// QueryPatternsCmd wraps commands for a branch's query patterns reports.
20+
func QueryPatternsCmd(ch *cmdutil.Helper) *cobra.Command {
21+
cmd := &cobra.Command{
22+
Use: "query-patterns <command>",
23+
Short: "Download query pattern reports for a branch",
24+
}
25+
26+
cmd.AddCommand(DownloadQueryPatternsCmd(ch))
27+
28+
return cmd
29+
}
30+
31+
// QueryPatternsDownload is the result of downloading a query patterns report.
32+
type QueryPatternsDownload struct {
33+
ID string `header:"id" json:"id"`
34+
State string `header:"state" json:"state"`
35+
File string `header:"file" json:"file"`
36+
}
37+
38+
// DownloadQueryPatternsCmd generates a query patterns report for a branch,
39+
// waits for it to finish, and downloads the resulting CSV file.
40+
func DownloadQueryPatternsCmd(ch *cmdutil.Helper) *cobra.Command {
41+
var flags struct {
42+
output string
43+
}
44+
45+
cmd := &cobra.Command{
46+
Use: "download <database> <branch>",
47+
Short: "Download a CSV report of the query patterns for a branch",
48+
Args: cmdutil.RequiredArgs("database", "branch"),
49+
RunE: func(cmd *cobra.Command, args []string) error {
50+
ctx := cmd.Context()
51+
database, branch := args[0], args[1]
52+
53+
client, err := ch.Client()
54+
if err != nil {
55+
return err
56+
}
57+
58+
end := ch.Printer.PrintProgress(fmt.Sprintf("Generating query patterns report for %s in %s...",
59+
printer.BoldBlue(branch), printer.BoldBlue(database)))
60+
defer end()
61+
62+
report, err := client.QueryPatterns.CreateReport(ctx, &ps.CreateQueryPatternsReportRequest{
63+
Organization: ch.Config.Organization,
64+
Database: database,
65+
Branch: branch,
66+
})
67+
if err != nil {
68+
return queryPatternsError(ch, err, database, branch)
69+
}
70+
71+
ticker := time.NewTicker(queryPatternsPollInterval)
72+
defer ticker.Stop()
73+
74+
for report.State == "pending" {
75+
select {
76+
case <-ctx.Done():
77+
return ctx.Err()
78+
case <-ticker.C:
79+
report, err = client.QueryPatterns.GetReport(ctx, &ps.GetQueryPatternsReportRequest{
80+
Organization: ch.Config.Organization,
81+
Database: database,
82+
Branch: branch,
83+
Report: report.PublicID,
84+
})
85+
if err != nil {
86+
return queryPatternsError(ch, err, database, branch)
87+
}
88+
}
89+
}
90+
91+
if report.State != "completed" {
92+
return fmt.Errorf("query patterns report %s for branch %s failed to generate, please try again",
93+
printer.BoldBlue(report.PublicID), printer.BoldBlue(branch))
94+
}
95+
96+
path := flags.output
97+
if path == "" {
98+
path = fmt.Sprintf("query-patterns-%s-%s-%s-%s.csv",
99+
ch.Config.Organization, database, branch,
100+
time.Now().UTC().Format("20060102T150405Z"))
101+
}
102+
103+
body, err := client.QueryPatterns.DownloadReport(ctx, &ps.DownloadQueryPatternsReportRequest{
104+
Organization: ch.Config.Organization,
105+
Database: database,
106+
Branch: branch,
107+
Report: report.PublicID,
108+
})
109+
if err != nil {
110+
return queryPatternsError(ch, err, database, branch)
111+
}
112+
defer body.Close()
113+
114+
f, err := os.Create(path)
115+
if err != nil {
116+
return fmt.Errorf("creating file %s: %w", path, err)
117+
}
118+
119+
_, err = io.Copy(f, body)
120+
if closeErr := f.Close(); err == nil {
121+
err = closeErr
122+
}
123+
if err != nil {
124+
return fmt.Errorf("writing query patterns report to %s: %w", path, err)
125+
}
126+
127+
end()
128+
129+
if ch.Printer.Format() == printer.Human {
130+
ch.Printer.Printf("Successfully downloaded query patterns report to %s\n", printer.BoldBlue(path))
131+
return nil
132+
}
133+
134+
return ch.Printer.PrintResource(&QueryPatternsDownload{
135+
ID: report.PublicID,
136+
State: report.State,
137+
File: path,
138+
})
139+
},
140+
}
141+
142+
cmd.Flags().StringVar(&flags.output, "output", "",
143+
"Output file for the query patterns report. Defaults to the current directory as query-patterns-<organization>-<database>-<branch>-<timestamp>.csv.")
144+
145+
return cmd
146+
}
147+
148+
// queryPatternsError maps a not-found API error to a message explaining both
149+
// causes: a missing branch, or query insights being disabled for the database.
150+
func queryPatternsError(ch *cmdutil.Helper, err error, database, branch string) error {
151+
switch cmdutil.ErrCode(err) {
152+
case ps.ErrNotFound:
153+
return fmt.Errorf("branch %s does not exist in database %s (organization: %s) or query insights is not enabled for the database",
154+
printer.BoldBlue(branch), printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization))
155+
default:
156+
return cmdutil.HandleError(err)
157+
}
158+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package branch
2+
3+
import (
4+
"bytes"
5+
"compress/gzip"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"path/filepath"
12+
"testing"
13+
"time"
14+
15+
qt "github.com/frankban/quicktest"
16+
17+
"github.com/planetscale/cli/internal/cmdutil"
18+
"github.com/planetscale/cli/internal/config"
19+
"github.com/planetscale/cli/internal/printer"
20+
ps "github.com/planetscale/planetscale-go/planetscale"
21+
)
22+
23+
const queryPatternsCSV = "normalized_sql,query_count\nselect ?,10\n"
24+
25+
func queryPatternsTestHelper(org, baseURL string, format printer.Format, buf *bytes.Buffer) *cmdutil.Helper {
26+
p := printer.NewPrinter(&format)
27+
p.SetResourceOutput(buf)
28+
p.SetHumanOutput(bytes.NewBuffer(nil))
29+
30+
return &cmdutil.Helper{
31+
Printer: p,
32+
Config: &config.Config{AccessToken: "token", Organization: org, BaseURL: baseURL},
33+
Client: func() (*ps.Client, error) {
34+
return ps.NewClient(ps.WithBaseURL(baseURL), ps.WithAccessToken("token"))
35+
},
36+
}
37+
}
38+
39+
func queryPatternsServer(t *testing.T, c *qt.C, showStates []string) *httptest.Server {
40+
t.Helper()
41+
42+
base := "/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns"
43+
shows := 0
44+
mux := http.NewServeMux()
45+
46+
mux.HandleFunc(base, func(w http.ResponseWriter, r *http.Request) {
47+
c.Assert(r.Method, qt.Equals, http.MethodPost)
48+
c.Assert(r.Header.Get("Authorization"), qt.Equals, "Bearer token")
49+
w.Header().Set("Content-Type", "application/json")
50+
w.WriteHeader(http.StatusCreated)
51+
_ = json.NewEncoder(w).Encode(map[string]string{"id": "report1", "state": "pending"})
52+
})
53+
54+
mux.HandleFunc(base+"/report1", func(w http.ResponseWriter, r *http.Request) {
55+
state := showStates[min(shows, len(showStates)-1)]
56+
shows++
57+
w.Header().Set("Content-Type", "application/json")
58+
_ = json.NewEncoder(w).Encode(map[string]string{"id": "report1", "state": state})
59+
})
60+
61+
mux.HandleFunc(base+"/report1/download", func(w http.ResponseWriter, r *http.Request) {
62+
http.Redirect(w, r, "/blob", http.StatusFound)
63+
})
64+
65+
mux.HandleFunc("/blob", func(w http.ResponseWriter, r *http.Request) {
66+
w.Header().Set("Content-Type", "application/gzip")
67+
gz := gzip.NewWriter(w)
68+
_, _ = gz.Write([]byte(queryPatternsCSV))
69+
_ = gz.Close()
70+
})
71+
72+
server := httptest.NewServer(mux)
73+
t.Cleanup(server.Close)
74+
return server
75+
}
76+
77+
func TestBranch_QueryPatternsDownloadCmd(t *testing.T) {
78+
c := qt.New(t)
79+
80+
prevInterval := queryPatternsPollInterval
81+
queryPatternsPollInterval = 10 * time.Millisecond
82+
t.Cleanup(func() { queryPatternsPollInterval = prevInterval })
83+
84+
server := queryPatternsServer(t, c, []string{"pending", "completed"})
85+
86+
var buf bytes.Buffer
87+
ch := queryPatternsTestHelper("my-org", server.URL, printer.JSON, &buf)
88+
89+
output := filepath.Join(t.TempDir(), "report.csv")
90+
91+
cmd := QueryPatternsCmd(ch)
92+
cmd.SetArgs([]string{"download", "my-db", "my-branch", "--output", output})
93+
err := cmd.Execute()
94+
95+
c.Assert(err, qt.IsNil)
96+
97+
data, err := os.ReadFile(output)
98+
c.Assert(err, qt.IsNil)
99+
c.Assert(string(data), qt.Equals, queryPatternsCSV)
100+
101+
c.Assert(buf.String(), qt.JSONEquals, &QueryPatternsDownload{
102+
ID: "report1",
103+
State: "completed",
104+
File: output,
105+
})
106+
}
107+
108+
func TestBranch_QueryPatternsDownloadCmd_Failed(t *testing.T) {
109+
c := qt.New(t)
110+
111+
prevInterval := queryPatternsPollInterval
112+
queryPatternsPollInterval = 10 * time.Millisecond
113+
t.Cleanup(func() { queryPatternsPollInterval = prevInterval })
114+
115+
server := queryPatternsServer(t, c, []string{"failed"})
116+
117+
var buf bytes.Buffer
118+
ch := queryPatternsTestHelper("my-org", server.URL, printer.JSON, &buf)
119+
120+
cmd := QueryPatternsCmd(ch)
121+
cmd.SetArgs([]string{"download", "my-db", "my-branch", "--output", filepath.Join(t.TempDir(), "report.csv")})
122+
err := cmd.Execute()
123+
124+
c.Assert(err, qt.IsNotNil)
125+
c.Assert(err.Error(), qt.Contains, "failed to generate")
126+
}
127+
128+
func TestBranch_QueryPatternsDownloadCmd_NotFound(t *testing.T) {
129+
c := qt.New(t)
130+
131+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132+
w.Header().Set("Content-Type", "application/json")
133+
w.WriteHeader(http.StatusNotFound)
134+
fmt.Fprint(w, `{"code":"not_found","message":"Not Found"}`)
135+
}))
136+
t.Cleanup(server.Close)
137+
138+
var buf bytes.Buffer
139+
ch := queryPatternsTestHelper("my-org", server.URL, printer.JSON, &buf)
140+
141+
cmd := QueryPatternsCmd(ch)
142+
cmd.SetArgs([]string{"download", "my-db", "my-branch"})
143+
err := cmd.Execute()
144+
145+
c.Assert(err, qt.IsNotNil)
146+
c.Assert(err.Error(), qt.Contains, "query insights is not enabled")
147+
}

0 commit comments

Comments
 (0)