diff --git a/README.md b/README.md index 480ddb5..d3a264b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,21 @@ Manage and execute Dune queries. | `query run [--param key=value] [--performance small\|medium\|large] [--limit] [--timeout] [--no-wait]` | Execute a saved query and display results | | `query run-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 --query-id [--private] [--performance small\|medium\|large] [--cron ] [--expires-at ]` | Materialize a saved query into a table | +| `matview get ` | Get a matview's metadata, size, and refresh schedule | +| `matview list [--limit] [--offset] [--all]` | List the materialized views you own | +| `matview update [--private] [--performance] [--cron ] [--no-schedule] [--expires-at]` | Update settings or the schedule (preserves the schedule unless changed) | +| `matview refresh [--performance small\|medium\|large]` | Trigger an on-demand refresh | +| `matview delete ` | 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 index dba8280..0a35809 100644 --- a/cli/root.go +++ b/cli/root.go @@ -20,6 +20,7 @@ import ( "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 @@ func init() { 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 index 0000000..10dab84 --- /dev/null +++ b/cmd/matview/create.go @@ -0,0 +1,98 @@ +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" + + "Matviews are public by default; pass --private to restrict access (private matviews\n" + + "require a supporting plan). With --cron the matview is refreshed periodically (5-field\n" + + "cron, minimum 15-minute 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_private --query-id 12345 --private", + 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", false, "make the matview private (requires a supporting plan); defaults to public") + 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 index 0000000..5e4809f --- /dev/null +++ b/cmd/matview/create_test.go @@ -0,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.False(t, got.IsPrivate, "private should default to false (public)") + 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 index 0000000..a7ef782 --- /dev/null +++ b/cmd/matview/delete.go @@ -0,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 ", + 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 index 0000000..0a7a33c --- /dev/null +++ b/cmd/matview/delete_test.go @@ -0,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 index 0000000..4bd72e1 --- /dev/null +++ b/cmd/matview/get.go @@ -0,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 ", + 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 index 0000000..7c4ccbb --- /dev/null +++ b/cmd/matview/get_test.go @@ -0,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 index 0000000..62a2710 --- /dev/null +++ b/cmd/matview/helpers.go @@ -0,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 index 0000000..37a5c15 --- /dev/null +++ b/cmd/matview/list.go @@ -0,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 index 0000000..e544b76 --- /dev/null +++ b/cmd/matview/list_test.go @@ -0,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{ + MaterializedViews: []*models.MaterializedViewListElement{{SQLID: "dune.t.result_a", QueryID: 1}}, + NextOffset: 1, + }, nil + default: + return &models.ListMaterializedViewsResponse{ + MaterializedViews: []*models.MaterializedViewListElement{{SQLID: "dune.t.result_b", QueryID: 2}}, + NextOffset: 0, + }, nil + } + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"matview", "list", "--all"}) + require.NoError(t, root.Execute()) + + assert.Equal(t, []int{0, 1}, calls, "should page until next_offset is 0") + out := buf.String() + assert.Contains(t, out, "dune.t.result_a") + assert.Contains(t, out, "dune.t.result_b") + assert.NotContains(t, out, "More results available", "no pagination hint when --all") +} + +func TestListShowsNextOffsetHint(t *testing.T) { + mock := &mockClient{ + listFn: func(int, int) (*models.ListMaterializedViewsResponse, error) { + return &models.ListMaterializedViewsResponse{ + MaterializedViews: []*models.MaterializedViewListElement{{SQLID: "dune.t.result_a", QueryID: 1}}, + NextOffset: 100, + }, nil + }, + } + root, buf := newTestRoot(mock) + root.SetArgs([]string{"matview", "list"}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "--offset 100") +} diff --git a/cmd/matview/matview.go b/cmd/matview/matview.go new file mode 100644 index 0000000..c8b9c9e --- /dev/null +++ b/cmd/matview/matview.go @@ -0,0 +1,30 @@ +package matview + +import "github.com/spf13/cobra" + +// NewMatviewCmd returns the `matview` parent command. +func NewMatviewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "matview", + Aliases: []string{"mv"}, + Short: "Create, inspect, refresh, and delete Dune materialized views", + Long: "Manage materialized views (matviews) — query results persisted into a queryable\n" + + "table, optionally refreshed on a schedule.\n\n" + + "Subcommands:\n" + + " create - Materialize a saved query into a table (optionally scheduled)\n" + + " get - Fetch a matview's metadata, size, and refresh schedule\n" + + " list - List the materialized views you own\n" + + " update - Change a matview's settings or refresh schedule\n" + + " refresh - Trigger an on-demand refresh\n" + + " delete - Permanently delete a matview and its schedule\n\n" + + "A matview is referenced by its fully-qualified SQL name (e.g.\n" + + "dune.my_team.result_token_summary) for get, update, refresh, and delete.", + } + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newRefreshCmd()) + cmd.AddCommand(newDeleteCmd()) + return cmd +} diff --git a/cmd/matview/refresh.go b/cmd/matview/refresh.go new file mode 100644 index 0000000..55d27c6 --- /dev/null +++ b/cmd/matview/refresh.go @@ -0,0 +1,57 @@ +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 newRefreshCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "refresh ", + Short: "Trigger an on-demand refresh of a materialized view", + Long: "Re-execute a matview's source query and update its table with fresh results.\n" + + "Refreshing consumes credits based on the compute used. You must own the matview.\n\n" + + "The name must be fully qualified, e.g. dune.my_team.result_token_summary.\n\n" + + "Examples:\n" + + " dune matview refresh dune.my_team.result_token_summary\n" + + " dune matview refresh dune.my_team.result_token_summary --performance large", + Args: cobra.ExactArgs(1), + RunE: runRefresh, + } + + cmd.Flags().String("performance", "", "execution tier: \"small\", \"medium\", or \"large\" (default: account default)") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runRefresh(cmd *cobra.Command, args []string) error { + performance, err := parsePerformance(cmd) + if err != nil { + return err + } + + client := cmdutil.ClientFromCmd(cmd) + + resp, err := client.RefreshMaterializedView(args[0], models.RefreshMaterializedViewRequest{ + Performance: performance, + }) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + return output.PrintJSON(w, resp) + default: + fmt.Fprintf(w, "Refreshing materialized view %s\n", resp.SQLID) + fmt.Fprintf(w, "Execution ID: %s\n", resp.ExecutionID) + fmt.Fprintf(w, "\nCheck results with:\n dune execution results %s\n", resp.ExecutionID) + return nil + } +} diff --git a/cmd/matview/refresh_test.go b/cmd/matview/refresh_test.go new file mode 100644 index 0000000..19fe4be --- /dev/null +++ b/cmd/matview/refresh_test.go @@ -0,0 +1,73 @@ +package matview_test + +import ( + "errors" + "testing" + + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRefreshSuccess(t *testing.T) { + var gotName string + var gotReq models.RefreshMaterializedViewRequest + mock := &mockClient{ + refreshFn: func( + name string, req models.RefreshMaterializedViewRequest, + ) (*models.RefreshMaterializedViewResponse, error) { + gotName, gotReq = name, req + return &models.RefreshMaterializedViewResponse{ + SQLID: "dune.my_team.result_token_summary", + ExecutionID: "01HZ999", + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"matview", "refresh", "dune.my_team.result_token_summary", "--performance", "large"}) + require.NoError(t, root.Execute()) + + assert.Equal(t, "dune.my_team.result_token_summary", gotName) + assert.Equal(t, "large", gotReq.Performance) + out := buf.String() + assert.Contains(t, out, "Refreshing materialized view dune.my_team.result_token_summary") + assert.Contains(t, out, "Execution ID: 01HZ999") +} + +func TestRefreshDefaultPerformance(t *testing.T) { + var gotReq models.RefreshMaterializedViewRequest + mock := &mockClient{ + refreshFn: func( + _ string, req models.RefreshMaterializedViewRequest, + ) (*models.RefreshMaterializedViewResponse, error) { + gotReq = req + return &models.RefreshMaterializedViewResponse{SQLID: "dune.t.result_x", ExecutionID: "e1"}, nil + }, + } + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "refresh", "dune.t.result_x"}) + require.NoError(t, root.Execute()) + assert.Empty(t, gotReq.Performance, "no --performance sends empty (server default)") +} + +func TestRefreshInvalidPerformance(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"matview", "refresh", "dune.t.result_x", "--performance", "huge"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid performance tier") +} + +func TestRefreshAPIError(t *testing.T) { + mock := &mockClient{ + refreshFn: func(string, models.RefreshMaterializedViewRequest) (*models.RefreshMaterializedViewResponse, error) { + return nil, errors.New("api: not enough credits") + }, + } + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "refresh", "dune.t.result_x"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not enough credits") +} diff --git a/cmd/matview/testutil_test.go b/cmd/matview/testutil_test.go new file mode 100644 index 0000000..9db97cc --- /dev/null +++ b/cmd/matview/testutil_test.go @@ -0,0 +1,66 @@ +package matview_test + +import ( + "bytes" + "context" + + "github.com/duneanalytics/cli/cmd/matview" + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/duneapi-client-go/dune" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +// mockClient embeds the interface so unimplemented methods panic. +type mockClient struct { + dune.DuneClient + upsertFn func(models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) + getFn func(string) (*models.GetMaterializedViewResponse, error) + listFn func(int, int) (*models.ListMaterializedViewsResponse, error) + refreshFn func(string, models.RefreshMaterializedViewRequest) (*models.RefreshMaterializedViewResponse, error) + deleteFn func(string) (*models.DeleteMaterializedViewResponse, error) +} + +func (m *mockClient) UpsertMaterializedView( + req models.UpsertMaterializedViewRequest, +) (*models.UpsertMaterializedViewResponse, error) { + return m.upsertFn(req) +} + +func (m *mockClient) GetMaterializedView(name string) (*models.GetMaterializedViewResponse, error) { + return m.getFn(name) +} + +func (m *mockClient) ListMaterializedViews(limit, offset int) (*models.ListMaterializedViewsResponse, error) { + return m.listFn(limit, offset) +} + +func (m *mockClient) RefreshMaterializedView( + name string, + req models.RefreshMaterializedViewRequest, +) (*models.RefreshMaterializedViewResponse, error) { + return m.refreshFn(name, req) +} + +func (m *mockClient) DeleteMaterializedView(name string) (*models.DeleteMaterializedViewResponse, error) { + return m.deleteFn(name) +} + +// newTestRoot builds a root → matview command tree with the mock injected. +func newTestRoot(mock dune.DuneClient) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{ + Use: "dune", + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + cmdutil.SetClient(cmd, mock) + }, + } + root.SetContext(context.Background()) + root.AddCommand(matview.NewMatviewCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} + +func strptr(s string) *string { return &s } diff --git a/cmd/matview/update.go b/cmd/matview/update.go new file mode 100644 index 0000000..2ad8d44 --- /dev/null +++ b/cmd/matview/update.go @@ -0,0 +1,132 @@ +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 newUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a materialized view's settings or refresh schedule", + Long: "Change a matview's privacy, performance tier, or refresh schedule. Updating\n" + + "re-executes the source query. The name and source query are immutable.\n\n" + + "Settings you don't pass are preserved (read-modify-write). In particular, the\n" + + "existing refresh schedule is kept unless you change it with --cron or remove it\n" + + "with --no-schedule — so a plain `update --private=false` will not silently drop\n" + + "a schedule.\n\n" + + "The name must be fully qualified, e.g. dune.my_team.result_token_summary.\n\n" + + "Examples:\n" + + " dune matview update dune.my_team.result_x --performance large\n" + + " dune matview update dune.my_team.result_x --cron \"0 0 * * *\"\n" + + " dune matview update dune.my_team.result_x --no-schedule\n" + + " dune matview update dune.my_team.result_x --private=false", + Args: cobra.ExactArgs(1), + RunE: runUpdate, + } + + cmd.Flags().Bool("private", false, "set the matview's privacy") + cmd.Flags().String("performance", "", "execution tier: \"small\", \"medium\", or \"large\"") + cmd.Flags().String("cron", "", "set or replace the refresh schedule (5-field cron, min 15-minute interval)") + cmd.Flags().Bool("no-schedule", false, "remove the refresh schedule") + cmd.Flags().String("expires-at", "", "RFC3339 time when the refresh schedule expires") + cmd.MarkFlagsMutuallyExclusive("cron", "no-schedule") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runUpdate(cmd *cobra.Command, args []string) error { + fqName := args[0] + performance, err := parsePerformance(cmd) + if err != nil { + return err + } + + // --no-schedule is a directive to remove the schedule; only a true value is a real change + // (an explicit --no-schedule=false is the default and means nothing). + noSchedule, _ := cmd.Flags().GetBool("no-schedule") + + // 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. + if !cmd.Flags().Changed("private") && !cmd.Flags().Changed("performance") && + !cmd.Flags().Changed("cron") && !noSchedule && + !cmd.Flags().Changed("expires-at") { + 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 + // refresh schedule — are preserved. The upsert endpoint overwrites everything, and omitting + // the cron expression would silently drop an existing schedule. + current, err := client.GetMaterializedView(fqName) + if err != nil { + return err + } + + req := models.UpsertMaterializedViewRequest{ + Name: bareName(current.SQLID), + QueryID: current.QueryID, + IsPrivate: current.IsPrivate, + } + if cmd.Flags().Changed("private") { + req.IsPrivate, _ = cmd.Flags().GetBool("private") + } + + // Resolve the schedule, defaulting to preserving the current one. + switch { + case noSchedule: + // leave CronExpression nil → removes the schedule + case cmd.Flags().Changed("cron"): + cron, _ := cmd.Flags().GetString("cron") + req.CronExpression = &cron + case current.Schedule != nil: + cron := current.Schedule.CronExpression + req.CronExpression = &cron + } + + if cmd.Flags().Changed("expires-at") && req.CronExpression == nil { + return fmt.Errorf("--expires-at requires a refresh schedule (set one with --cron)") + } + + // Performance: an explicit flag wins; otherwise keep the scheduled tier if there is one. + switch { + case performance != "": + req.Performance = performance + case current.Schedule != nil: + req.Performance = current.Schedule.Performance + } + + // Expiry: an explicit flag wins; otherwise preserve the current expiry while keeping a schedule. + switch { + case req.CronExpression == nil: + // no schedule → no expiry + case cmd.Flags().Changed("expires-at"): + expiresAt, _ := cmd.Flags().GetString("expires-at") + req.ExpiresAt = &expiresAt + case current.Schedule != nil: + req.ExpiresAt = current.Schedule.ExpiresAt + } + + 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, "Updated materialized view %s\n", resp.SQLID) + fmt.Fprintf(w, "Execution ID: %s\n", resp.ExecutionID) + return nil + } +} diff --git a/cmd/matview/update_test.go b/cmd/matview/update_test.go new file mode 100644 index 0000000..22c0439 --- /dev/null +++ b/cmd/matview/update_test.go @@ -0,0 +1,149 @@ +package matview_test + +import ( + "errors" + "testing" + + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// scheduledMatview is a matview with an existing refresh schedule, used to verify read-modify-write. +func scheduledMatview() *models.GetMaterializedViewResponse { + return &models.GetMaterializedViewResponse{ + SQLID: "dune.my_team.result_token_summary", + QueryID: 12345, + IsPrivate: true, + Schedule: &models.MaterializedViewSchedule{ + CronExpression: "0 */6 * * *", + Performance: "medium", + ExpiresAt: strptr("2026-09-11T23:59:59Z"), + }, + } +} + +// Changing an unrelated setting must preserve the existing schedule. +func TestUpdatePreservesSchedule(t *testing.T) { + var got models.UpsertMaterializedViewRequest + mock := &mockClient{ + getFn: func(string) (*models.GetMaterializedViewResponse, error) { return scheduledMatview(), nil }, + upsertFn: func(req models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) { + got = req + return &models.UpsertMaterializedViewResponse{SQLID: "dune.my_team.result_token_summary", ExecutionID: "e1"}, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "update", "dune.my_team.result_token_summary", "--private=false"}) + require.NoError(t, root.Execute()) + + assert.Equal(t, "result_token_summary", got.Name, "upsert uses the bare name") + assert.Equal(t, 12345, got.QueryID) + assert.False(t, got.IsPrivate, "private override applied") + require.NotNil(t, got.CronExpression, "existing schedule must be preserved") + assert.Equal(t, "0 */6 * * *", *got.CronExpression) + assert.Equal(t, "medium", got.Performance, "scheduled tier preserved") + require.NotNil(t, got.ExpiresAt) + assert.Equal(t, "2026-09-11T23:59:59Z", *got.ExpiresAt) +} + +func TestUpdateReplacesCron(t *testing.T) { + var got models.UpsertMaterializedViewRequest + mock := &mockClient{ + getFn: func(string) (*models.GetMaterializedViewResponse, error) { return scheduledMatview(), nil }, + upsertFn: func(req models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) { + got = req + return &models.UpsertMaterializedViewResponse{SQLID: "dune.my_team.result_token_summary", ExecutionID: "e1"}, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "update", "dune.my_team.result_token_summary", "--cron", "0 0 * * *"}) + require.NoError(t, root.Execute()) + + require.NotNil(t, got.CronExpression) + assert.Equal(t, "0 0 * * *", *got.CronExpression) +} + +func TestUpdateRemovesSchedule(t *testing.T) { + var got models.UpsertMaterializedViewRequest + mock := &mockClient{ + getFn: func(string) (*models.GetMaterializedViewResponse, error) { return scheduledMatview(), nil }, + upsertFn: func(req models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) { + got = req + return &models.UpsertMaterializedViewResponse{SQLID: "dune.my_team.result_token_summary", ExecutionID: "e1"}, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "update", "dune.my_team.result_token_summary", "--no-schedule"}) + require.NoError(t, root.Execute()) + + assert.Nil(t, got.CronExpression, "schedule must be removed") + assert.Nil(t, got.ExpiresAt) +} + +func TestUpdateCronAndNoScheduleMutuallyExclusive(t *testing.T) { + mock := &mockClient{ + getFn: func(string) (*models.GetMaterializedViewResponse, error) { return scheduledMatview(), nil }, + } + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "update", "dune.t.result_x", "--cron", "0 0 * * *", "--no-schedule"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "[cron no-schedule]") +} + +// A no-flag update must error before any API call — updating re-executes the query and spends +// credits, so a no-op must not silently trigger a refresh. +func TestUpdateNoFlagsErrors(t *testing.T) { + mock := &mockClient{ + getFn: func(string) (*models.GetMaterializedViewResponse, error) { + t.Fatal("GetMaterializedView must not be called for a no-op update") + return nil, nil + }, + upsertFn: func(models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) { + t.Fatal("UpsertMaterializedView must not be called for a no-op update") + return nil, nil + }, + } + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "update", "dune.t.result_x"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one flag must be provided") +} + +// `--no-schedule=false` is the default and not a real change, so it must be rejected like a +// no-flag update (it would otherwise pass the guard and trigger a no-op re-execution). +func TestUpdateNoScheduleFalseErrors(t *testing.T) { + mock := &mockClient{ + getFn: func(string) (*models.GetMaterializedViewResponse, error) { + t.Fatal("GetMaterializedView must not be called for --no-schedule=false with no other change") + return nil, nil + }, + upsertFn: func(models.UpsertMaterializedViewRequest) (*models.UpsertMaterializedViewResponse, error) { + t.Fatal("UpsertMaterializedView must not be called for a no-op update") + return nil, nil + }, + } + root, _ := newTestRoot(mock) + root.SetArgs([]string{"matview", "update", "dune.t.result_x", "--no-schedule=false"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one flag must be provided") +} + +func TestUpdateNotFound(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", "update", "dune.t.result_missing", "--private=false"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/go.mod b/go.mod index dbef716..3146b5a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.6 require ( github.com/amplitude/analytics-go v1.3.0 github.com/charmbracelet/fang v0.4.4 - github.com/duneanalytics/duneapi-client-go v0.4.8 + github.com/duneanalytics/duneapi-client-go v0.5.0 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index ed21402..5e3d634 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/duneanalytics/duneapi-client-go v0.4.8 h1:zKdX+ib5oZ3zZsiOcwhoa48xquffLOevrIUnDgI+jYg= -github.com/duneanalytics/duneapi-client-go v0.4.8/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE= +github.com/duneanalytics/duneapi-client-go v0.5.0 h1:5oikLwdL/Pd+XWTc1jhgouOfhCn3RK53BcCnptS5nnE= +github.com/duneanalytics/duneapi-client-go v0.5.0/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=