matviews: add dune matview command group#61
Conversation
PR SummaryLow Risk Overview create materializes a saved query via upsert, with optional cron/ The command is registered in Reviewed by Cursor Bugbot for commit 85e120f. Configure here. |
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Update allows no-op upsert
- Added guard to require at least one flag (--private, --performance, --cron, --no-schedule, or --expires-at) before allowing upsert, matching behavior of query and dashboard update commands.
Or push these changes by commenting:
@cursor push 8b185aca25
Preview (8b185aca25)
diff --git a/cmd/matview/update.go b/cmd/matview/update.go
--- a/cmd/matview/update.go
+++ b/cmd/matview/update.go
@@ -47,6 +47,18 @@
return err
}
+ changedFlags := []string{"private", "performance", "cron", "no-schedule", "expires-at"}
+ hasChange := false
+ for _, f := range changedFlags {
+ if cmd.Flags().Changed(f) {
+ hasChange = true
+ break
+ }
+ }
+ if !hasChange {
+ return fmt.Errorf("at least one flag must be provided (--private, --performance, --cron, --no-schedule, or --expires-at)")
+ }
+
client := cmdutil.ClientFromCmd(cmd)
// Read-modify-write: fetch the current state so unspecified settings — especially theYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Update lacks required change flags
- Added validation to require at least one mutable flag before proceeding with update, preventing accidental query execution and aligning with other CLI update commands.
Or push these changes by commenting:
@cursor push 22448c2ae6
Preview (22448c2ae6)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -38,6 +38,21 @@
| `query run <query-id> [--param key=value] [--performance small\|medium\|large] [--limit] [--timeout] [--no-wait]` | Execute a saved query and display results |
| `query run-sql --sql <sql> [--param key=value] [--performance small\|medium\|large] [--limit] [--timeout] [--no-wait]` | Execute raw SQL directly |
+### `dune matview` (alias: `mv`)
+
+Create and manage materialized views — query results persisted into a queryable table, optionally refreshed on a schedule.
+
+| Command | Description |
+|---------|-------------|
+| `matview create --name <name> --query-id <id> [--private] [--performance small\|medium\|large] [--cron <expr>] [--expires-at <RFC3339>]` | Materialize a saved query into a table |
+| `matview get <name>` | Get a matview's metadata, size, and refresh schedule |
+| `matview list [--limit] [--offset] [--all]` | List the materialized views you own |
+| `matview update <name> [--private] [--performance] [--cron <expr>] [--no-schedule] [--expires-at]` | Update settings or the schedule (preserves the schedule unless changed) |
+| `matview refresh <name> [--performance small\|medium\|large]` | Trigger an on-demand refresh |
+| `matview delete <name>` | Permanently delete a matview and its schedule |
+
+`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`).
+
### `dune execution`
Manage query executions.
diff --git a/cli/root.go b/cli/root.go
--- a/cli/root.go
+++ b/cli/root.go
@@ -20,6 +20,7 @@
"github.com/duneanalytics/cli/cmd/dataset"
"github.com/duneanalytics/cli/cmd/docs"
"github.com/duneanalytics/cli/cmd/execution"
+ "github.com/duneanalytics/cli/cmd/matview"
"github.com/duneanalytics/cli/cmd/query"
"github.com/duneanalytics/cli/cmd/sim"
"github.com/duneanalytics/cli/cmd/usage"
@@ -122,6 +123,7 @@
rootCmd.AddCommand(dataset.NewDatasetCmd())
rootCmd.AddCommand(docs.NewDocsCmd())
rootCmd.AddCommand(query.NewQueryCmd())
+ rootCmd.AddCommand(matview.NewMatviewCmd())
rootCmd.AddCommand(execution.NewExecutionCmd())
rootCmd.AddCommand(usage.NewUsageCmd())
rootCmd.AddCommand(whoami.NewWhoAmICmd())
diff --git a/cmd/matview/create.go b/cmd/matview/create.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/create.go
@@ -1,0 +1,97 @@
+package matview
+
+import (
+ "fmt"
+
+ "github.com/duneanalytics/cli/cmdutil"
+ "github.com/duneanalytics/cli/output"
+ "github.com/duneanalytics/duneapi-client-go/models"
+ "github.com/spf13/cobra"
+)
+
+func newCreateCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Materialize a saved query into a table",
+ Long: "Create a materialized view from an existing saved query. The query's results are\n" +
+ "written into a queryable table and an immediate execution is triggered.\n\n" +
+ "Requirements:\n" +
+ " - The source query must be saved (non-temporary) and have no parameters.\n" +
+ " - The query must be owned by the same user/team as the matview.\n" +
+ " - The name must start with \"result_\", be 8-128 lowercase alphanumeric/underscore\n" +
+ " characters, and not end with an underscore. It is immutable after creation.\n\n" +
+ "The matview is owned by your authenticated context: a team API key creates a\n" +
+ "team-owned matview, a personal key a personal one.\n\n" +
+ "If a matview with this name already exists for the same query it is replaced\n" +
+ "(re-run). To change settings on an existing matview, use `dune matview update`.\n\n" +
+ "With --cron the matview is refreshed periodically (5-field cron, minimum 15-minute\n" +
+ "interval).\n\n" +
+ "Examples:\n" +
+ " dune matview create --name result_token_summary --query-id 12345 --performance medium\n" +
+ " dune matview create --name result_daily --query-id 12345 --cron \"0 */6 * * *\"\n" +
+ " dune matview create --name result_public --query-id 12345 --private=false",
+ RunE: runCreate,
+ }
+
+ cmd.Flags().String("name", "", "SQL-safe matview name, e.g. \"result_token_summary\" (required)")
+ cmd.Flags().Int("query-id", 0, "ID of the source query to materialize (required)")
+ cmd.Flags().Bool("private", true, "make the matview private; private matviews require a supporting plan")
+ cmd.Flags().String("performance", "", "execution tier: \"small\", \"medium\", or \"large\" (default: account default)")
+ cmd.Flags().String("cron", "", "5-field cron for periodic refresh, min 15-minute interval (default: none)")
+ cmd.Flags().String("expires-at", "", "RFC3339 time when the refresh schedule expires (requires --cron)")
+ _ = cmd.MarkFlagRequired("name")
+ _ = cmd.MarkFlagRequired("query-id")
+ output.AddFormatFlag(cmd, "text")
+
+ return cmd
+}
+
+func runCreate(cmd *cobra.Command, _ []string) error {
+ name, _ := cmd.Flags().GetString("name")
+ if err := validateMatviewName(name); err != nil {
+ return err
+ }
+ performance, err := parsePerformance(cmd)
+ if err != nil {
+ return err
+ }
+ if cmd.Flags().Changed("expires-at") && !cmd.Flags().Changed("cron") {
+ return fmt.Errorf("--expires-at requires --cron (expiry applies to the refresh schedule)")
+ }
+
+ queryID, _ := cmd.Flags().GetInt("query-id")
+ private, _ := cmd.Flags().GetBool("private")
+
+ req := models.UpsertMaterializedViewRequest{
+ Name: name,
+ QueryID: queryID,
+ IsPrivate: private,
+ Performance: performance,
+ }
+ if cmd.Flags().Changed("cron") {
+ cron, _ := cmd.Flags().GetString("cron")
+ req.CronExpression = &cron
+ }
+ if cmd.Flags().Changed("expires-at") {
+ expiresAt, _ := cmd.Flags().GetString("expires-at")
+ req.ExpiresAt = &expiresAt
+ }
+
+ client := cmdutil.ClientFromCmd(cmd)
+ resp, err := client.UpsertMaterializedView(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, "Created materialized view %s\n", resp.SQLID)
+ fmt.Fprintf(w, "Execution ID: %s\n", resp.ExecutionID)
+ fmt.Fprintf(w, "\nThe matview is being built. Check results with:\n")
+ fmt.Fprintf(w, " dune execution results %s\n", resp.ExecutionID)
+ return nil
+ }
+}
diff --git a/cmd/matview/create_test.go b/cmd/matview/create_test.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/create_test.go
@@ -1,0 +1,129 @@
+package matview_test
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/duneanalytics/duneapi-client-go/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateSuccess(t *testing.T) {
+ var got models.UpsertMaterializedViewRequest
+ mock := &mockClient{
+ upsertFn: func(req models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
+ got = req
+ return &models.UpsertMaterializedViewResponse{
+ SQLID: "dune.my_team.result_token_summary",
+ ExecutionID: "01HZ065",
+ }, nil
+ },
+ }
+
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{
+ "matview", "create",
+ "--name", "result_token_summary",
+ "--query-id", "12345",
+ "--performance", "medium",
+ })
+ require.NoError(t, root.Execute())
+
+ assert.Equal(t, "result_token_summary", got.Name)
+ assert.Equal(t, 12345, got.QueryID)
+ assert.True(t, got.IsPrivate, "private should default to true")
+ assert.Equal(t, "medium", got.Performance)
+ assert.Nil(t, got.CronExpression)
+
+ out := buf.String()
+ assert.Contains(t, out, "Created materialized view dune.my_team.result_token_summary")
+ assert.Contains(t, out, "Execution ID: 01HZ065")
+}
+
+func TestCreateWithCron(t *testing.T) {
+ var got models.UpsertMaterializedViewRequest
+ mock := &mockClient{
+ upsertFn: func(req models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
+ got = req
+ return &models.UpsertMaterializedViewResponse{SQLID: "dune.t.result_x", ExecutionID: "e1"}, nil
+ },
+ }
+
+ root, _ := newTestRoot(mock)
+ root.SetArgs([]string{
+ "matview", "create",
+ "--name", "result_scheduled",
+ "--query-id", "7",
+ "--cron", "0 */6 * * *",
+ "--private=false",
+ })
+ require.NoError(t, root.Execute())
+
+ require.NotNil(t, got.CronExpression)
+ assert.Equal(t, "0 */6 * * *", *got.CronExpression)
+ assert.False(t, got.IsPrivate)
+}
+
+func TestCreateInvalidName(t *testing.T) {
+ root, _ := newTestRoot(&mockClient{})
+ root.SetArgs([]string{"matview", "create", "--name", "not_result", "--query-id", "1"})
+ err := root.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid matview name")
+}
+
+func TestCreateExpiresAtRequiresCron(t *testing.T) {
+ root, _ := newTestRoot(&mockClient{})
+ root.SetArgs([]string{
+ "matview", "create",
+ "--name", "result_token_summary",
+ "--query-id", "1",
+ "--expires-at", "2026-09-11T23:59:59Z",
+ })
+ err := root.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--expires-at requires --cron")
+}
+
+func TestCreateInvalidPerformance(t *testing.T) {
+ root, _ := newTestRoot(&mockClient{})
+ root.SetArgs([]string{
+ "matview", "create",
+ "--name", "result_token_summary",
+ "--query-id", "1",
+ "--performance", "huge",
+ })
+ err := root.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid performance tier")
+}
+
+func TestCreateJSONOutput(t *testing.T) {
+ mock := &mockClient{
+ upsertFn: func(models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
+ return &models.UpsertMaterializedViewResponse{SQLID: "dune.t.result_x", ExecutionID: "e1"}, nil
+ },
+ }
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "create", "--name", "result_x_long", "--query-id", "1", "-o", "json"})
+ require.NoError(t, root.Execute())
+
+ var resp models.UpsertMaterializedViewResponse
+ require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
+ assert.Equal(t, "dune.t.result_x", resp.SQLID)
+}
+
+func TestCreateAPIError(t *testing.T) {
+ mock := &mockClient{
+ upsertFn: func(models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) {
+ return nil, errors.New("api: plan does not allow private matviews")
+ },
+ }
+ root, _ := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "create", "--name", "result_token_summary", "--query-id", "1"})
+ err := root.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "plan does not allow private matviews")
+}
diff --git a/cmd/matview/delete.go b/cmd/matview/delete.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/delete.go
@@ -1,0 +1,47 @@
+package matview
+
+import (
+ "fmt"
+
+ "github.com/duneanalytics/cli/cmdutil"
+ "github.com/duneanalytics/cli/output"
+ "github.com/spf13/cobra"
+)
+
+func newDeleteCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "delete <name>",
+ Short: "Permanently delete a materialized view",
+ Long: "Permanently delete a materialized view and its refresh schedule. This drops the\n" +
+ "underlying table — it can no longer be queried. This action cannot be undone.\n\n" +
+ "The name must be fully qualified, e.g. dune.my_team.result_token_summary.\n" +
+ "You must own the matview.\n\n" +
+ "Examples:\n" +
+ " dune matview delete dune.my_team.result_token_summary\n" +
+ " dune mv delete dune.my_team.result_token_summary -o json",
+ Args: cobra.ExactArgs(1),
+ RunE: runDelete,
+ }
+
+ output.AddFormatFlag(cmd, "text")
+
+ return cmd
+}
+
+func runDelete(cmd *cobra.Command, args []string) error {
+ client := cmdutil.ClientFromCmd(cmd)
+
+ resp, err := client.DeleteMaterializedView(args[0])
+ if err != nil {
+ return err
+ }
+
+ w := cmd.OutOrStdout()
+ switch output.FormatFromCmd(cmd) {
+ case output.FormatJSON:
+ return output.PrintJSON(w, resp)
+ default:
+ fmt.Fprintf(w, "Deleted materialized view %s\n", args[0])
+ return nil
+ }
+}
diff --git a/cmd/matview/delete_test.go b/cmd/matview/delete_test.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/delete_test.go
@@ -1,0 +1,52 @@
+package matview_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/duneanalytics/duneapi-client-go/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeleteSuccess(t *testing.T) {
+ var gotName string
+ mock := &mockClient{
+ deleteFn: func(name string) (*models.DeleteMaterializedViewResponse, error) {
+ gotName = name
+ return &models.DeleteMaterializedViewResponse{Message: "ok"}, nil
+ },
+ }
+
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "delete", "dune.my_team.result_token_summary"})
+ require.NoError(t, root.Execute())
+
+ assert.Equal(t, "dune.my_team.result_token_summary", gotName)
+ assert.Contains(t, buf.String(), "Deleted materialized view dune.my_team.result_token_summary")
+}
+
+func TestDeleteViaAlias(t *testing.T) {
+ mock := &mockClient{
+ deleteFn: func(string) (*models.DeleteMaterializedViewResponse, error) {
+ return &models.DeleteMaterializedViewResponse{Message: "ok"}, nil
+ },
+ }
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"mv", "delete", "dune.t.result_x"})
+ require.NoError(t, root.Execute())
+ assert.Contains(t, buf.String(), "Deleted materialized view dune.t.result_x")
+}
+
+func TestDeleteAPIError(t *testing.T) {
+ mock := &mockClient{
+ deleteFn: func(string) (*models.DeleteMaterializedViewResponse, error) {
+ return nil, errors.New("api: Materialized view not found")
+ },
+ }
+ root, _ := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "delete", "dune.t.result_missing"})
+ err := root.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Materialized view not found")
+}
diff --git a/cmd/matview/get.go b/cmd/matview/get.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/get.go
@@ -1,0 +1,69 @@
+package matview
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/duneanalytics/cli/cmdutil"
+ "github.com/duneanalytics/cli/output"
+ "github.com/spf13/cobra"
+)
+
+func newGetCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "get <name>",
+ Short: "Fetch a materialized view by its fully-qualified SQL name",
+ Long: "Retrieve a matview's metadata: source query, privacy, table size, recent execution\n" +
+ "IDs, and refresh schedule (if any). Only matviews visible to you are returned.\n\n" +
+ "The name must be fully qualified, e.g. dune.my_team.result_token_summary.\n\n" +
+ "Examples:\n" +
+ " dune matview get dune.my_team.result_token_summary\n" +
+ " dune mv get dune.my_team.result_token_summary -o json",
+ Args: cobra.ExactArgs(1),
+ RunE: runGet,
+ }
+
+ output.AddFormatFlag(cmd, "text")
+
+ return cmd
+}
+
+func runGet(cmd *cobra.Command, args []string) error {
+ client := cmdutil.ClientFromCmd(cmd)
+
+ resp, err := client.GetMaterializedView(args[0])
+ if err != nil {
+ return err
+ }
+
+ w := cmd.OutOrStdout()
+ switch output.FormatFromCmd(cmd) {
+ case output.FormatJSON:
+ return output.PrintJSON(w, resp)
+ default:
+ fmt.Fprintf(w, "Name: %s\n", resp.SQLID)
+ fmt.Fprintf(w, "Query ID: %d\n", resp.QueryID)
+ fmt.Fprintf(w, "Private: %t\n", resp.IsPrivate)
+ fmt.Fprintf(w, "Table size: %s\n", output.FormatBytes(resp.TableSizeBytes))
+ if len(resp.LastExecutionIDs) > 0 {
+ fmt.Fprintf(w, "Executions: %s\n", strings.Join(resp.LastExecutionIDs, ", "))
+ }
+ fmt.Fprintln(w)
+ if resp.Schedule == nil {
+ fmt.Fprintln(w, "Refresh schedule: none")
+ return nil
+ }
+ fmt.Fprintln(w, "Refresh schedule:")
+ fmt.Fprintf(w, " Cron: %s\n", resp.Schedule.CronExpression)
+ if resp.Schedule.Performance != "" {
+ fmt.Fprintf(w, " Performance: %s\n", resp.Schedule.Performance)
+ }
+ if resp.Schedule.NextExecutionTime != nil {
+ fmt.Fprintf(w, " Next run: %s\n", *resp.Schedule.NextExecutionTime)
+ }
+ if resp.Schedule.ExpiresAt != nil {
+ fmt.Fprintf(w, " Expires at: %s\n", *resp.Schedule.ExpiresAt)
+ }
+ return nil
+ }
+}
diff --git a/cmd/matview/get_test.go b/cmd/matview/get_test.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/get_test.go
@@ -1,0 +1,104 @@
+package matview_test
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/duneanalytics/duneapi-client-go/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetWithSchedule(t *testing.T) {
+ mock := &mockClient{
+ getFn: func(name string) (*models.GetMaterializedViewResponse, error) {
+ assert.Equal(t, "dune.my_team.result_token_summary", name)
+ return &models.GetMaterializedViewResponse{
+ SQLID: "dune.my_team.result_token_summary",
+ QueryID: 12345,
+ IsPrivate: true,
+ TableSizeBytes: 2048,
+ LastExecutionIDs: []string{"exec_1", "exec_2"},
+ Schedule: &models.MaterializedViewSchedule{
+ CronExpression: "0 */6 * * *",
+ Performance: "medium",
+ NextExecutionTime: strptr("2026-06-12T00:00:00Z"),
+ ExpiresAt: strptr("2026-09-11T23:59:59Z"),
+ },
+ }, nil
+ },
+ }
+
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "get", "dune.my_team.result_token_summary"})
+ require.NoError(t, root.Execute())
+
+ out := buf.String()
+ assert.Contains(t, out, "Name: dune.my_team.result_token_summary")
+ assert.Contains(t, out, "Query ID: 12345")
+ assert.Contains(t, out, "Private: true")
+ assert.Contains(t, out, "Table size: 2.0 KB")
+ assert.Contains(t, out, "Executions: exec_1, exec_2")
+ assert.Contains(t, out, "Refresh schedule:")
+ assert.Contains(t, out, "Cron: 0 */6 * * *")
+ assert.Contains(t, out, "Performance: medium")
+ assert.Contains(t, out, "Next run: 2026-06-12T00:00:00Z")
+ assert.Contains(t, out, "Expires at: 2026-09-11T23:59:59Z")
+}
+
+func TestGetWithoutSchedule(t *testing.T) {
+ mock := &mockClient{
+ getFn: func(string) (*models.GetMaterializedViewResponse, error) {
+ return &models.GetMaterializedViewResponse{
+ SQLID: "dune.my_team.result_x",
+ QueryID: 7,
+ }, nil
+ },
+ }
+
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "get", "dune.my_team.result_x"})
+ require.NoError(t, root.Execute())
+
+ out := buf.String()
+ assert.Contains(t, out, "Refresh schedule: none")
+ assert.NotContains(t, out, "Cron:")
+}
+
+func TestGetJSONOutput(t *testing.T) {
+ mock := &mockClient{
+ getFn: func(string) (*models.GetMaterializedViewResponse, error) {
+ return &models.GetMaterializedViewResponse{SQLID: "dune.t.result_x", QueryID: 7, IsPrivate: true}, nil
+ },
+ }
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "get", "dune.t.result_x", "-o", "json"})
+ require.NoError(t, root.Execute())
+
+ var resp models.GetMaterializedViewResponse
+ require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
+ assert.Equal(t, "dune.t.result_x", resp.SQLID)
+ assert.Equal(t, 7, resp.QueryID)
+}
+
+func TestGetMissingArgument(t *testing.T) {
+ root, _ := newTestRoot(&mockClient{})
+ root.SetArgs([]string{"matview", "get"})
+ err := root.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "accepts 1 arg(s)")
+}
+
+func TestGetAPIError(t *testing.T) {
+ mock := &mockClient{
+ getFn: func(string) (*models.GetMaterializedViewResponse, error) {
+ return nil, errors.New("api: not found")
+ },
+ }
+ root, _ := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "get", "dune.t.result_missing"})
+ err := root.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
diff --git a/cmd/matview/helpers.go b/cmd/matview/helpers.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/helpers.go
@@ -1,0 +1,55 @@
+package matview
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// matviewNameRe mirrors the server-side matview name validator (handlers/matviews and the
+// GraphQL matviewName validator): a "result_" prefix, lowercase alphanumerics and underscores,
+// and no trailing underscore.
+var matviewNameRe = regexp.MustCompile(`^result_([a-z0-9_]{0,}[a-z0-9]+)?$`)
+
+// validateMatviewName checks a bare matview name client-side for a fast, friendly failure.
+// The server remains the source of truth.
+func validateMatviewName(name string) error {
+ if len(name) < 8 || len(name) > 128 {
+ return fmt.Errorf("invalid matview name %q: must be between 8 and 128 characters", name)
+ }
+ if !matviewNameRe.MatchString(name) {
+ return fmt.Errorf(
+ "invalid matview name %q: must start with \"result_\", contain only lowercase letters, "+
+ "numbers, and underscores, and not end with an underscore",
+ name,
+ )
+ }
+ return nil
+}
+
+// parsePerformance validates the --performance flag. An empty value means "use the account
+// default tier".
+func parsePerformance(cmd *cobra.Command) (string, error) {
+ performance, _ := cmd.Flags().GetString("performance")
+ switch performance {
+ case "", "small", "medium", "large":
+ return performance, nil
+ default:
+ return "", fmt.Errorf(
+ "invalid performance tier %q: must be \"small\", \"medium\", or \"large\"",
+ performance,
+ )
+ }
+}
+
+// bareName returns the table segment of a fully-qualified SQL name
+// (e.g. "dune.my_team.result_x" -> "result_x"). The upsert endpoint takes the bare name, while
+// get/refresh/delete take the fully-qualified name.
+func bareName(fqName string) string {
+ if i := strings.LastIndex(fqName, "."); i >= 0 {
+ return fqName[i+1:]
+ }
+ return fqName
+}
diff --git a/cmd/matview/list.go b/cmd/matview/list.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/list.go
@@ -1,0 +1,90 @@
+package matview
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/duneanalytics/cli/cmdutil"
+ "github.com/duneanalytics/cli/output"
+ "github.com/duneanalytics/duneapi-client-go/models"
+ "github.com/spf13/cobra"
+)
+
+func newListCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List the materialized views you own",
+ Long: "List materialized views owned by the authenticated user or team, paginated.\n\n" +
+ "Examples:\n" +
+ " dune matview list\n" +
+ " dune matview list --limit 50 --offset 100\n" +
+ " dune matview list --all -o json",
+ RunE: runList,
+ }
+
+ cmd.Flags().Int("limit", 100, "max number of matviews to return per page (max 1000)")
+ cmd.Flags().Int("offset", 0, "pagination offset")
+ cmd.Flags().Bool("all", false, "fetch all pages (ignores --offset)")
+ output.AddFormatFlag(cmd, "text")
+
+ return cmd
+}
+
+func runList(cmd *cobra.Command, _ []string) error {
+ limit, _ := cmd.Flags().GetInt("limit")
+ offset, _ := cmd.Flags().GetInt("offset")
+ all, _ := cmd.Flags().GetBool("all")
+
+ client := cmdutil.ClientFromCmd(cmd)
+
+ result := &models.ListMaterializedViewsResponse{}
+ if all {
+ for nextOffset := 0; ; {
+ resp, err := client.ListMaterializedViews(limit, nextOffset)
+ if err != nil {
+ return err
+ }
+ result.MaterializedViews = append(result.MaterializedViews, resp.MaterializedViews...)
+ if resp.NextOffset <= 0 {
+ break
+ }
+ nextOffset = int(resp.NextOffset)
+ }
+ } else {
+ resp, err := client.ListMaterializedViews(limit, offset)
+ if err != nil {
+ return err
+ }
+ result.MaterializedViews = resp.MaterializedViews
+ result.NextOffset = resp.NextOffset
+ }
+
+ w := cmd.OutOrStdout()
+ switch output.FormatFromCmd(cmd) {
+ case output.FormatJSON:
+ return output.PrintJSON(w, result)
+ default:
+ if len(result.MaterializedViews) == 0 {
+ fmt.Fprintln(w, "No materialized views found.")
+ return nil
+ }
+ rows := make([][]string, 0, len(result.MaterializedViews))
+ for _, mv := range result.MaterializedViews {
+ private := "no"
+ if mv.IsPrivate {
+ private = "yes"
+ }
+ rows = append(rows, []string{
+ mv.SQLID,
+ strconv.Itoa(mv.QueryID),
+ private,
+ output.FormatBytes(mv.TableSizeBytes),
+ })
+ }
+ output.PrintTable(w, []string{"NAME", "QUERY ID", "PRIVATE", "SIZE"}, rows)
+ if result.NextOffset > 0 {
+ fmt.Fprintf(w, "\nMore results available. Next: --offset %d (or use --all)\n", result.NextOffset)
+ }
+ return nil
+ }
+}
diff --git a/cmd/matview/list_test.go b/cmd/matview/list_test.go
new file mode 100644
--- /dev/null
+++ b/cmd/matview/list_test.go
@@ -1,0 +1,95 @@
+package matview_test
+
+import (
+ "testing"
+
+ "github.com/duneanalytics/duneapi-client-go/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestListSuccess(t *testing.T) {
+ var gotLimit, gotOffset int
+ mock := &mockClient{
+ listFn: func(limit, offset int) (*models.ListMaterializedViewsResponse, error) {
+ gotLimit, gotOffset = limit, offset
+ return &models.ListMaterializedViewsResponse{
+ MaterializedViews: []*models.MaterializedViewListElement{
+ {SQLID: "dune.t.result_a", QueryID: 1, IsPrivate: true, TableSizeBytes: 1024},
+ {SQLID: "dune.t.result_b", QueryID: 2, IsPrivate: false, TableSizeBytes: 0},
+ },
+ }, nil
+ },
+ }
+
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "list", "--limit", "50", "--offset", "10"})
+ require.NoError(t, root.Execute())
+
+ assert.Equal(t, 50, gotLimit)
+ assert.Equal(t, 10, gotOffset)
+ out := buf.String()
+ assert.Contains(t, out, "NAME")
+ assert.Contains(t, out, "dune.t.result_a")
+ assert.Contains(t, out, "dune.t.result_b")
+ assert.Contains(t, out, "yes")
+ assert.Contains(t, out, "no")
+}
+
+func TestListEmpty(t *testing.T) {
+ mock := &mockClient{
+ listFn: func(int, int) (*models.ListMaterializedViewsResponse, error) {
+ return &models.ListMaterializedViewsResponse{}, nil
+ },
+ }
+ root, buf := newTestRoot(mock)
+ root.SetArgs([]string{"matview", "list"})
+ require.NoError(t, root.Execute())
+ assert.Contains(t, buf.String(), "No materialized views found.")
+}
+
+func TestListAllPaginates(t *testing.T) {
+ var calls []int // offsets requested
+ mock := &mockClient{
+ listFn: func(limit, offset int) (*models.ListMaterializedViewsResponse, error) {
+ calls = append(calls, offset)
+ switch offset {
+ case 0:
+ return &models.ListMaterializedViewsResponse{
... diff truncated: showing 800 of 1362 linesYou can send follow-ups to the cloud agent here.
efe58cf to
b192181
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: No-schedule false triggers noop refresh
- Modified validation guard to only treat --no-schedule as a change when set to true, preventing false-only invocations from bypassing the no-op check.
Or push these changes by commenting:
@cursor push 86a41e059c
Preview (86a41e059c)
diff --git a/cmd/matview/update.go b/cmd/matview/update.go
--- a/cmd/matview/update.go
+++ b/cmd/matview/update.go
@@ -49,8 +49,10 @@
// Require at least one change flag. Updating re-executes the source query (consuming
// credits), so a no-op update must not silently trigger a refresh.
+ noSchedule, _ := cmd.Flags().GetBool("no-schedule")
+ noScheduleChanged := cmd.Flags().Changed("no-schedule") && noSchedule
if !cmd.Flags().Changed("private") && !cmd.Flags().Changed("performance") &&
- !cmd.Flags().Changed("cron") && !cmd.Flags().Changed("no-schedule") &&
+ !cmd.Flags().Changed("cron") && !noScheduleChanged &&
!cmd.Flags().Changed("expires-at") {
return fmt.Errorf(
"at least one flag must be provided (--private, --performance, --cron, --no-schedule, or --expires-at)",
@@ -77,7 +79,6 @@
}
// Resolve the schedule, defaulting to preserving the current one.
- noSchedule, _ := cmd.Flags().GetBool("no-schedule")
switch {
case noSchedule:
// leave CronExpression nil → removes the scheduleYou can send follow-ups to the cloud agent here.
b192181 to
85e120f
Compare
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 85e120f. Configure here.
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.
85e120f to
e2d2420
Compare


Adds the
dune matviewcommand group (aliasmv) to the CLI, consuming duneapi-client-go v0.5.0: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.