Skip to content

Commit efe58cf

Browse files
committed
matviews: add dune matview command group
Adds the `dune matview` command group (alias `mv`) to the CLI, consuming duneapi-client-go v0.5.0: - create — materialize a saved query into a table (optionally scheduled) - get — fetch a matview's metadata, size, and refresh schedule - list — list owned matviews (paginated, --all) - update — change settings/schedule; read-modify-write, so it preserves the existing schedule unless --cron/--no-schedule is passed - refresh — trigger an on-demand refresh - delete — permanently delete a matview and its schedule Bumps duneapi-client-go 0.4.8 -> 0.5.0 (adds the matview client methods). Registered in cli/root.go, documented in README.md, with mock-client tests for each subcommand. Verified end-to-end against dev. Part of GRO-416; closes GRO-422.
1 parent dabb239 commit efe58cf

19 files changed

Lines changed: 1212 additions & 3 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ Manage and execute Dune queries.
3838
| `query run <query-id> [--param key=value] [--performance small\|medium\|large] [--limit] [--timeout] [--no-wait]` | Execute a saved query and display results |
3939
| `query run-sql --sql <sql> [--param key=value] [--performance small\|medium\|large] [--limit] [--timeout] [--no-wait]` | Execute raw SQL directly |
4040

41+
### `dune matview` (alias: `mv`)
42+
43+
Create and manage materialized views — query results persisted into a queryable table, optionally refreshed on a schedule.
44+
45+
| Command | Description |
46+
|---------|-------------|
47+
| `matview create --name <name> --query-id <id> [--private] [--performance small\|medium\|large] [--cron <expr>] [--expires-at <RFC3339>]` | Materialize a saved query into a table |
48+
| `matview get <name>` | Get a matview's metadata, size, and refresh schedule |
49+
| `matview list [--limit] [--offset] [--all]` | List the materialized views you own |
50+
| `matview update <name> [--private] [--performance] [--cron <expr>] [--no-schedule] [--expires-at]` | Update settings or the schedule (preserves the schedule unless changed) |
51+
| `matview refresh <name> [--performance small\|medium\|large]` | Trigger an on-demand refresh |
52+
| `matview delete <name>` | Permanently delete a matview and its schedule |
53+
54+
`create` takes a bare name (e.g. `result_token_summary`); `get`, `update`, `refresh`, and `delete` take the fully-qualified SQL name (e.g. `dune.my_team.result_token_summary`).
55+
4156
### `dune execution`
4257

4358
Manage query executions.

cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/duneanalytics/cli/cmd/dataset"
2121
"github.com/duneanalytics/cli/cmd/docs"
2222
"github.com/duneanalytics/cli/cmd/execution"
23+
"github.com/duneanalytics/cli/cmd/matview"
2324
"github.com/duneanalytics/cli/cmd/query"
2425
"github.com/duneanalytics/cli/cmd/sim"
2526
"github.com/duneanalytics/cli/cmd/usage"
@@ -122,6 +123,7 @@ func init() {
122123
rootCmd.AddCommand(dataset.NewDatasetCmd())
123124
rootCmd.AddCommand(docs.NewDocsCmd())
124125
rootCmd.AddCommand(query.NewQueryCmd())
126+
rootCmd.AddCommand(matview.NewMatviewCmd())
125127
rootCmd.AddCommand(execution.NewExecutionCmd())
126128
rootCmd.AddCommand(usage.NewUsageCmd())
127129
rootCmd.AddCommand(whoami.NewWhoAmICmd())

cmd/matview/create.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package matview
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 newCreateCmd() *cobra.Command {
13+
cmd := &cobra.Command{
14+
Use: "create",
15+
Short: "Materialize a saved query into a table",
16+
Long: "Create a materialized view from an existing saved query. The query's results are\n" +
17+
"written into a queryable table and an immediate execution is triggered.\n\n" +
18+
"Requirements:\n" +
19+
" - The source query must be saved (non-temporary) and have no parameters.\n" +
20+
" - The query must be owned by the same user/team as the matview.\n" +
21+
" - The name must start with \"result_\", be 8-128 lowercase alphanumeric/underscore\n" +
22+
" characters, and not end with an underscore. It is immutable after creation.\n\n" +
23+
"The matview is owned by your authenticated context: a team API key creates a\n" +
24+
"team-owned matview, a personal key a personal one.\n\n" +
25+
"If a matview with this name already exists for the same query it is replaced\n" +
26+
"(re-run). To change settings on an existing matview, use `dune matview update`.\n\n" +
27+
"With --cron the matview is refreshed periodically (5-field cron, minimum 15-minute\n" +
28+
"interval).\n\n" +
29+
"Examples:\n" +
30+
" dune matview create --name result_token_summary --query-id 12345 --performance medium\n" +
31+
" dune matview create --name result_daily --query-id 12345 --cron \"0 */6 * * *\"\n" +
32+
" dune matview create --name result_public --query-id 12345 --private=false",
33+
RunE: runCreate,
34+
}
35+
36+
cmd.Flags().String("name", "", "SQL-safe matview name, e.g. \"result_token_summary\" (required)")
37+
cmd.Flags().Int("query-id", 0, "ID of the source query to materialize (required)")
38+
cmd.Flags().Bool("private", true, "make the matview private; private matviews require a supporting plan")
39+
cmd.Flags().String("performance", "", "execution tier: \"small\", \"medium\", or \"large\" (default: account default)")
40+
cmd.Flags().String("cron", "", "5-field cron for periodic refresh, min 15-minute interval (default: none)")
41+
cmd.Flags().String("expires-at", "", "RFC3339 time when the refresh schedule expires (requires --cron)")
42+
_ = cmd.MarkFlagRequired("name")
43+
_ = cmd.MarkFlagRequired("query-id")
44+
output.AddFormatFlag(cmd, "text")
45+
46+
return cmd
47+
}
48+
49+
func runCreate(cmd *cobra.Command, _ []string) error {
50+
name, _ := cmd.Flags().GetString("name")
51+
if err := validateMatviewName(name); err != nil {
52+
return err
53+
}
54+
performance, err := parsePerformance(cmd)
55+
if err != nil {
56+
return err
57+
}
58+
if cmd.Flags().Changed("expires-at") && !cmd.Flags().Changed("cron") {
59+
return fmt.Errorf("--expires-at requires --cron (expiry applies to the refresh schedule)")
60+
}
61+
62+
queryID, _ := cmd.Flags().GetInt("query-id")
63+
private, _ := cmd.Flags().GetBool("private")
64+
65+
req := models.UpsertMaterializedViewRequest{
66+
Name: name,
67+
QueryID: queryID,
68+
IsPrivate: private,
69+
Performance: performance,
70+
}
71+
if cmd.Flags().Changed("cron") {
72+
cron, _ := cmd.Flags().GetString("cron")
73+
req.CronExpression = &cron
74+
}
75+
if cmd.Flags().Changed("expires-at") {
76+
expiresAt, _ := cmd.Flags().GetString("expires-at")
77+
req.ExpiresAt = &expiresAt
78+
}
79+
80+
client := cmdutil.ClientFromCmd(cmd)
81+
resp, err := client.UpsertMaterializedView(req)
82+
if err != nil {
83+
return err
84+
}
85+
86+
w := cmd.OutOrStdout()
87+
switch output.FormatFromCmd(cmd) {
88+
case output.FormatJSON:
89+
return output.PrintJSON(w, resp)
90+
default:
91+
fmt.Fprintf(w, "Created materialized view %s\n", resp.SQLID)
92+
fmt.Fprintf(w, "Execution ID: %s\n", resp.ExecutionID)
93+
fmt.Fprintf(w, "\nThe matview is being built. Check results with:\n")
94+
fmt.Fprintf(w, " dune execution results %s\n", resp.ExecutionID)
95+
return nil
96+
}
97+
}

cmd/matview/create_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package matview_test
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"testing"
7+
8+
"github.com/duneanalytics/duneapi-client-go/models"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestCreateSuccess(t *testing.T) {
14+
var got models.UpsertMaterializedViewRequest
15+
mock := &mockClient{
16+
upsertFn: func(req models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
17+
got = req
18+
return &models.UpsertMaterializedViewResponse{
19+
SQLID: "dune.my_team.result_token_summary",
20+
ExecutionID: "01HZ065",
21+
}, nil
22+
},
23+
}
24+
25+
root, buf := newTestRoot(mock)
26+
root.SetArgs([]string{
27+
"matview", "create",
28+
"--name", "result_token_summary",
29+
"--query-id", "12345",
30+
"--performance", "medium",
31+
})
32+
require.NoError(t, root.Execute())
33+
34+
assert.Equal(t, "result_token_summary", got.Name)
35+
assert.Equal(t, 12345, got.QueryID)
36+
assert.True(t, got.IsPrivate, "private should default to true")
37+
assert.Equal(t, "medium", got.Performance)
38+
assert.Nil(t, got.CronExpression)
39+
40+
out := buf.String()
41+
assert.Contains(t, out, "Created materialized view dune.my_team.result_token_summary")
42+
assert.Contains(t, out, "Execution ID: 01HZ065")
43+
}
44+
45+
func TestCreateWithCron(t *testing.T) {
46+
var got models.UpsertMaterializedViewRequest
47+
mock := &mockClient{
48+
upsertFn: func(req models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
49+
got = req
50+
return &models.UpsertMaterializedViewResponse{SQLID: "dune.t.result_x", ExecutionID: "e1"}, nil
51+
},
52+
}
53+
54+
root, _ := newTestRoot(mock)
55+
root.SetArgs([]string{
56+
"matview", "create",
57+
"--name", "result_scheduled",
58+
"--query-id", "7",
59+
"--cron", "0 */6 * * *",
60+
"--private=false",
61+
})
62+
require.NoError(t, root.Execute())
63+
64+
require.NotNil(t, got.CronExpression)
65+
assert.Equal(t, "0 */6 * * *", *got.CronExpression)
66+
assert.False(t, got.IsPrivate)
67+
}
68+
69+
func TestCreateInvalidName(t *testing.T) {
70+
root, _ := newTestRoot(&mockClient{})
71+
root.SetArgs([]string{"matview", "create", "--name", "not_result", "--query-id", "1"})
72+
err := root.Execute()
73+
require.Error(t, err)
74+
assert.Contains(t, err.Error(), "invalid matview name")
75+
}
76+
77+
func TestCreateExpiresAtRequiresCron(t *testing.T) {
78+
root, _ := newTestRoot(&mockClient{})
79+
root.SetArgs([]string{
80+
"matview", "create",
81+
"--name", "result_token_summary",
82+
"--query-id", "1",
83+
"--expires-at", "2026-09-11T23:59:59Z",
84+
})
85+
err := root.Execute()
86+
require.Error(t, err)
87+
assert.Contains(t, err.Error(), "--expires-at requires --cron")
88+
}
89+
90+
func TestCreateInvalidPerformance(t *testing.T) {
91+
root, _ := newTestRoot(&mockClient{})
92+
root.SetArgs([]string{
93+
"matview", "create",
94+
"--name", "result_token_summary",
95+
"--query-id", "1",
96+
"--performance", "huge",
97+
})
98+
err := root.Execute()
99+
require.Error(t, err)
100+
assert.Contains(t, err.Error(), "invalid performance tier")
101+
}
102+
103+
func TestCreateJSONOutput(t *testing.T) {
104+
mock := &mockClient{
105+
upsertFn: func(models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
106+
return &models.UpsertMaterializedViewResponse{SQLID: "dune.t.result_x", ExecutionID: "e1"}, nil
107+
},
108+
}
109+
root, buf := newTestRoot(mock)
110+
root.SetArgs([]string{"matview", "create", "--name", "result_x_long", "--query-id", "1", "-o", "json"})
111+
require.NoError(t, root.Execute())
112+
113+
var resp models.UpsertMaterializedViewResponse
114+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
115+
assert.Equal(t, "dune.t.result_x", resp.SQLID)
116+
}
117+
118+
func TestCreateAPIError(t *testing.T) {
119+
mock := &mockClient{
120+
upsertFn: func(models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
121+
return nil, errors.New("api: plan does not allow private matviews")
122+
},
123+
}
124+
root, _ := newTestRoot(mock)
125+
root.SetArgs([]string{"matview", "create", "--name", "result_token_summary", "--query-id", "1"})
126+
err := root.Execute()
127+
require.Error(t, err)
128+
assert.Contains(t, err.Error(), "plan does not allow private matviews")
129+
}

cmd/matview/delete.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package matview
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/duneanalytics/cli/cmdutil"
7+
"github.com/duneanalytics/cli/output"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func newDeleteCmd() *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "delete <name>",
14+
Short: "Permanently delete a materialized view",
15+
Long: "Permanently delete a materialized view and its refresh schedule. This drops the\n" +
16+
"underlying table — it can no longer be queried. This action cannot be undone.\n\n" +
17+
"The name must be fully qualified, e.g. dune.my_team.result_token_summary.\n" +
18+
"You must own the matview.\n\n" +
19+
"Examples:\n" +
20+
" dune matview delete dune.my_team.result_token_summary\n" +
21+
" dune mv delete dune.my_team.result_token_summary -o json",
22+
Args: cobra.ExactArgs(1),
23+
RunE: runDelete,
24+
}
25+
26+
output.AddFormatFlag(cmd, "text")
27+
28+
return cmd
29+
}
30+
31+
func runDelete(cmd *cobra.Command, args []string) error {
32+
client := cmdutil.ClientFromCmd(cmd)
33+
34+
resp, err := client.DeleteMaterializedView(args[0])
35+
if err != nil {
36+
return err
37+
}
38+
39+
w := cmd.OutOrStdout()
40+
switch output.FormatFromCmd(cmd) {
41+
case output.FormatJSON:
42+
return output.PrintJSON(w, resp)
43+
default:
44+
fmt.Fprintf(w, "Deleted materialized view %s\n", args[0])
45+
return nil
46+
}
47+
}

