Skip to content

Commit c9274b4

Browse files
committed
task complete
1 parent 8d4e437 commit c9274b4

8 files changed

Lines changed: 355 additions & 39 deletions

File tree

cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77

88
"github.com/charmbracelet/fang"
9+
"github.com/duneanalytics/cli/cmd/execution"
910
"github.com/duneanalytics/cli/cmd/query"
1011
"github.com/duneanalytics/cli/cmdutil"
1112
"github.com/duneanalytics/duneapi-client-go/config"
@@ -43,6 +44,7 @@ var rootCmd = &cobra.Command{
4344
func init() {
4445
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Dune API key (overrides DUNE_API_KEY env var)")
4546
rootCmd.AddCommand(query.NewQueryCmd())
47+
rootCmd.AddCommand(execution.NewExecutionCmd())
4648
}
4749

4850
// Execute runs the root command via Fang.

cmd/execution/execution.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package execution
2+
3+
import "github.com/spf13/cobra"
4+
5+
// NewExecutionCmd returns the `execution` parent command.
6+
func NewExecutionCmd() *cobra.Command {
7+
cmd := &cobra.Command{
8+
Use: "execution",
9+
Short: "Manage query executions",
10+
}
11+
cmd.AddCommand(newResultsCmd())
12+
return cmd
13+
}

cmd/execution/results.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package execution
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/duneanalytics/cli/cmdutil"
7+
"github.com/duneanalytics/cli/output"
8+
"github.com/duneanalytics/duneapi-client-go/models"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newResultsCmd() *cobra.Command {
13+
cmd := &cobra.Command{
14+
Use: "results <execution-id>",
15+
Short: "Fetch results of a query execution",
16+
Args: cobra.ExactArgs(1),
17+
RunE: runResults,
18+
}
19+
20+
cmd.Flags().Int("limit", 0, "maximum number of rows to return (0 = all)")
21+
cmd.Flags().Int("offset", 0, "number of rows to skip")
22+
output.AddFormatFlag(cmd, "text")
23+
24+
return cmd
25+
}
26+
27+
func runResults(cmd *cobra.Command, args []string) error {
28+
executionID := args[0]
29+
30+
limit, _ := cmd.Flags().GetInt("limit")
31+
offset, _ := cmd.Flags().GetInt("offset")
32+
33+
opts := models.ResultOptions{}
34+
if limit > 0 || offset > 0 {
35+
opts.Page = &models.ResultPageOption{
36+
Offset: uint64(offset),
37+
Limit: uint32(limit),
38+
}
39+
}
40+
41+
client := cmdutil.ClientFromCmd(cmd)
42+
resp, err := client.QueryResultsV2(executionID, opts)
43+
if err != nil {
44+
return err
45+
}
46+
47+
switch resp.State {
48+
case "QUERY_STATE_COMPLETED":
49+
return output.DisplayResults(cmd, resp)
50+
case "QUERY_STATE_PENDING", "QUERY_STATE_EXECUTING":
51+
w := cmd.OutOrStdout()
52+
switch output.FormatFromCmd(cmd) {
53+
case output.FormatJSON:
54+
return output.PrintJSON(w, resp)
55+
default:
56+
fmt.Fprintf(w, "Execution ID: %s\n", executionID)
57+
fmt.Fprintf(w, "State: %s\n", resp.State)
58+
return nil
59+
}
60+
case "QUERY_STATE_FAILED":
61+
msg := "execution failed"
62+
if resp.Error != nil {
63+
msg = resp.Error.Message
64+
}
65+
return fmt.Errorf("%s", msg)
66+
case "QUERY_STATE_CANCELLED":
67+
return fmt.Errorf("execution was cancelled")
68+
default:
69+
return fmt.Errorf("unexpected execution state: %s", resp.State)
70+
}
71+
}

cmd/execution/results_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package execution_test
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/duneanalytics/duneapi-client-go/models"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
var testResultsResponse = &models.ResultsResponse{
15+
QueryID: 4125432,
16+
State: "QUERY_STATE_COMPLETED",
17+
ExecutionEndedAt: ptrTime(time.Now()),
18+
IsExecutionFinished: true,
19+
Result: models.Result{
20+
Metadata: models.ResultMetadata{
21+
ColumnNames: []string{"block_number", "tx_hash"},
22+
RowCount: 2,
23+
},
24+
Rows: []map[string]any{
25+
{"block_number": float64(100), "tx_hash": "0xabc"},
26+
{"block_number": float64(200), "tx_hash": "0xdef"},
27+
},
28+
},
29+
}
30+
31+
func ptrTime(t time.Time) *time.Time { return &t }
32+
33+
func TestResultsSuccess(t *testing.T) {
34+
mock := &mockClient{
35+
queryResultsV2Fn: func(id string, _ models.ResultOptions) (*models.ResultsResponse, error) {
36+
assert.Equal(t, "01ABCDEFGHIJKLMNOPQRSTUV", id)
37+
return testResultsResponse, nil
38+
},
39+
}
40+
41+
root, buf := newTestRoot(mock)
42+
root.SetArgs([]string{"execution", "results", "01ABCDEFGHIJKLMNOPQRSTUV"})
43+
require.NoError(t, root.Execute())
44+
45+
out := buf.String()
46+
assert.Contains(t, out, "block_number")
47+
assert.Contains(t, out, "tx_hash")
48+
assert.Contains(t, out, "100")
49+
assert.Contains(t, out, "0xabc")
50+
assert.Contains(t, out, "2 rows")
51+
}
52+
53+
func TestResultsJSONOutput(t *testing.T) {
54+
mock := &mockClient{
55+
queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) {
56+
return testResultsResponse, nil
57+
},
58+
}
59+
60+
root, buf := newTestRoot(mock)
61+
root.SetArgs([]string{"execution", "results", "01ABC", "-o", "json"})
62+
require.NoError(t, root.Execute())
63+
64+
var got models.ResultsResponse
65+
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
66+
assert.Equal(t, int64(4125432), got.QueryID)
67+
assert.Equal(t, "QUERY_STATE_COMPLETED", got.State)
68+
}
69+
70+
func TestResultsPending(t *testing.T) {
71+
mock := &mockClient{
72+
queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) {
73+
return &models.ResultsResponse{
74+
State: "QUERY_STATE_PENDING",
75+
}, nil
76+
},
77+
}
78+
79+
root, buf := newTestRoot(mock)
80+
root.SetArgs([]string{"execution", "results", "01ABC"})
81+
require.NoError(t, root.Execute())
82+
83+
out := buf.String()
84+
assert.Contains(t, out, "Execution ID: 01ABC")
85+
assert.Contains(t, out, "State: QUERY_STATE_PENDING")
86+
}
87+
88+
func TestResultsExecuting(t *testing.T) {
89+
mock := &mockClient{
90+
queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) {
91+
return &models.ResultsResponse{
92+
State: "QUERY_STATE_EXECUTING",
93+
}, nil
94+
},
95+
}
96+
97+
root, buf := newTestRoot(mock)
98+
root.SetArgs([]string{"execution", "results", "01ABC"})
99+
require.NoError(t, root.Execute())
100+
101+
out := buf.String()
102+
assert.Contains(t, out, "State: QUERY_STATE_EXECUTING")
103+
}
104+
105+
func TestResultsFailed(t *testing.T) {
106+
mock := &mockClient{
107+
queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) {
108+
return &models.ResultsResponse{
109+
State: "QUERY_STATE_FAILED",
110+
Error: &models.ExecutionError{
111+
Type: "EXECUTION_ERROR",
112+
Message: "syntax error at line 1",
113+
},
114+
}, nil
115+
},
116+
}
117+
118+
root, _ := newTestRoot(mock)
119+
root.SetArgs([]string{"execution", "results", "01ABC"})
120+
err := root.Execute()
121+
require.Error(t, err)
122+
assert.Contains(t, err.Error(), "syntax error at line 1")
123+
}
124+
125+
func TestResultsCancelled(t *testing.T) {
126+
now := time.Now()
127+
mock := &mockClient{
128+
queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) {
129+
return &models.ResultsResponse{
130+
State: "QUERY_STATE_CANCELLED",
131+
CancelledAt: &now,
132+
}, nil
133+
},
134+
}
135+
136+
root, _ := newTestRoot(mock)
137+
root.SetArgs([]string{"execution", "results", "01ABC"})
138+
err := root.Execute()
139+
require.Error(t, err)
140+
assert.Contains(t, err.Error(), "cancelled")
141+
}
142+
143+
func TestResultsWithLimitAndOffset(t *testing.T) {
144+
var capturedOpts models.ResultOptions
145+
mock := &mockClient{
146+
queryResultsV2Fn: func(_ string, opts models.ResultOptions) (*models.ResultsResponse, error) {
147+
capturedOpts = opts
148+
return testResultsResponse, nil
149+
},
150+
}
151+
152+
root, _ := newTestRoot(mock)
153+
root.SetArgs([]string{"execution", "results", "01ABC", "--limit", "10", "--offset", "5"})
154+
require.NoError(t, root.Execute())
155+
156+
require.NotNil(t, capturedOpts.Page)
157+
assert.Equal(t, uint32(10), capturedOpts.Page.Limit)
158+
assert.Equal(t, uint64(5), capturedOpts.Page.Offset)
159+
}
160+
161+
func TestResultsAPIError(t *testing.T) {
162+
mock := &mockClient{
163+
queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) {
164+
return nil, errors.New("api: connection refused")
165+
},
166+
}
167+
168+
root, _ := newTestRoot(mock)
169+
root.SetArgs([]string{"execution", "results", "01ABC"})
170+
err := root.Execute()
171+
require.Error(t, err)
172+
assert.Contains(t, err.Error(), "api: connection refused")
173+
}
174+
175+
func TestResultsMissingArgument(t *testing.T) {
176+
root, _ := newTestRoot(&mockClient{})
177+
root.SetArgs([]string{"execution", "results"})
178+
err := root.Execute()
179+
require.Error(t, err)
180+
assert.Contains(t, err.Error(), "accepts 1 arg(s)")
181+
}

