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
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,72 @@ mb snippet archive 1
mb snippet archive 1 --json
```

## Data actions

CRUD on `/api/action` plus `execute`. A data action is a parameterized native SQL write (`INSERT`, `UPDATE`, `DELETE`) filed in a collection. Data actions are off by default: an admin must enable them on the target database first. `mb data-action create` authors data actions only — no `model_id`, no implicit data actions — and needs a server whose data actions do not require a model.

### `mb data-action list`

```sh
mb data-action list
mb data-action list --json
```

### `mb data-action get <id>`

```sh
mb data-action get 1
mb data-action get 1 --json --full
```

### `mb data-action create`

```sh
cat data-action.json | mb data-action create
mb data-action create --file data-action.json
mb data-action create --file data-action.json --skip-validate
```

| Flag | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--body <json>` | Inline JSON body. |
| `--file <path>` | Path to JSON body file. |
| `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. |

Body fields: `name` (required), `type` (required, `"query"`), `database_id` (required), `dataset_query` (required native query whose `{{tag}}` placeholders are its inputs), `parameters` (one per template tag), `collection_id` (optional; the root when omitted), `description`, `visualization_settings`.

### `mb data-action update <id>`

Patch a data action; the body carries only the fields to change (`name`, `database_id`, `dataset_query`, `parameters`, `collection_id`, `description`, `visualization_settings`, `archived`).

```sh
cat patch.json | mb data-action update 1
mb data-action update 1 --body '{"name":"Rename an order"}'
```

### `mb data-action archive <id>`

```sh
mb data-action archive 1
```

### `mb data-action delete <id>`

Delete a data action and the dashboard buttons that run it.

```sh
mb data-action delete 1 --yes
```

### `mb data-action execute <id>`

Run a data action. The body is `{"parameters": {...}}` keyed by parameter id; the result reports `rows-affected`.

```sh
mb data-action execute 1 --body '{"parameters":{"id":1,"note":"rush"}}'
mb data-action execute 1 --file values.json
```

## Segments

CRUD on `/api/segment`. A segment is a saved MBQL filter macro tied to a table — used in card filters to share a reusable predicate. Mutating endpoints require a `revision_message` for the audit log.
Expand Down
125 changes: 125 additions & 0 deletions packages/cli/skill-data/data-action/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
name: data-action
description: Author and run Metabase data actions with the `mb` CLI — saved, parameterized native SQL writes (INSERT, UPDATE, DELETE) filed in a collection and run with values. Covers enabling them on a database, the body, mapping template tags to parameters, running, and the lifecycle. Triggers — "add a data action", "let users update a row", "a button that inserts a record", "run a write query", "a form that saves to the database", "delete rows from Metabase".
allowed-tools: Read, Write, Edit, Bash, AskUserQuestion
requires: [dataActionsWithoutModel]
---

# Data actions

A **data action** is one native SQL statement that writes — `INSERT`, `UPDATE` or `DELETE` — with named inputs. It lives in a collection, and dashboards, apps and `mb data-action execute` run it with values.

Flag conventions and `./.scratch` are in `core`; the native query and its template tags follow `native-sql`. This skill covers what a data action adds.

## Check the database first

Data actions are off on every database until an admin turns on **Model actions** for it (Admin → Databases → the database). `mb` cannot change that setting.

```bash
mb db get <id> --full --json
```

- `features` must contain `"actions"` — otherwise the driver cannot run writes; pick another database.
- `settings["database-enable-actions"]` must be `true`. If it is not, ask the user to have an admin enable it. Create and execute fail with `Actions are not enabled.` until then.

## The body

```json
{
"name": "Set order note",
"type": "query",
"database_id": 1,
"collection_id": 12,
"dataset_query": {
"lib/type": "mbql/query",
"database": 1,
"stages": [
{
"lib/type": "mbql.stage/native",
"native": "UPDATE orders SET note = {{note}} WHERE id = {{order_id}}",
"template-tags": {
"order_id": {
"id": "order_id",
"name": "order_id",
"display-name": "Order ID",
"type": "number",
"required": true
},
"note": {
"id": "note",
"name": "note",
"display-name": "Note",
"type": "text",
"required": true
}
}
}
]
},
"parameters": [
{
"id": "order_id",
"slug": "order_id",
"name": "Order ID",
"type": "number/=",
"target": ["variable", ["template-tag", "order_id"]],
"required": true
},
{
"id": "note",
"slug": "note",
"name": "Note",
"type": "string/=",
"target": ["variable", ["template-tag", "note"]],
"required": true
}
]
}
```

- `type` is always `"query"`. Never set `model_id` or `type: "implicit"`; `mb data-action create` refuses both.
- `database_id` and `dataset_query.database` are the same id (`mb db list`). Omit `collection_id` for the root collection (`mb collection list`).
- One statement, no `;`. `create` and `update` validate `dataset_query` as `native-sql` does.

## Parameters: one per template tag

Each `{{name}}` needs a `template-tags` entry and a `parameters` entry, all using the same name:

| Tag `type` | Parameter `type` |
| ---------- | ---------------- |
| `number` | `number/=` |
| `text` | `string/=` |
| `date` | `date/single` |
| `boolean` | `boolean/=` |

- Parameter `id` and `slug` are the tag name; `target` is `["variable", ["template-tag", "<name>"]]`.
- Use raw variables only. A field filter (`type: "dimension"`), snippet or card reference does not work in a write — write the comparison yourself (`WHERE id = {{order_id}}`).
- Set `required: true` on every input the statement cannot run without (`WHERE` keys, `NOT NULL` columns).
- Wrap an optional input in `[[ … ]]` with `required: false`; the clause drops out when no value is given. This makes one action do partial updates: `UPDATE orders SET updated_at = now() [[, note = {{note}}]] [[, status = {{status}}]] WHERE id = {{order_id}}`.

## Run it

```bash
mb data-action execute <id> --body '{"parameters":{"order_id":1042,"note":"rush delivery"}}'
```

`parameters` is keyed by parameter `id`. The result is `{"rows-affected": <n>}`. A missing required value is refused. A run cannot be undone, so confirm with the user before running against data that matters, and test a new action on a row created for the purpose.

## Lifecycle

| Task | Command |
| ------------------------ | ---------------------------------------------------------------------------------------- |
| Find | `mb data-action list` (unarchived only), `mb search --models action <text>` |
| Inspect | `mb data-action get <id> --full --json` |
| Change SQL, inputs, name | `mb data-action update <id> --file patch.json` (changed fields only) |
| Move | `mb data-action update <id> --body '{"collection_id":<id>}'` |
| Archive / restore | `mb data-action archive <id>` / `mb data-action update <id> --body '{"archived":false}'` |
| Delete | `mb data-action delete <id> --yes` (also removes the dashboard buttons that run it) |

Archiving or deleting a collection archives or deletes its data actions. Existing model-bound or implicit data actions still list, run and archive; leave them as they are.

## Don't

- Don't create a model for a data action.
- Don't concatenate values into the SQL; every value goes through a template tag.
- Don't run a data action on production data without the user's go-ahead.
2 changes: 1 addition & 1 deletion packages/cli/skill-data/native-sql/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,6 @@ mb card query 12 --parameters '[{"type":"string/=","target":["dimension",["templ

- Don't wrap a field filter in an operator (`WHERE col = {{ff}}`) — write it bare (`WHERE {{ff}}`).
- Don't author the flat `{type:"native", …}` form — send the native stage above.
- Don't use native SQL for DDL or multiple statements — the editor is read-only, single-statement; `CREATE`/`UPDATE`/`;`-chained SQL is unsupported. To materialize a table, use a `transform`.
- Don't use native SQL for DDL or multiple statements — the editor is read-only, single-statement; `CREATE`/`UPDATE`/`;`-chained SQL is unsupported. To materialize a table, use a `transform`; to write rows (`INSERT`/`UPDATE`/`DELETE`), use a data action (`data-action`).
- Don't expect `[[ ]]` to save you from a case/type mismatch — `WHERE plan = {{p}}` returns zero rows on a case-sensitive engine if the value's case is off; that's a value problem, not syntax.
- Don't reach for native when a structured query fits — you lose the engine-independence and readability of an `mbql.stage/mbql` stage.
30 changes: 30 additions & 0 deletions packages/cli/src/commands/data-action/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { DataAction } from "@metabase/client/domain/data-action";
import { dataActionView } from "../../output/views/data-action";
import { renderSummary } from "../../output/render";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "archive", description: "Archive (soft-delete) a data action by id" },
requires: ["dataAction.archive"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
id: { type: "positional", description: "Data action id", required: true },
},
outputSchema: DataAction,
examples: ["mb data-action archive 1"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
const updated = await client.dataAction.archive(id);
renderSummary(
updated,
dataActionView,
`Archived data action ${updated.id} "${updated.name}".`,
ctx,
);
},
});
51 changes: 51 additions & 0 deletions packages/cli/src/commands/data-action/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { DataAction, DataActionCreateInput } from "@metabase/client/domain/data-action";
import { dataActionView } from "../../output/views/data-action";
import { renderSummary } from "../../output/render";
import { readBody } from "../../runtime/body";
import { bodyInputFlags } from "../body-flags";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";
import {
DATA_ACTION_DATASET_QUERY_LABELS,
preflightMbql5Query,
skipValidateFlag,
} from "../validate-query";

export default defineMetabaseCommand({
meta: { name: "create", description: "Create a data action from JSON" },
details:
'A data action is a parameterized native query that writes to a database, filed in a collection. The JSON body needs `name`, `type: "query"`, `database_id`, a native `dataset_query` whose `{{tag}}` placeholders are its inputs, and `parameters` targeting those tags; `collection_id` picks the collection (omit for the root). Data actions must be enabled on the database. An MBQL 5 `dataset_query` is checked against a bundled JSON Schema before sending; pass --skip-validate to bypass.',
skills: [
{ skill: "data-action", purpose: "author a data action and its parameters" },
{ skill: "native-sql", purpose: "the native query and its template tags" },
],
requires: ["dataAction.create"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
...bodyInputFlags,
...skipValidateFlag,
},
inputSchema: DataActionCreateInput,
outputSchema: DataAction,
examples: [
"cat data-action.json | mb data-action create",
"mb data-action create --file data-action.json",
"mb data-action create --file data-action.json --skip-validate",
],
async run({ args, ctx, getClient }) {
const body = await readBody({ flag: args.body, file: args.file }, DataActionCreateInput);
preflightMbql5Query(body.dataset_query, DATA_ACTION_DATASET_QUERY_LABELS, {
skip: args["skip-validate"] === true,
});
const client = await getClient();
const created = await client.dataAction.create(body);
renderSummary(
created,
dataActionView,
`Created data action ${created.id} "${created.name}".`,
ctx,
);
},
});
31 changes: 31 additions & 0 deletions packages/cli/src/commands/data-action/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { confirmAndDelete, DeleteResult } from "../delete-runtime";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "delete", description: "Delete a data action by id" },
requires: ["dataAction.delete"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
yes: { type: "boolean", description: "Skip confirmation", default: false },
id: { type: "positional", description: "Data action id", required: true },
},
outputSchema: DeleteResult,
examples: ["mb data-action delete 1 --yes", "mb data-action delete 1"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
await confirmAndDelete({
id,
yes: args.yes,
promptMessage: `Delete data action ${id}?`,
successMessage: `Deleted data action ${id}.`,
abortMessage: `Aborted; data action ${id} was not deleted.`,
deleteResource: () => client.dataAction.delete(id),
ctx,
});
},
});
44 changes: 44 additions & 0 deletions packages/cli/src/commands/data-action/execute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
DataActionExecuteInput,
DataActionExecuteResult,
} from "@metabase/client/domain/data-action";
import { dataActionExecuteResultView } from "../../output/views/data-action";
import { renderSummary } from "../../output/render";
import { readBody } from "../../runtime/body";
import { bodyInputFlags } from "../body-flags";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "execute", description: "Run a data action by id" },
details:
'The JSON body is `{"parameters": {...}}`, keyed by the data action\'s parameter ids. The data action writes to its database, so a run cannot be undone by the CLI.',
skills: [{ skill: "data-action", purpose: "the parameter values a data action takes" }],
requires: ["dataAction.execute"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
...bodyInputFlags,
id: { type: "positional", description: "Data action id", required: true },
},
inputSchema: DataActionExecuteInput,
outputSchema: DataActionExecuteResult,
examples: [
'mb data-action execute 1 --body \'{"parameters":{"id":1,"note":"rush"}}\'',
"mb data-action execute 1 --file values.json",
],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const body = await readBody({ flag: args.body, file: args.file }, DataActionExecuteInput);
const client = await getClient();
const result = await client.dataAction.execute(id, body);
renderSummary(
result,
dataActionExecuteResultView,
`Ran data action ${id}; ${result["rows-affected"]} rows affected.`,
ctx,
);
},
});
25 changes: 25 additions & 0 deletions packages/cli/src/commands/data-action/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { DataAction } from "@metabase/client/domain/data-action";
import { dataActionView } from "../../output/views/data-action";
import { renderItem } from "../../output/render";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "get", description: "Get a data action by id" },
requires: ["dataAction.get"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
id: { type: "positional", description: "Data action id", required: true },
},
outputSchema: DataAction,
examples: ["mb data-action get 1", "mb data-action get 1 --json --full"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
const dataAction = await client.dataAction.get(id);
renderItem(dataAction, dataActionView, ctx);
},
});
Loading
Loading