From 8d1606af3bfb9c554eb4c84028a7b24371538fe6 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:35:51 +0100 Subject: [PATCH] finished task --- {internal/cli => cli}/root.go | 12 +--- cmd/main.go | 2 +- cmd/query/create.go | 56 +++++++++++++++ cmd/query/create_test.go | 132 ++++++++++++++++++++++++++++++++++ cmd/query/query.go | 4 +- cmdutil/client.go | 24 +++++++ go.mod | 4 ++ go.sum | 5 +- output/output.go | 73 +++++++++++++++++++ output/output_test.go | 53 ++++++++++++++ plan/query-commands.md | 31 ++++---- 11 files changed, 366 insertions(+), 30 deletions(-) rename {internal/cli => cli}/root.go (76%) create mode 100644 cmd/query/create.go create mode 100644 cmd/query/create_test.go create mode 100644 cmdutil/client.go create mode 100644 output/output.go create mode 100644 output/output_test.go diff --git a/internal/cli/root.go b/cli/root.go similarity index 76% rename from internal/cli/root.go rename to cli/root.go index af99dcd..e413cea 100644 --- a/internal/cli/root.go +++ b/cli/root.go @@ -7,13 +7,12 @@ import ( "github.com/charmbracelet/fang" "github.com/duneanalytics/cli/cmd/query" + "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/duneapi-client-go/config" "github.com/duneanalytics/duneapi-client-go/dune" "github.com/spf13/cobra" ) -type clientKey struct{} - var apiKeyFlag string var rootCmd = &cobra.Command{ @@ -21,7 +20,7 @@ var rootCmd = &cobra.Command{ Short: "Dune CLI — interact with the Dune Analytics API", Long: "A command-line interface for interacting with the Dune Analytics API.\n" + "Manage queries, execute them, and retrieve results.", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { var env *config.Env switch { @@ -36,7 +35,7 @@ var rootCmd = &cobra.Command{ } client := dune.NewDuneClient(env) - cmd.SetContext(context.WithValue(cmd.Context(), clientKey{}, dune.DuneClient(client))) + cmdutil.SetClient(cmd, client) return nil }, } @@ -46,11 +45,6 @@ func init() { rootCmd.AddCommand(query.NewQueryCmd()) } -// ClientFromCmd extracts the DuneClient stored in the command's context. -func ClientFromCmd(cmd *cobra.Command) dune.DuneClient { - return cmd.Context().Value(clientKey{}).(dune.DuneClient) -} - // Execute runs the root command via Fang. func Execute() { if err := fang.Execute(context.Background(), rootCmd); err != nil { diff --git a/cmd/main.go b/cmd/main.go index 8d6bebe..eb490f1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,6 +1,6 @@ package main -import "github.com/duneanalytics/cli/internal/cli" +import "github.com/duneanalytics/cli/cli" func main() { cli.Execute() diff --git a/cmd/query/create.go b/cmd/query/create.go new file mode 100644 index 0000000..87ae571 --- /dev/null +++ b/cmd/query/create.go @@ -0,0 +1,56 @@ +package query + +import ( + "fmt" + + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/cli/output" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +func newCreateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new saved query", + RunE: runCreate, + } + + cmd.Flags().String("name", "", "query name (required)") + cmd.Flags().String("sql", "", "query SQL (required)") + cmd.Flags().String("description", "", "query description") + cmd.Flags().Bool("private", false, "make the query private") + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("sql") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runCreate(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + + name, _ := cmd.Flags().GetString("name") + sql, _ := cmd.Flags().GetString("sql") + description, _ := cmd.Flags().GetString("description") + private, _ := cmd.Flags().GetBool("private") + + resp, err := client.CreateQuery(models.CreateQueryRequest{ + Name: name, + QuerySQL: sql, + Description: description, + IsPrivate: private, + }) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + return output.PrintJSON(w, resp) + default: + fmt.Fprintf(w, "Created query %d\n", resp.QueryID) + return nil + } +} diff --git a/cmd/query/create_test.go b/cmd/query/create_test.go new file mode 100644 index 0000000..425548a --- /dev/null +++ b/cmd/query/create_test.go @@ -0,0 +1,132 @@ +package query_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/duneanalytics/cli/cmd/query" + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/duneapi-client-go/dune" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockClient embeds the interface so unimplemented methods panic. +type mockClient struct { + dune.DuneClient + createQueryFn func(models.CreateQueryRequest) (*models.CreateQueryResponse, error) +} + +func (m *mockClient) CreateQuery(req models.CreateQueryRequest) (*models.CreateQueryResponse, error) { + return m.createQueryFn(req) +} + +// newTestRoot builds a root → query → create command tree with the mock injected. +func newTestRoot(mock dune.DuneClient) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{ + Use: "dune", + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + cmdutil.SetClient(cmd, mock) + }, + } + root.SetContext(context.Background()) + + root.AddCommand(query.NewQueryCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} + +func TestCreateSuccess(t *testing.T) { + mock := &mockClient{ + createQueryFn: func(req models.CreateQueryRequest) (*models.CreateQueryResponse, error) { + assert.Equal(t, "Test", req.Name) + assert.Equal(t, "SELECT 1", req.QuerySQL) + return &models.CreateQueryResponse{QueryID: 4125432}, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"query", "create", "--name", "Test", "--sql", "SELECT 1"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "Created query 4125432\n", buf.String()) +} + +func TestCreateJSONOutput(t *testing.T) { + mock := &mockClient{ + createQueryFn: func(_ models.CreateQueryRequest) (*models.CreateQueryResponse, error) { + return &models.CreateQueryResponse{QueryID: 4125432}, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"query", "create", "--name", "Test", "--sql", "SELECT 1", "-o", "json"}) + require.NoError(t, root.Execute()) + + var got map[string]int + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, 4125432, got["query_id"]) +} + +func TestCreatePrivateFlag(t *testing.T) { + mock := &mockClient{ + createQueryFn: func(req models.CreateQueryRequest) (*models.CreateQueryResponse, error) { + assert.True(t, req.IsPrivate) + return &models.CreateQueryResponse{QueryID: 1}, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "create", "--name", "T", "--sql", "S", "--private"}) + require.NoError(t, root.Execute()) +} + +func TestCreateDescriptionFlag(t *testing.T) { + mock := &mockClient{ + createQueryFn: func(req models.CreateQueryRequest) (*models.CreateQueryResponse, error) { + assert.Equal(t, "my desc", req.Description) + return &models.CreateQueryResponse{QueryID: 1}, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "create", "--name", "T", "--sql", "S", "--description", "my desc"}) + require.NoError(t, root.Execute()) +} + +func TestCreateMissingName(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"query", "create", "--sql", "SELECT 1"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), `required flag(s) "name" not set`) +} + +func TestCreateMissingSQL(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"query", "create", "--name", "Test"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), `required flag(s) "sql" not set`) +} + +func TestCreateAPIError(t *testing.T) { + mock := &mockClient{ + createQueryFn: func(_ models.CreateQueryRequest) (*models.CreateQueryResponse, error) { + return nil, errors.New("api: unauthorized") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "create", "--name", "T", "--sql", "S"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: unauthorized") +} diff --git a/cmd/query/query.go b/cmd/query/query.go index 5204c40..141a293 100644 --- a/cmd/query/query.go +++ b/cmd/query/query.go @@ -4,8 +4,10 @@ import "github.com/spf13/cobra" // NewQueryCmd returns the `query` parent command. func NewQueryCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "query", Short: "Manage Dune queries", } + cmd.AddCommand(newCreateCmd()) + return cmd } diff --git a/cmdutil/client.go b/cmdutil/client.go new file mode 100644 index 0000000..e864f50 --- /dev/null +++ b/cmdutil/client.go @@ -0,0 +1,24 @@ +package cmdutil + +import ( + "context" + + "github.com/duneanalytics/duneapi-client-go/dune" + "github.com/spf13/cobra" +) + +type clientKey struct{} + +// SetClient stores a DuneClient in the command's context. +func SetClient(cmd *cobra.Command, client dune.DuneClient) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + cmd.SetContext(context.WithValue(ctx, clientKey{}, client)) +} + +// ClientFromCmd extracts the DuneClient stored in the command's context. +func ClientFromCmd(cmd *cobra.Command) dune.DuneClient { + return cmd.Context().Value(clientKey{}).(dune.DuneClient) +} diff --git a/go.mod b/go.mod index acfa741..4070b65 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/fang v0.4.4 github.com/duneanalytics/duneapi-client-go v0.4.0 github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 ) require ( @@ -20,6 +21,7 @@ require ( github.com/clipperhouse/displaywidth v0.4.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect @@ -28,10 +30,12 @@ require ( github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1ba69bc..95aa7b8 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -69,6 +69,7 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/output/output.go b/output/output.go new file mode 100644 index 0000000..a7b3269 --- /dev/null +++ b/output/output.go @@ -0,0 +1,73 @@ +package output + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +const ( + FormatText = "text" + FormatJSON = "json" + FormatCSV = "csv" +) + +// AddFormatFlag registers the -o/--output flag on cmd with the given default. +func AddFormatFlag(cmd *cobra.Command, defaultFormat string) { + cmd.Flags().StringP("output", "o", defaultFormat, `output format: "text", "json", or "csv"`) +} + +// FormatFromCmd reads the output flag value from cmd. +func FormatFromCmd(cmd *cobra.Command) string { + f, _ := cmd.Flags().GetString("output") + return f +} + +// PrintJSON encodes v as indented JSON and writes it to w. +func PrintJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// PrintTable writes columns and rows as aligned text using tabwriter. +func PrintTable(w io.Writer, columns []string, rows [][]string) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for i, col := range columns { + if i > 0 { + fmt.Fprint(tw, "\t") + } + fmt.Fprint(tw, col) + } + fmt.Fprintln(tw) + + for _, row := range rows { + for i, cell := range row { + if i > 0 { + fmt.Fprint(tw, "\t") + } + fmt.Fprint(tw, cell) + } + fmt.Fprintln(tw) + } + tw.Flush() +} + +// PrintCSV writes columns and rows as CSV to w. +func PrintCSV(w io.Writer, columns []string, rows [][]string) error { + cw := csv.NewWriter(w) + if err := cw.Write(columns); err != nil { + return err + } + for _, row := range rows { + if err := cw.Write(row); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} diff --git a/output/output_test.go b/output/output_test.go new file mode 100644 index 0000000..40a759d --- /dev/null +++ b/output/output_test.go @@ -0,0 +1,53 @@ +package output + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrintJSON(t *testing.T) { + var buf bytes.Buffer + err := PrintJSON(&buf, map[string]int{"query_id": 42}) + require.NoError(t, err) + assert.JSONEq(t, `{"query_id": 42}`, buf.String()) +} + +func TestPrintTable(t *testing.T) { + var buf bytes.Buffer + PrintTable(&buf, []string{"ID", "NAME"}, [][]string{ + {"1", "alpha"}, + {"2", "beta"}, + }) + out := buf.String() + assert.Contains(t, out, "ID") + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "alpha") + assert.Contains(t, out, "beta") +} + +func TestPrintCSV(t *testing.T) { + var buf bytes.Buffer + err := PrintCSV(&buf, []string{"id", "name"}, [][]string{ + {"1", "alpha"}, + {"2", "beta"}, + }) + require.NoError(t, err) + assert.Equal(t, "id,name\n1,alpha\n2,beta\n", buf.String()) +} + +func TestAddFormatFlag_DefaultValue(t *testing.T) { + cmd := &cobra.Command{} + AddFormatFlag(cmd, "json") + assert.Equal(t, "json", FormatFromCmd(cmd)) +} + +func TestFormatFromCmd(t *testing.T) { + cmd := &cobra.Command{} + AddFormatFlag(cmd, "text") + _ = cmd.Flags().Set("output", "csv") + assert.Equal(t, "csv", FormatFromCmd(cmd)) +} diff --git a/plan/query-commands.md b/plan/query-commands.md index 4e17f1b..85d4c60 100644 --- a/plan/query-commands.md +++ b/plan/query-commands.md @@ -81,16 +81,16 @@ One client, created from `*config.Env` in `PersistentPreRunE`. No wrapper struct - [x] Done -Add `github.com/spf13/cobra`, `github.com/charmbracelet/fang`, and `github.com/duneanalytics/duneapi-client-go` deps. Create root command (`internal/cli/root.go`) with persistent `--api-key` flag (overrides `DUNE_API_KEY` env). Create `query` parent command (`cmd/query/query.go`). Use `fang.Execute(context.Background(), rootCmd)`. +Add `github.com/spf13/cobra`, `github.com/charmbracelet/fang`, and `github.com/duneanalytics/duneapi-client-go` deps. Create root command (`cli/root.go`) with persistent `--api-key` flag (overrides `DUNE_API_KEY` env). Create `query` parent command (`cmd/query/query.go`). Use `fang.Execute(context.Background(), rootCmd)`. **SDK integration:** - Delete local `config/` package — use SDK's `config` package instead (identical API: `FromEnvVars()`, `FromAPIKey()`, `Env{APIKey, Host}`) - Delete local `models/error.go` — use SDK error patterns - In `PersistentPreRunE`: build `*config.Env` from SDK, create `dune.NewDuneClient(env)`, store in context - Add `replace` directive to `go.mod` pointing to `../duneapi-client-go` -- Provide `ClientFromCmd(cmd) dune.DuneClient` helper +- Provide `cmdutil.ClientFromCmd(cmd) dune.DuneClient` helper -File structure: `cmd/main.go`, `internal/cli/root.go`, `cmd/query/query.go`. +File structure: `cmd/main.go`, `cli/root.go`, `cmdutil/client.go`, `cmd/query/query.go`. Reuses: `config.Env`, `config.FromEnvVars()`, `config.FromAPIKey()`, `dune.NewDuneClient(env)`. @@ -293,15 +293,15 @@ archiveQueryURLTemplate = "%s/api/v1/query/%d/archive" // POST ## Step 3: Output Formatting -- [x] Deferred — create `internal/output/` inline when the first command needs it (Step 4). +- [x] Deferred — create `output/` inline when the first command needs it (Step 4). --- ## Step 4: `dune query create` -- [ ] Done +- [x] Done -`cmd/query/create.go` — flags: `--name` (required), `--sql` (required), `--description`, `--private`, `-o`. Gets client via `cli.ClientFromCmd(cmd)`, calls `client.CreateQuery(models.CreateQueryRequest{...})`. +`cmd/query/create.go` — flags: `--name` (required), `--sql` (required), `--description`, `--private`, `-o`. Gets client via `cmdutil.ClientFromCmd(cmd)`, calls `client.CreateQuery(models.CreateQueryRequest{...})`. API reference: POST `/api/v1/query` — name (max 600 chars), query_sql (max 500k chars), description (max 1k chars), is_private, parameters, tags → `{"query_id": int}`. @@ -407,7 +407,7 @@ API reference: POST `/api/v1/query/{queryId}/archive` — dedicated endpoint, no API reference: POST `/api/v1/query/{query_id}/execute` — body: `{"query_parameters": {...}, "performance": "medium"|"large"}`. Response: `{"execution_id": string, "state": string}`. -No `internal/poll.go` needed — the SDK's `Execution.WaitGetResults()` replaces all custom polling logic. +No `poll.go` needed — the SDK's `Execution.WaitGetResults()` replaces all custom polling logic. Reuses: SDK's `RunQuery`, `Execution.WaitGetResults`, `QueryExecute`, `ResultsResponse`. @@ -496,6 +496,8 @@ Reuses: SDK's `RunSQL`, `Execution.WaitGetResults`, `ResultsResponse`. ``` cli/ # CLI repo + cli/ + root.go # Step 1: root command, Execute() cmd/ main.go # Entry point (exists) query/ @@ -507,19 +509,14 @@ cli/ # CLI repo run.go # Step 8 results.go # Step 9 run_sql.go # Step 10 - internal/ - cli/ - root.go # Step 1: DuneClient init, context helpers - output/ - output.go # Created inline with first command that needs it + cmdutil/ + client.go # SetClient, ClientFromCmd (context helpers) + output/ + output.go # Shared output formatting (text, JSON, CSV) go.mod # Has replace directive → ../duneapi-client-go plan/ query-commands.md # This plan - DELETED (Step 1): - config/config.go # Replaced by SDK's config package - models/error.go # Replaced by SDK error patterns - duneapi-client-go/ # SDK repo (separate) models/ query.go # Step 2: new — query CRUD types @@ -541,5 +538,5 @@ Step 1 (scaffolding + SDK integration + replace directive) └── Step 10 (run-sql — need Step 1, SDK already has RunSQL) ``` -Output formatting (`internal/output/`) is created inline with the first command that needs it. +Output formatting (`output/`) is created inline with the first command that needs it. Steps 4-7 depend on Step 2. Steps 8-10 only need Step 1 (SDK already has execution methods).