Skip to content

matviews: add dune matview command group#61

Merged
ivpusic merged 1 commit into
mainfrom
ivan/gro-422-matview-cli
Jun 12, 2026
Merged

matviews: add dune matview command group#61
ivpusic merged 1 commit into
mainfrom
ivan/gro-422-matview-cli

Conversation

@ivpusic

@ivpusic ivpusic commented Jun 12, 2026

Copy link
Copy Markdown
Member

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.

@ivpusic ivpusic marked this pull request as ready for review June 12, 2026 12:37

ivpusic commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@cursor

cursor Bot commented Jun 12, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
New CLI surface delegating to existing API client methods; destructive delete and credit-spending update/refresh are explicit and guarded (e.g. no-op update blocked). No auth or core CLI infrastructure changes beyond registering the command.

Overview
Adds dune matview (alias mv) so users can create, inspect, list, update, refresh, and delete materialized views from the CLI, wired to duneapi-client-go v0.5.0 (bumped from 0.4.8).

create materializes a saved query via upsert, with optional cron/expires-at, client-side result_* name checks, and text/json output pointing at dune execution results. get, list (pagination + --all), refresh, and delete use fully-qualified SQL names; update does a read-modify-write upsert so schedules and expiry are preserved unless --cron, --no-schedule, or other flags change them, and rejects no-op updates that would still re-run the query and spend credits.

The command is registered in cli/root.go, documented in README.md, and covered by mock-client tests per subcommand.

Reviewed by Cursor Bugbot for commit 85e120f. Configure here.

@ivpusic

ivpusic commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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 the

You can send follow-ups to the cloud agent here.

Comment thread cmd/matview/update.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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 lines

You can send follow-ups to the cloud agent here.

Comment thread cmd/matview/update.go
@ivpusic ivpusic force-pushed the ivan/gro-422-matview-cli branch from efe58cf to b192181 Compare June 12, 2026 12:43
@ivpusic

ivpusic commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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 schedule

You can send follow-ups to the cloud agent here.

Comment thread cmd/matview/update.go
@ivpusic ivpusic force-pushed the ivan/gro-422-matview-cli branch from b192181 to 85e120f Compare June 12, 2026 13:04
@ivpusic

ivpusic commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ 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.

Comment thread cmd/matview/create.go Outdated
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.
@ivpusic ivpusic force-pushed the ivan/gro-422-matview-cli branch from 85e120f to e2d2420 Compare June 12, 2026 13:26
@ivpusic ivpusic merged commit 122f240 into main Jun 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants