Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ Manage and execute Dune queries.
| `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.
Expand Down
2 changes: 2 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
98 changes: 98 additions & 0 deletions cmd/matview/create.go
Original file line number Diff line number Diff line change
@@ -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
}
}
129 changes: 129 additions & 0 deletions cmd/matview/create_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
47 changes: 47 additions & 0 deletions cmd/matview/delete.go
Original file line number Diff line number Diff line change
@@ -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 <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
}
}
52 changes: 52 additions & 0 deletions cmd/matview/delete_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading