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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Add `card params` and `dashboard params list` commands to discover the parameters a saved question or dashboard accepts before running it; failed parameterized runs now point to the exact discovery command (#14)
- Add `collection` commands (`list`, `get`, `items`) to browse collections and discover the cards, dashboards, and nested collections inside them, with `--models` filtering and `root` collection support (#13)
- Classify `--error-format json` errors from typed errors instead of message substrings, making structured error output reliable when wording changes (#12)
- Add `--timeout` flag and propagate request contexts so commands abort cleanly on Ctrl+C, with clear timeout and cancellation errors (#11)
Expand Down
73 changes: 64 additions & 9 deletions internal/cli/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ var cardRunCmd = &cobra.Command{
RunE: runCardRun,
}

var cardParamsCmd = &cobra.Command{
Use: "params <id>",
Short: "List the parameters a saved question accepts",
Long: "Lists the parameters a saved question accepts so they can be supplied to 'card run --param key=value'.",
Args: cobra.ExactArgs(1),
RunE: runCardParams,
}

type cardParamsResult struct {
CardID int `json:"card_id"`
CardName string `json:"card_name"`
QueryType string `json:"query_type,omitempty"`
Parameters []client.CardParameter `json:"parameters"`
}

type cardSummary struct {
ID int `json:"id"`
Name string `json:"name"`
Expand All @@ -57,6 +72,7 @@ func init() {
cardCmd.AddCommand(cardListCmd)
cardCmd.AddCommand(cardGetCmd)
cardCmd.AddCommand(cardRunCmd)
cardCmd.AddCommand(cardParamsCmd)

cardGetCmd.Flags().Bool("full", false, "Include the full query definition and card metadata")
cardRunCmd.Flags().String("fields", "", "Comma-separated list of columns to include in output")
Expand Down Expand Up @@ -128,12 +144,44 @@ func runCardRun(cmd *cobra.Command, args []string) error {

result, err := c.RunCardWithParams(ctx, id, params)
if err != nil {
return wrapParameterizedRunError(err)
return wrapParameterizedRunError(err, len(params) > 0, fmt.Sprintf("mb-cli card params %d", id))
}

return formatQueryResultOutput(cmd, result)
}

func runCardParams(cmd *cobra.Command, args []string) error {
id, err := strconv.Atoi(args[0])
if err != nil {
return err
}

c, err := newClient(cmd)
if err != nil {
return err
}

ctx, cancel := requestContext(cmd)
defer cancel()

card, err := c.GetCard(ctx, id)
if err != nil {
return err
}

format, _ := cmd.Flags().GetString("format")
if format == "json" {
return formatter.Output(cmd, cardParamsResult{
CardID: card.ID,
CardName: card.Name,
QueryType: card.QueryType,
Parameters: card.CardParameters(),
})
}

return formatter.FormatCardParametersTable(card, os.Stdout)
}

func summarizeCard(card *client.Card) cardSummary {
return cardSummary{
ID: card.ID,
Expand Down Expand Up @@ -181,15 +229,22 @@ func formatQueryResultOutput(cmd *cobra.Command, result *client.QueryResult) err
return formatter.FormatQueryResults(format, columns, rows, os.Stdout)
}

func wrapParameterizedRunError(err error) error {
// wrapParameterizedRunError classifies a failed card or dashboard run. When the
// caller supplied parameters, an API rejection is surfaced as a
// ParameterizedQueryError carrying inspect, the exact command that lists the
// valid parameters for the query. Metabase rejects invalid parameters with a
// 500, so the supplied-parameters context, not the status code, drives this.
func wrapParameterizedRunError(err error, hadParams bool, inspect string) error {
var apiErr *mberr.APIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusBadRequest:
return &mberr.ParameterizedQueryError{Err: err}
case http.StatusNotFound:
return fmt.Errorf("query target was not found (%w)", err)
}
if !errors.As(err, &apiErr) {
return err
}

if apiErr.StatusCode == http.StatusNotFound {
return fmt.Errorf("query target was not found (%w)", err)
}
if hadParams {
return &mberr.ParameterizedQueryError{Err: err, Inspect: inspect}
}
return err
}
28 changes: 27 additions & 1 deletion internal/cli/context_embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ Set both environment variables (required):
| `query filter` | Run a structured query with field filters | none | `--db`, `--table`, `--where` |
| `card list` | List saved questions | none | none |
| `card get <id>` | Get card details | id (positional) | none |
| `card params <id>` | List the parameters a saved question accepts | id (positional) | none |
| `card run <id>` | Execute a saved question | id (positional) | none |
| `dashboard list` | List dashboards | none | none |
| `dashboard get <id>` | Get dashboard details | id (positional) | none |
| `dashboard cards <id>` | List cards used by a dashboard | id (positional) | none |
| `dashboard analyze <id>` | Summarize dashboard dependencies | id (positional) | none |
| `dashboard run-card <dashboard-id> <dashcard-id> <card-id>` | Execute a dashboard card | dashboard-id, dashcard-id, card-id (positional) | none |
| `dashboard params list <dashboard-id>` | List a dashboard's parameters and the cards they filter | dashboard-id (positional) | none |
| `dashboard params values <dashboard-id> <param-key>` | List valid dashboard parameter values | dashboard-id, param-key (positional) | none |
| `dashboard params search <dashboard-id> <param-key> <query>` | Search dashboard parameter values | dashboard-id, param-key, query (positional) | none |
| `collection list` | List collections | none | none |
Expand Down Expand Up @@ -148,6 +150,22 @@ Query result commands (`query sql`, `query filter`, `card run`, `table data`) fo

Dashboard inspection commands default to concise summaries in table mode. Use `--format json` for full raw dashboard or analysis payloads.

## Parameterized Cards and Dashboards

Some saved questions and dashboard cards take parameters. Discover them before
running, instead of guessing parameter names or values:

- `card params <id>` lists the parameters a saved question accepts. Pass each
listed `name` to `card run` as `--param <name>=<value>`.
- `dashboard params list <dashboard-id>` lists a dashboard's parameters and how
many cards each one filters.
- `dashboard params values <dashboard-id> <param-key>` lists the valid values
for a parameter; `dashboard params search` filters those values by a query.

When a parameterized run fails because a parameter key or value is invalid, the
error's suggestion names the exact discovery command to run next (for example,
`mb-cli card params 5` or `mb-cli dashboard params list 298`).

## Structured Error Output

Use `--error-format json` to get machine-readable errors on stderr:
Expand Down Expand Up @@ -213,12 +231,20 @@ mb-cli query filter --db 1 --table products --where "id=prod_1234" --export csv
mb-cli card list
mb-cli card run 5

# Discover a card's parameters before running it, then pass them
mb-cli card params 5
mb-cli card run 5 --param timeframe_days=14

# Inspect dashboard structure and dependencies
mb-cli dashboard get 298
mb-cli dashboard cards 298
mb-cli dashboard params values 298 merchant_name
mb-cli dashboard analyze 298

# Discover dashboard parameters, inspect their values, then run a card
mb-cli dashboard params list 298
mb-cli dashboard params values 298 merchant_name
mb-cli dashboard run-card 298 1234 50 --param merchant_name=acme

# Browse collections and discover their contents
mb-cli collection list
mb-cli collection get root
Expand Down
66 changes: 65 additions & 1 deletion internal/cli/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ var dashboardParamsCmd = &cobra.Command{
Short: "Dashboard parameter commands",
}

var dashboardParamsListCmd = &cobra.Command{
Use: "list <dashboard-id>",
Short: "List a dashboard's parameters and the cards they filter",
Args: cobra.ExactArgs(1),
RunE: runDashboardParamsList,
}

var dashboardParamsValuesCmd = &cobra.Command{
Use: "values <dashboard-id> <param-key>",
Short: "List valid values for a dashboard parameter",
Expand Down Expand Up @@ -98,6 +105,20 @@ type dashboardParamMappingRow struct {
Target string `json:"target,omitempty"`
}

type dashboardParamsListResult struct {
DashboardID int `json:"dashboard_id"`
DashboardName string `json:"dashboard_name"`
Parameters []dashboardParamListRow `json:"parameters"`
}

type dashboardParamListRow struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Type string `json:"type"`
MappedCards []dashboardParamMappingRow `json:"mapped_cards"`
}

func init() {
rootCmd.AddCommand(dashboardCmd)

Expand All @@ -107,6 +128,7 @@ func init() {
dashboardCmd.AddCommand(dashboardRunCardCmd)
dashboardCmd.AddCommand(dashboardParamsCmd)

dashboardParamsCmd.AddCommand(dashboardParamsListCmd)
dashboardParamsCmd.AddCommand(dashboardParamsValuesCmd)
dashboardParamsCmd.AddCommand(dashboardParamsSearchCmd)

Expand Down Expand Up @@ -225,6 +247,48 @@ func buildDashboardCardRows(dashboard *client.Dashboard) []dashboardCardRow {
return rows
}

func runDashboardParamsList(cmd *cobra.Command, args []string) error {
dashboardID, err := strconv.Atoi(args[0])
if err != nil {
return err
}

c, err := newClient(cmd)
if err != nil {
return err
}

ctx, cancel := requestContext(cmd)
defer cancel()

dashboard, err := c.GetDashboard(ctx, dashboardID)
if err != nil {
return err
}

format, _ := cmd.Flags().GetString("format")
if format == "json" {
rows := make([]dashboardParamListRow, 0, len(dashboard.Parameters))
for i := range dashboard.Parameters {
parameter := &dashboard.Parameters[i]
rows = append(rows, dashboardParamListRow{
ID: parameter.ID,
Name: parameter.Name,
Slug: parameter.Slug,
Type: parameter.Type,
MappedCards: buildDashboardParamMappingRows(dashboard, parameter),
})
}
return formatter.Output(cmd, dashboardParamsListResult{
DashboardID: dashboard.ID,
DashboardName: dashboard.Name,
Parameters: rows,
})
}

return formatter.FormatDashboardParametersListTable(dashboard, os.Stdout)
}

func runDashboardParamValues(cmd *cobra.Command, args []string) error {
return runDashboardParamLookup(cmd, args[0], args[1], "", false)
}
Expand Down Expand Up @@ -262,7 +326,7 @@ func runDashboardRunCard(cmd *cobra.Command, args []string) error {

result, err := c.RunDashboardCard(ctx, dashboardID, dashcardID, cardID, params)
if err != nil {
return wrapParameterizedRunError(err)
return wrapParameterizedRunError(err, len(params) > 0, fmt.Sprintf("mb-cli dashboard params list %d", dashboardID))
}

return formatQueryResultOutput(cmd, result)
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ func classifyError(err error) (errorType, suggestion string) {

var paramErr *mberr.ParameterizedQueryError
if errors.As(err, &paramErr) {
return "API_ERROR", "Check parameter IDs with 'mb-cli dashboard get <id>' or 'mb-cli card get <id> --full'"
if paramErr.Inspect != "" {
return "API_ERROR", fmt.Sprintf("Run '%s' to list valid parameter names, types, and default values", paramErr.Inspect)
}
return "API_ERROR", "List valid parameters with 'mb-cli card params <id>' or 'mb-cli dashboard params list <id>'"
}

var resolutionErr *mberr.ResolutionError
Expand Down
30 changes: 30 additions & 0 deletions internal/client/parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,40 @@ package client

import (
"encoding/json"
"sort"
"strconv"
"strings"
)

// CardParameters returns the parameters a saved question accepts, derived from
// its native query template tags and sorted by name for stable output. Card
// and snippet template tags are excluded because they reference other queries
// rather than accepting a user-supplied value.
func (c *Card) CardParameters() []CardParameter {
if c == nil || c.DatasetQuery == nil || c.DatasetQuery.Native == nil {
return []CardParameter{}
}

tags := c.DatasetQuery.Native.TemplateTags
params := make([]CardParameter, 0, len(tags))
for key, tag := range tags {
if tag.Type == "card" || tag.Type == "snippet" {
continue
}
params = append(params, CardParameter{
Name: key,
DisplayName: tag.DisplayName,
Type: tag.Type,
WidgetType: tag.WidgetType,
Required: tag.Required,
Default: tag.Default,
})
}

sort.Slice(params, func(i, j int) bool { return params[i].Name < params[j].Name })
return params
}

func buildCardQueryParameters(card *Card, params map[string]string) []QueryParameter {
if len(params) == 0 {
return nil
Expand Down
13 changes: 13 additions & 0 deletions internal/client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ type TemplateTag struct {
Type string `json:"type,omitempty"`
WidgetType string `json:"widget-type,omitempty"`
Required bool `json:"required,omitempty"`
Default any `json:"default,omitempty"`
}

// StructuredQuery represents an MBQL structured query.
Expand All @@ -140,6 +141,18 @@ type Card struct {
VisualizationSettings map[string]any `json:"visualization_settings,omitempty"`
}

// CardParameter describes a parameter a saved question accepts. It is derived
// from a native query template tag and names the value passed to
// `card run --param <name>=<value>`.
type CardParameter struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Type string `json:"type,omitempty"`
WidgetType string `json:"widget_type,omitempty"`
Required bool `json:"required"`
Default any `json:"default,omitempty"`
}

// Dashboard represents a Metabase dashboard.
type Dashboard struct {
ID int `json:"id"`
Expand Down
Loading
Loading