cmd/execution/testutil_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package execution_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
7+
"github.com/duneanalytics/cli/cmd/execution"
8+
"github.com/duneanalytics/cli/cmdutil"
9+
"github.com/duneanalytics/duneapi-client-go/dune"
10+
"github.com/duneanalytics/duneapi-client-go/models"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type mockClient struct {
15+
dune.DuneClient
16+
queryResultsV2Fn func(string, models.ResultOptions) (*models.ResultsResponse, error)
17+
}
18+
19+
func (m *mockClient) QueryResultsV2(executionID string, options models.ResultOptions) (*models.ResultsResponse, error) {
20+
return m.queryResultsV2Fn(executionID, options)
21+
}
22+
23+
// newTestRoot builds a root → execution command tree with the mock injected.
24+
func newTestRoot(mock dune.DuneClient) (*cobra.Command, *bytes.Buffer) {
25+
root := &cobra.Command{
26+
Use: "dune",
27+
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
28+
cmdutil.SetClient(cmd, mock)
29+
},
30+
}
31+
root.SetContext(context.Background())
32+
root.AddCommand(execution.NewExecutionCmd())
33+
34+
var buf bytes.Buffer
35+
root.SetOut(&buf)
36+
37+
return root, &buf
38+
}

cmd/query/run.go

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func runWait(cmd *cobra.Command, req models.ExecuteRequest) error {
100100
return fmt.Errorf("%s", msg)
101101
}
102102

