Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions cmd/execution/execution.go
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 78 additions & 0 deletions cmd/execution/results.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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 <execution-id>",
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")

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{
Offset: uint64(offset),
Limit: uint32(limit),
}
Comment thread
ivpusic marked this conversation as resolved.
}

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known QUERY_STATE_EXPIRED state treated as unexpected

Low Severity

The plan document explicitly lists QUERY_STATE_EXPIRED as a valid API state, but the switch statement has no case for it. It falls through to the default branch, producing the misleading message "unexpected execution state: QUERY_STATE_EXPIRED" — even though it's a known, expected state that deserves a clearer user-facing message.

Fix in Cursor Fix in Web

}
}
181 changes: 181 additions & 0 deletions cmd/execution/results_test.go
Original file line number Diff line number Diff line change
@@ -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)")
}
38 changes: 38 additions & 0 deletions cmd/execution/testutil_test.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 1 addition & 38 deletions cmd/query/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
Loading