From c9274b429c51f83a323205ad2c096bb4ee450192 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:36:12 +0100 Subject: [PATCH 1/2] task complete --- cli/root.go | 2 + cmd/execution/execution.go | 13 +++ cmd/execution/results.go | 71 +++++++++++++ cmd/execution/results_test.go | 181 +++++++++++++++++++++++++++++++++ cmd/execution/testutil_test.go | 38 +++++++ cmd/query/run.go | 39 +------ output/results.go | 48 +++++++++ plan/query-commands.md | 2 +- 8 files changed, 355 insertions(+), 39 deletions(-) create mode 100644 cmd/execution/execution.go create mode 100644 cmd/execution/results.go create mode 100644 cmd/execution/results_test.go create mode 100644 cmd/execution/testutil_test.go create mode 100644 output/results.go diff --git a/cli/root.go b/cli/root.go index e413cea..df39094 100644 --- a/cli/root.go +++ b/cli/root.go @@ -6,6 +6,7 @@ import ( "os" "github.com/charmbracelet/fang" + "github.com/duneanalytics/cli/cmd/execution" "github.com/duneanalytics/cli/cmd/query" "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/duneapi-client-go/config" @@ -43,6 +44,7 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Dune API key (overrides DUNE_API_KEY env var)") rootCmd.AddCommand(query.NewQueryCmd()) + rootCmd.AddCommand(execution.NewExecutionCmd()) } // Execute runs the root command via Fang. diff --git a/cmd/execution/execution.go b/cmd/execution/execution.go new file mode 100644 index 0000000..0f25dd2 --- /dev/null +++ b/cmd/execution/execution.go @@ -0,0 +1,13 @@ +package execution + +import "github.com/spf13/cobra" + +// NewExecutionCmd returns the `execution` parent command. +func NewExecutionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "execution", + Short: "Manage query executions", + } + cmd.AddCommand(newResultsCmd()) + return cmd +} diff --git a/cmd/execution/results.go b/cmd/execution/results.go new file mode 100644 index 0000000..f95006a --- /dev/null +++ b/cmd/execution/results.go @@ -0,0 +1,71 @@ +package execution + +import ( + "fmt" + + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/cli/output" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +func newResultsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "results ", + Short: "Fetch results of a query execution", + Args: cobra.ExactArgs(1), + RunE: runResults, + } + + cmd.Flags().Int("limit", 0, "maximum number of rows to return (0 = all)") + cmd.Flags().Int("offset", 0, "number of rows to skip") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runResults(cmd *cobra.Command, args []string) error { + executionID := args[0] + + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + + opts := models.ResultOptions{} + if limit > 0 || offset > 0 { + opts.Page = &models.ResultPageOption{ + Offset: uint64(offset), + Limit: uint32(limit), + } + } + + client := cmdutil.ClientFromCmd(cmd) + resp, err := client.QueryResultsV2(executionID, opts) + if err != nil { + return err + } + + switch resp.State { + case "QUERY_STATE_COMPLETED": + return output.DisplayResults(cmd, resp) + case "QUERY_STATE_PENDING", "QUERY_STATE_EXECUTING": + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + return output.PrintJSON(w, resp) + default: + fmt.Fprintf(w, "Execution ID: %s\n", executionID) + fmt.Fprintf(w, "State: %s\n", resp.State) + return nil + } + case "QUERY_STATE_FAILED": + msg := "execution failed" + if resp.Error != nil { + msg = resp.Error.Message + } + return fmt.Errorf("%s", msg) + case "QUERY_STATE_CANCELLED": + return fmt.Errorf("execution was cancelled") + default: + return fmt.Errorf("unexpected execution state: %s", resp.State) + } +} diff --git a/cmd/execution/results_test.go b/cmd/execution/results_test.go new file mode 100644 index 0000000..4145004 --- /dev/null +++ b/cmd/execution/results_test.go @@ -0,0 +1,181 @@ +package execution_test + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "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", + ExecutionEndedAt: ptrTime(time.Now()), + IsExecutionFinished: true, + 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 ptrTime(t time.Time) *time.Time { return &t } + +func TestResultsSuccess(t *testing.T) { + mock := &mockClient{ + queryResultsV2Fn: func(id string, _ models.ResultOptions) (*models.ResultsResponse, error) { + assert.Equal(t, "01ABCDEFGHIJKLMNOPQRSTUV", id) + return testResultsResponse, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABCDEFGHIJKLMNOPQRSTUV"}) + 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, "2 rows") +} + +func TestResultsJSONOutput(t *testing.T) { + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + return testResultsResponse, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABC", "-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 TestResultsPending(t *testing.T) { + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + return &models.ResultsResponse{ + State: "QUERY_STATE_PENDING", + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABC"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Execution ID: 01ABC") + assert.Contains(t, out, "State: QUERY_STATE_PENDING") +} + +func TestResultsExecuting(t *testing.T) { + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + return &models.ResultsResponse{ + State: "QUERY_STATE_EXECUTING", + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABC"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "State: QUERY_STATE_EXECUTING") +} + +func TestResultsFailed(t *testing.T) { + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + return &models.ResultsResponse{ + State: "QUERY_STATE_FAILED", + Error: &models.ExecutionError{ + Type: "EXECUTION_ERROR", + Message: "syntax error at line 1", + }, + }, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABC"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "syntax error at line 1") +} + +func TestResultsCancelled(t *testing.T) { + now := time.Now() + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + return &models.ResultsResponse{ + State: "QUERY_STATE_CANCELLED", + CancelledAt: &now, + }, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABC"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "cancelled") +} + +func TestResultsWithLimitAndOffset(t *testing.T) { + var capturedOpts models.ResultOptions + mock := &mockClient{ + queryResultsV2Fn: func(_ string, opts models.ResultOptions) (*models.ResultsResponse, error) { + capturedOpts = opts + return testResultsResponse, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABC", "--limit", "10", "--offset", "5"}) + require.NoError(t, root.Execute()) + + require.NotNil(t, capturedOpts.Page) + assert.Equal(t, uint32(10), capturedOpts.Page.Limit) + assert.Equal(t, uint64(5), capturedOpts.Page.Offset) +} + +func TestResultsAPIError(t *testing.T) { + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + return nil, errors.New("api: connection refused") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "01ABC"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: connection refused") +} + +func TestResultsMissingArgument(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"execution", "results"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} diff --git a/cmd/execution/testutil_test.go b/cmd/execution/testutil_test.go new file mode 100644 index 0000000..d71bd30 --- /dev/null +++ b/cmd/execution/testutil_test.go @@ -0,0 +1,38 @@ +package execution_test + +import ( + "bytes" + "context" + + "github.com/duneanalytics/cli/cmd/execution" + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/duneapi-client-go/dune" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +type mockClient struct { + dune.DuneClient + queryResultsV2Fn func(string, models.ResultOptions) (*models.ResultsResponse, error) +} + +func (m *mockClient) QueryResultsV2(executionID string, options models.ResultOptions) (*models.ResultsResponse, error) { + return m.queryResultsV2Fn(executionID, options) +} + +// newTestRoot builds a root → execution 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(execution.NewExecutionCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} diff --git a/cmd/query/run.go b/cmd/query/run.go index 65346ff..a7ba195 100644 --- a/cmd/query/run.go +++ b/cmd/query/run.go @@ -100,7 +100,7 @@ func runWait(cmd *cobra.Command, req models.ExecuteRequest) error { return fmt.Errorf("%s", msg) } - return displayResults(cmd, resp) + return output.DisplayResults(cmd, resp) } func parseParams(raw []string) (map[string]any, error) { @@ -121,40 +121,3 @@ func parseParams(raw []string) (map[string]any, error) { 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/output/results.go b/output/results.go new file mode 100644 index 0000000..5d01a00 --- /dev/null +++ b/output/results.go @@ -0,0 +1,48 @@ +package output + +import ( + "fmt" + + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +// DisplayResults renders a ResultsResponse as JSON or table based on cmd flags. +func DisplayResults(cmd *cobra.Command, resp *models.ResultsResponse) error { + w := cmd.OutOrStdout() + + if FormatFromCmd(cmd) == FormatJSON { + return 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) + + 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 +} + +// ResultRowsToStrings converts result rows to a string grid using column ordering. +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/plan/query-commands.md b/plan/query-commands.md index c48ab2e..04b9743 100644 --- a/plan/query-commands.md +++ b/plan/query-commands.md @@ -442,7 +442,7 @@ Reuses: SDK's `RunQuery`, `Execution.WaitGetResults`, `QueryExecute`, `ResultsRe ## Step 9: `dune execution results` -- [ ] Done +- [x] Done New `execution` parent command (`cmd/execution/execution.go`) + `results` subcommand (`cmd/execution/results.go`). From 6857dc12381236f0e7cba2195ddb5944656c0766 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Mar 2026 09:01:33 +0000 Subject: [PATCH 2/2] Fix negative flag value wrapping in execution results command Add validation to ensure limit and offset flags are non-negative before casting to unsigned types. This prevents negative values from wrapping to large positive numbers (e.g., uint64(-1) = 18446744073709551615). Applied via @cursor push command --- cmd/execution/results.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/execution/results.go b/cmd/execution/results.go index f95006a..a9735c3 100644 --- a/cmd/execution/results.go +++ b/cmd/execution/results.go @@ -30,6 +30,13 @@ func runResults(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") + if limit < 0 { + return fmt.Errorf("limit must be non-negative, got %d", limit) + } + if offset < 0 { + return fmt.Errorf("offset must be non-negative, got %d", offset) + } + opts := models.ResultOptions{} if limit > 0 || offset > 0 { opts.Page = &models.ResultPageOption{