cmd/matview/delete_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package matview_test
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/duneanalytics/duneapi-client-go/models"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestDeleteSuccess(t *testing.T) {
13+
var gotName string
14+
mock := &mockClient{
15+
deleteFn: func(name string) (*models.DeleteMaterializedViewResponse, error) {
16+
gotName = name
17+
return &models.DeleteMaterializedViewResponse{Message: "ok"}, nil
18+
},
19+
}
20+
21+
root, buf := newTestRoot(mock)
22+
root.SetArgs([]string{"matview", "delete", "dune.my_team.result_token_summary"})
23+
require.NoError(t, root.Execute())
24+
25+
assert.Equal(t, "dune.my_team.result_token_summary", gotName)
26+
assert.Contains(t, buf.String(), "Deleted materialized view dune.my_team.result_token_summary")
27+
}
28+
29+
func TestDeleteViaAlias(t *testing.T) {
30+
mock := &mockClient{
31+
deleteFn: func(string) (*models.DeleteMaterializedViewResponse, error) {
32+
return &models.DeleteMaterializedViewResponse{Message: "ok"}, nil
33+
},
34+
}
35+
root, buf := newTestRoot(mock)
36+
root.SetArgs([]string{"mv", "delete", "dune.t.result_x"})
37+
require.NoError(t, root.Execute())
38+
assert.Contains(t, buf.String(), "Deleted materialized view dune.t.result_x")
39+
}
40+
41+
func TestDeleteAPIError(t *testing.T) {
42+
mock := &mockClient{
43+
deleteFn: func(string) (*models.DeleteMaterializedViewResponse, error) {
44+
return nil, errors.New("api: Materialized view not found")
45+
},
46+
}
47+
root, _ := newTestRoot(mock)
48+
root.SetArgs([]string{"matview", "delete", "dune.t.result_missing"})
49+
err := root.Execute()
50+
require.Error(t, err)
51+
assert.Contains(t, err.Error(), "Materialized view not found")
52+
}

0 commit comments

Comments
 (0)