Uh oh!
There was an error while loading. Please reload this page.
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. |
ivpusic
commented
Jun 12, 2026
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.
Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
efe58cf to
b192181Compareivpusic
commented
Jun 12, 2026
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.
Uh oh!
There was an error while loading. Please reload this page.
b192181 to
85e120fCompareivpusic
commented
Jun 12, 2026
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.
Uh oh!
There was an error while loading. Please reload this page.
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
e2d2420CompareUh oh!
There was an error while loading. Please reload this page.


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.