diff --git a/cmd/query/archive.go b/cmd/query/archive.go index 53f6918..1cec696 100644 --- a/cmd/query/archive.go +++ b/cmd/query/archive.go @@ -2,7 +2,6 @@ package query import ( "fmt" - "strconv" "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/cli/output" @@ -23,9 +22,9 @@ func newArchiveCmd() *cobra.Command { } func runArchive(cmd *cobra.Command, args []string) error { - queryID, err := strconv.Atoi(args[0]) + queryID, err := parseQueryID(args[0]) if err != nil { - return fmt.Errorf("invalid query ID %q: must be an integer", args[0]) + return err } client := cmdutil.ClientFromCmd(cmd) diff --git a/cmd/query/get.go b/cmd/query/get.go index bb1ed5d..ad0842b 100644 --- a/cmd/query/get.go +++ b/cmd/query/get.go @@ -2,7 +2,6 @@ package query import ( "fmt" - "strconv" "strings" "github.com/duneanalytics/cli/cmdutil" @@ -24,9 +23,9 @@ func newGetCmd() *cobra.Command { } func runGet(cmd *cobra.Command, args []string) error { - queryID, err := strconv.Atoi(args[0]) + queryID, err := parseQueryID(args[0]) if err != nil { - return fmt.Errorf("invalid query ID %q: must be an integer", args[0]) + return err } client := cmdutil.ClientFromCmd(cmd) diff --git a/cmd/query/helpers.go b/cmd/query/helpers.go new file mode 100644 index 0000000..45cb2f4 --- /dev/null +++ b/cmd/query/helpers.go @@ -0,0 +1,14 @@ +package query + +import ( + "fmt" + "strconv" +) + +func parseQueryID(arg string) (int, error) { + id, err := strconv.Atoi(arg) + if err != nil { + return 0, fmt.Errorf("invalid query ID %q: must be an integer", arg) + } + return id, nil +} diff --git a/cmd/query/query.go b/cmd/query/query.go index 3de2956..ea07a19 100644 --- a/cmd/query/query.go +++ b/cmd/query/query.go @@ -12,5 +12,6 @@ func NewQueryCmd() *cobra.Command { cmd.AddCommand(newGetCmd()) cmd.AddCommand(newUpdateCmd()) cmd.AddCommand(newArchiveCmd()) + cmd.AddCommand(newRunCmd()) return cmd } diff --git a/cmd/query/run.go b/cmd/query/run.go new file mode 100644 index 0000000..65346ff --- /dev/null +++ b/cmd/query/run.go @@ -0,0 +1,160 @@ +package query + +import ( + "fmt" + "strings" + "time" + + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/cli/output" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +func newRunCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run ", + Short: "Execute a saved query and display results", + Args: cobra.ExactArgs(1), + RunE: runRun, + } + + cmd.Flags().StringArray("param", nil, "query parameter in key=value format (repeatable)") + cmd.Flags().String("performance", "medium", `performance tier: "medium" or "large"`) + cmd.Flags().Int("limit", 0, "maximum number of rows to display (0 = all)") + cmd.Flags().Bool("no-wait", false, "submit execution and exit without waiting for results") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runRun(cmd *cobra.Command, args []string) error { + queryID, err := parseQueryID(args[0]) + if err != nil { + return err + } + + paramFlags, _ := cmd.Flags().GetStringArray("param") + params, err := parseParams(paramFlags) + if err != nil { + return err + } + + performance, _ := cmd.Flags().GetString("performance") + if performance != "medium" && performance != "large" { + return fmt.Errorf("invalid performance tier %q: must be \"medium\" or \"large\"", performance) + } + + req := models.ExecuteRequest{ + QueryID: queryID, + Performance: performance, + } + if len(params) > 0 { + req.QueryParameters = params + } + + noWait, _ := cmd.Flags().GetBool("no-wait") + if noWait { + return runNoWait(cmd, req) + } + return runWait(cmd, req) +} + +func runNoWait(cmd *cobra.Command, req models.ExecuteRequest) error { + client := cmdutil.ClientFromCmd(cmd) + + resp, err := client.QueryExecute(req) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + return output.PrintJSON(w, resp) + default: + fmt.Fprintf(w, "Execution ID: %s\n", resp.ExecutionID) + fmt.Fprintf(w, "State: %s\n", resp.State) + return nil + } +} + +func runWait(cmd *cobra.Command, req models.ExecuteRequest) error { + client := cmdutil.ClientFromCmd(cmd) + + exec, err := client.RunQuery(req) + if err != nil { + return err + } + + resp, err := exec.WaitGetResults(5*time.Second, 60) + if err != nil { + return err + } + + if resp.State != "QUERY_STATE_COMPLETED" { + msg := fmt.Sprintf("query execution failed with state %s", resp.State) + if resp.Error != nil { + msg += fmt.Sprintf(": %s", resp.Error.Message) + } + return fmt.Errorf("%s", msg) + } + + return displayResults(cmd, resp) +} + +func parseParams(raw []string) (map[string]any, error) { + if len(raw) == 0 { + return nil, nil + } + params := make(map[string]any, len(raw)) + for _, s := range raw { + key, value, ok := strings.Cut(s, "=") + if !ok { + return nil, fmt.Errorf("invalid parameter %q: expected key=value format", s) + } + if key == "" { + return nil, fmt.Errorf("invalid parameter %q: key cannot be empty", s) + } + params[key] = value + } + return params, nil +} + +func displayResults(cmd *cobra.Command, resp *models.ResultsResponse) error { + w := cmd.OutOrStdout() + + if output.FormatFromCmd(cmd) == output.FormatJSON { + return output.PrintJSON(w, resp) + } + + limit, _ := cmd.Flags().GetInt("limit") + columns := resp.Result.Metadata.ColumnNames + sourceRows := resp.Result.Rows + totalRows := len(sourceRows) + + if limit > 0 && limit < totalRows { + sourceRows = sourceRows[:limit] + } + rows := resultRowsToStrings(sourceRows, columns) + + output.PrintTable(w, columns, rows) + if limit > 0 && limit < totalRows { + fmt.Fprintf(w, "\nShowing %d of %d rows\n", limit, totalRows) + } else { + fmt.Fprintf(w, "\n%d rows\n", totalRows) + } + return nil +} + +func resultRowsToStrings(rows []map[string]any, columns []string) [][]string { + out := make([][]string, len(rows)) + for i, row := range rows { + cells := make([]string, len(columns)) + for j, col := range columns { + cells[j] = fmt.Sprintf("%v", row[col]) + } + out[i] = cells + } + return out +} diff --git a/cmd/query/run_test.go b/cmd/query/run_test.go new file mode 100644 index 0000000..613986c --- /dev/null +++ b/cmd/query/run_test.go @@ -0,0 +1,253 @@ +package query_test + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "github.com/duneanalytics/duneapi-client-go/dune" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testResultsResponse = &models.ResultsResponse{ + QueryID: 4125432, + State: "QUERY_STATE_COMPLETED", + Result: models.Result{ + Metadata: models.ResultMetadata{ + ColumnNames: []string{"block_number", "tx_hash"}, + RowCount: 2, + }, + Rows: []map[string]any{ + {"block_number": float64(100), "tx_hash": "0xabc"}, + {"block_number": float64(200), "tx_hash": "0xdef"}, + }, + }, +} + +func newWaitMock(t *testing.T, resp *models.ResultsResponse, respErr error) *mockClient { + t.Helper() + return &mockClient{ + runQueryFn: func(req models.ExecuteRequest) (dune.Execution, error) { + return &mockExecution{ + id: "01ABC", + waitGetResultsFn: func(_ time.Duration, _ int) (*models.ResultsResponse, error) { + return resp, respErr + }, + }, nil + }, + } +} + +func TestRunSuccess(t *testing.T) { + mock := newWaitMock(t, testResultsResponse, nil) + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "block_number") + assert.Contains(t, out, "tx_hash") + assert.Contains(t, out, "100") + assert.Contains(t, out, "0xabc") + assert.Contains(t, out, "200") + assert.Contains(t, out, "0xdef") + assert.Contains(t, out, "2 rows") +} + +func TestRunJSONOutput(t *testing.T) { + mock := newWaitMock(t, testResultsResponse, nil) + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432", "-o", "json"}) + require.NoError(t, root.Execute()) + + var got models.ResultsResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, int64(4125432), got.QueryID) + assert.Equal(t, "QUERY_STATE_COMPLETED", got.State) +} + +func TestRunWithLimit(t *testing.T) { + mock := newWaitMock(t, testResultsResponse, nil) + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432", "--limit", "1"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "100") + assert.Contains(t, out, "Showing 1 of 2 rows") + assert.NotContains(t, out, "200") +} + +func TestRunWithParams(t *testing.T) { + var captured models.ExecuteRequest + mock := &mockClient{ + runQueryFn: func(req models.ExecuteRequest) (dune.Execution, error) { + captured = req + return &mockExecution{ + id: "01ABC", + waitGetResultsFn: func(_ time.Duration, _ int) (*models.ResultsResponse, error) { + return testResultsResponse, nil + }, + }, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432", "--param", "wallet=0xabc", "--param", "days=30"}) + require.NoError(t, root.Execute()) + + assert.Equal(t, "0xabc", captured.QueryParameters["wallet"]) + assert.Equal(t, "30", captured.QueryParameters["days"]) +} + +func TestRunWithPerformance(t *testing.T) { + var captured models.ExecuteRequest + mock := &mockClient{ + runQueryFn: func(req models.ExecuteRequest) (dune.Execution, error) { + captured = req + return &mockExecution{ + id: "01ABC", + waitGetResultsFn: func(_ time.Duration, _ int) (*models.ResultsResponse, error) { + return testResultsResponse, nil + }, + }, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432", "--performance", "large"}) + require.NoError(t, root.Execute()) + + assert.Equal(t, "large", captured.Performance) +} + +func TestRunExecutionFailed(t *testing.T) { + failedResp := &models.ResultsResponse{ + QueryID: 4125432, + State: "QUERY_STATE_FAILED", + Error: &models.ExecutionError{ + Type: "EXECUTION_ERROR", + Message: "syntax error at line 1", + }, + } + mock := newWaitMock(t, failedResp, nil) + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "QUERY_STATE_FAILED") + assert.Contains(t, err.Error(), "syntax error at line 1") +} + +func TestRunAPIError(t *testing.T) { + mock := &mockClient{ + runQueryFn: func(_ models.ExecuteRequest) (dune.Execution, error) { + return nil, errors.New("api: connection refused") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: connection refused") +} + +func TestRunNoWait(t *testing.T) { + mock := &mockClient{ + queryExecuteFn: func(req models.ExecuteRequest) (*models.ExecuteResponse, error) { + return &models.ExecuteResponse{ + ExecutionID: "01ABCDEFGHIJKLMNOPQRSTUV", + State: "QUERY_STATE_PENDING", + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432", "--no-wait"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Execution ID: 01ABCDEFGHIJKLMNOPQRSTUV") + assert.Contains(t, out, "State: QUERY_STATE_PENDING") +} + +func TestRunNoWaitJSON(t *testing.T) { + mock := &mockClient{ + queryExecuteFn: func(_ models.ExecuteRequest) (*models.ExecuteResponse, error) { + return &models.ExecuteResponse{ + ExecutionID: "01ABCDEFGHIJKLMNOPQRSTUV", + State: "QUERY_STATE_PENDING", + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432", "--no-wait", "-o", "json"}) + require.NoError(t, root.Execute()) + + var got models.ExecuteResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "01ABCDEFGHIJKLMNOPQRSTUV", got.ExecutionID) +} + +func TestRunNoWaitAPIError(t *testing.T) { + mock := &mockClient{ + queryExecuteFn: func(_ models.ExecuteRequest) (*models.ExecuteResponse, error) { + return nil, errors.New("api: rate limited") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"query", "run", "4125432", "--no-wait"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: rate limited") +} + +func TestRunInvalidParam(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"query", "run", "4125432", "--param", "noequalssign"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "expected key=value") +} + +func TestRunEmptyParamKey(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"query", "run", "4125432", "--param", "=value"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "key cannot be empty") +} + +func TestRunMissingArgument(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"query", "run"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} + +func TestRunNonIntegerID(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"query", "run", "abc"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid query ID") +} + +func TestRunInvalidPerformance(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"query", "run", "4125432", "--performance", "xlarge"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid performance tier") +} diff --git a/cmd/query/testutil_test.go b/cmd/query/testutil_test.go index 3e7a0d3..4714264 100644 --- a/cmd/query/testutil_test.go +++ b/cmd/query/testutil_test.go @@ -3,6 +3,7 @@ package query_test import ( "bytes" "context" + "time" "github.com/duneanalytics/cli/cmd/query" "github.com/duneanalytics/cli/cmdutil" @@ -11,6 +12,19 @@ import ( "github.com/spf13/cobra" ) +// mockExecution implements dune.Execution for testing. +type mockExecution struct { + dune.Execution + id string + waitGetResultsFn func(time.Duration, int) (*models.ResultsResponse, error) +} + +func (m *mockExecution) WaitGetResults(poll time.Duration, maxRetries int) (*models.ResultsResponse, error) { + return m.waitGetResultsFn(poll, maxRetries) +} + +func (m *mockExecution) GetID() string { return m.id } + // mockClient embeds the interface so unimplemented methods panic. type mockClient struct { dune.DuneClient @@ -18,6 +32,8 @@ type mockClient struct { getQueryFn func(int) (*models.GetQueryResponse, error) updateQueryFn func(int, models.UpdateQueryRequest) (*models.UpdateQueryResponse, error) archiveQueryFn func(int) (*models.UpdateQueryResponse, error) + runQueryFn func(models.ExecuteRequest) (dune.Execution, error) + queryExecuteFn func(models.ExecuteRequest) (*models.ExecuteResponse, error) } func (m *mockClient) CreateQuery(req models.CreateQueryRequest) (*models.CreateQueryResponse, error) { @@ -36,6 +52,14 @@ func (m *mockClient) ArchiveQuery(queryID int) (*models.UpdateQueryResponse, err return m.archiveQueryFn(queryID) } +func (m *mockClient) RunQuery(req models.ExecuteRequest) (dune.Execution, error) { + return m.runQueryFn(req) +} + +func (m *mockClient) QueryExecute(req models.ExecuteRequest) (*models.ExecuteResponse, error) { + return m.queryExecuteFn(req) +} + // newTestRoot builds a root → query command tree with the mock injected. func newTestRoot(mock dune.DuneClient) (*cobra.Command, *bytes.Buffer) { root := &cobra.Command{ diff --git a/cmd/query/update.go b/cmd/query/update.go index 5ec711d..9172870 100644 --- a/cmd/query/update.go +++ b/cmd/query/update.go @@ -2,7 +2,6 @@ package query import ( "fmt" - "strconv" "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/cli/output" @@ -29,9 +28,9 @@ func newUpdateCmd() *cobra.Command { } func runUpdate(cmd *cobra.Command, args []string) error { - queryID, err := strconv.Atoi(args[0]) + queryID, err := parseQueryID(args[0]) if err != nil { - return fmt.Errorf("invalid query ID %q: must be an integer", args[0]) + return err } var req models.UpdateQueryRequest diff --git a/output/output.go b/output/output.go index a7b3269..e6d14d4 100644 --- a/output/output.go +++ b/output/output.go @@ -1,7 +1,6 @@ package output import ( - "encoding/csv" "encoding/json" "fmt" "io" @@ -13,12 +12,11 @@ import ( 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"`) + cmd.Flags().StringP("output", "o", defaultFormat, `output format: "text" or "json"`) } // FormatFromCmd reads the output flag value from cmd. @@ -57,17 +55,3 @@ func PrintTable(w io.Writer, columns []string, rows [][]string) { 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 index 40a759d..2a68028 100644 --- a/output/output_test.go +++ b/output/output_test.go @@ -29,16 +29,6 @@ func TestPrintTable(t *testing.T) { 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") @@ -48,6 +38,6 @@ func TestAddFormatFlag_DefaultValue(t *testing.T) { func TestFormatFromCmd(t *testing.T) { cmd := &cobra.Command{} AddFormatFlag(cmd, "text") - _ = cmd.Flags().Set("output", "csv") - assert.Equal(t, "csv", FormatFromCmd(cmd)) + _ = cmd.Flags().Set("output", "json") + assert.Equal(t, "json", FormatFromCmd(cmd)) } diff --git a/plan/query-commands.md b/plan/query-commands.md index 85d4c60..c48ab2e 100644 --- a/plan/query-commands.md +++ b/plan/query-commands.md @@ -1,16 +1,23 @@ -# Dune CLI — `dune query` Implementation Plan +# Dune CLI — Implementation Plan ## Commands +### `dune query` — query management + execution triggers + +| Command | Maps to MCP tool | SDK method | +|---------|-----------------|------------| +| `query create` | `createDuneQuery` | `CreateQuery` (new — added to SDK in Step 2) | +| `query get` | `getDuneQuery` | `GetQuery` (new — added to SDK in Step 2) | +| `query update` | `updateDuneQuery` | `UpdateQuery` (new — added to SDK in Step 2) | +| `query archive` | `updateDuneQuery` (is_archived) | `ArchiveQuery` (new — added to SDK in Step 2) | +| `query run` | `executeQueryById` + `getExecutionResults` | `RunQuery` + `Execution.WaitGetResults` | +| `query run-sql` | (ad-hoc SQL) + `getExecutionResults` | `RunSQL` + `Execution.WaitGetResults` | + +### `dune execution` — operations on executions (by execution ID) + | Command | Maps to MCP tool | SDK method | |---------|-----------------|------------| -| `create` | `createDuneQuery` | `CreateQuery` (new — added to SDK in Step 2) | -| `get` | `getDuneQuery` | `GetQuery` (new — added to SDK in Step 2) | -| `update` | `updateDuneQuery` | `UpdateQuery` (new — added to SDK in Step 2) | -| `archive` | `updateDuneQuery` (is_archived) | `ArchiveQuery` (new — added to SDK in Step 2) | -| `run` | `executeQueryById` + `getExecutionResults` | `RunQuery` + `Execution.WaitGetResults` | -| `results` | `getExecutionResults` | `QueryResultsV2` | -| `run-sql` | (ad-hoc SQL) + `getExecutionResults` | `RunSQL` + `Execution.WaitGetResults` | +| `execution results` | `getExecutionResults` | `QueryResultsV2` | All commands use **only** the SDK's `dune.DuneClient` interface. No separate HTTP client in the CLI. @@ -395,7 +402,7 @@ API reference: POST `/api/v1/query/{queryId}/archive` — dedicated endpoint, no ## Step 8: `dune query run` -- [ ] Done +- [x] Done `cmd/query/run.go` — positional arg: query ID. Flags: `--param key=value` (repeatable), `--performance medium|large`, `--limit`, `--no-wait`, `-o`. @@ -411,7 +418,7 @@ No `poll.go` needed — the SDK's `Execution.WaitGetResults()` replaces all cust Reuses: SDK's `RunQuery`, `Execution.WaitGetResults`, `QueryExecute`, `ResultsResponse`. -**Output:** `--no-wait`: `Execution ID: 01JG...` / table: rows + footer with row count / json: full result object / csv: standard CSV. +**Output:** `--no-wait`: `Execution ID: 01JG...` / table: rows + footer with row count / json: full result object. **Acceptance criteria:** - Executes and prints results as table @@ -421,7 +428,7 @@ Reuses: SDK's `RunQuery`, `Execution.WaitGetResults`, `QueryExecute`, `ResultsRe - `--no-wait` prints execution ID only - Failed execution prints error, exits 1 - Progress shown on stderr during polling (SDK handles this) -- `-o json` and `-o csv` work +- `-o json` works **Tests:** - Param parsing ("key=value" → map) @@ -429,15 +436,17 @@ Reuses: SDK's `RunQuery`, `Execution.WaitGetResults`, `QueryExecute`, `ResultsRe - No-wait mode returns execution ID - Successful execution renders table (mock DuneClient interface) - Failed execution prints error, exits 1 -- JSON and CSV output formats +- JSON output format --- -## Step 9: `dune query results` +## Step 9: `dune execution results` - [ ] Done -`cmd/query/results.go` — positional arg: execution ID (string). Flags: `--limit`, `--offset`, `-o`. +New `execution` parent command (`cmd/execution/execution.go`) + `results` subcommand (`cmd/execution/results.go`). + +Positional arg: execution ID (string). Flags: `--limit`, `--offset`, `-o`. One-shot fetch via `client.QueryResultsV2(executionID, models.ResultOptions{Page: &models.ResultPageOption{Offset, Limit}})` — no polling. If still running: print status, exit 0. If complete: display results. If failed: print error, exit 1. @@ -445,12 +454,14 @@ API reference: GET `/api/v1/execution/{execution_id}/results` — query params: Reuses: SDK's `QueryResultsV2`, `models.ResultOptions`, `models.ResultPageOption`, `models.ResultsResponse`. +Reuses from `cmd/query/run.go`: `displayResults` and `resultRowsToStrings` — these must be moved to a shared location (e.g., `output/` package or `cmdutil/`) since they'll now be called from a different package. + **Acceptance criteria:** -- Completed execution displays results -- `--limit` and `--offset` work +- `dune execution results ` displays results +- `--limit` and `--offset` work (passed to `ResultPageOption`) - Running execution prints status, exits 0 - Failed execution prints error, exits 1 -- `-o json` and `-o csv` work +- `-o json` works **Tests:** - Completed execution renders results (mock DuneClient) @@ -502,17 +513,21 @@ cli/ # CLI repo main.go # Entry point (exists) query/ query.go # Query parent command (exists) + helpers.go # Shared helpers (parseQueryID) create.go # Step 4 get.go # Step 5 update.go # Step 6 archive.go # Step 7 run.go # Step 8 - results.go # Step 9 run_sql.go # Step 10 + execution/ + execution.go # Step 9: Execution parent command + results.go # Step 9: Results subcommand cmdutil/ client.go # SetClient, ClientFromCmd (context helpers) output/ - output.go # Shared output formatting (text, JSON, CSV) + output.go # Shared output formatting (text, JSON) + results.go # Step 9: Shared result display (moved from cmd/query/run.go) go.mod # Has replace directive → ../duneapi-client-go plan/ query-commands.md # This plan @@ -534,8 +549,9 @@ duneapi-client-go/ # SDK repo (separate) Step 1 (scaffolding + SDK integration + replace directive) ├── Step 2 (add query CRUD to SDK — separate repo) │ └── Steps 4-7 (CRUD commands — need Step 2) - ├── Steps 8-9 (execution commands — need Step 1, SDK already has methods) - └── Step 10 (run-sql — need Step 1, SDK already has RunSQL) + ├── Step 8 (query run — need Step 1, SDK already has methods) + ├── Step 9 (execution results — need Step 1, new execution namespace) + └── Step 10 (query run-sql — need Step 1, SDK already has RunSQL) ``` Output formatting (`output/`) is created inline with the first command that needs it.