103-
return displayResults(cmd, resp)
103+
return output.DisplayResults(cmd, resp)
104104
}
105105

106106
func parseParams(raw []string) (map[string]any, error) {
@@ -121,40 +121,3 @@ func parseParams(raw []string) (map[string]any, error) {
121121
return params, nil
122122
}
123123

124-
func displayResults(cmd *cobra.Command, resp *models.ResultsResponse) error {
125-
w := cmd.OutOrStdout()
126-
127-
if output.FormatFromCmd(cmd) == output.FormatJSON {
128-
return output.PrintJSON(w, resp)
129-
}
130-
131-
limit, _ := cmd.Flags().GetInt("limit")
132-
columns := resp.Result.Metadata.ColumnNames
133-
sourceRows := resp.Result.Rows
134-
totalRows := len(sourceRows)
135-
136-
if limit > 0 && limit < totalRows {
137-
sourceRows = sourceRows[:limit]
138-
}
139-
rows := resultRowsToStrings(sourceRows, columns)
140-
141-
output.PrintTable(w, columns, rows)
142-
if limit > 0 && limit < totalRows {
143-
fmt.Fprintf(w, "\nShowing %d of %d rows\n", limit, totalRows)
144-
} else {
145-
fmt.Fprintf(w, "\n%d rows\n", totalRows)
146-
}
147-
return nil
148-
}
149-
150-
func resultRowsToStrings(rows []map[string]any, columns []string) [][]string {
151-
out := make([][]string, len(rows))
152-
for i, row := range rows {
153-
cells := make([]string, len(columns))
154-
for j, col := range columns {
155-
cells[j] = fmt.Sprintf("%v", row[col])
156-
}
157-
out[i] = cells
158-
}
159-
return out
160-
}

0 commit comments

Comments
 (0)