diff --git a/README.md b/README.md index b51ccf4d..30bd0654 100644 --- a/README.md +++ b/README.md @@ -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 ` + +```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 ` | Inline JSON body. | +| `--file ` | 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 ` + +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 ` + +```sh +mb data-action archive 1 +``` + +### `mb data-action delete ` + +Delete a data action and the dashboard buttons that run it. + +```sh +mb data-action delete 1 --yes +``` + +### `mb data-action execute ` + +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. diff --git a/packages/cli/skill-data/data-action/SKILL.md b/packages/cli/skill-data/data-action/SKILL.md new file mode 100644 index 00000000..54f18fb2 --- /dev/null +++ b/packages/cli/skill-data/data-action/SKILL.md @@ -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 --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", ""]]`. +- 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 --body '{"parameters":{"order_id":1042,"note":"rush delivery"}}' +``` + +`parameters` is keyed by parameter `id`. The result is `{"rows-affected": }`. 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 ` | +| Inspect | `mb data-action get --full --json` | +| Change SQL, inputs, name | `mb data-action update --file patch.json` (changed fields only) | +| Move | `mb data-action update --body '{"collection_id":}'` | +| Archive / restore | `mb data-action archive ` / `mb data-action update --body '{"archived":false}'` | +| Delete | `mb data-action delete --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. diff --git a/packages/cli/skill-data/native-sql/SKILL.md b/packages/cli/skill-data/native-sql/SKILL.md index fd9735a9..b2179b51 100644 --- a/packages/cli/skill-data/native-sql/SKILL.md +++ b/packages/cli/skill-data/native-sql/SKILL.md @@ -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. diff --git a/packages/cli/src/commands/data-action/archive.ts b/packages/cli/src/commands/data-action/archive.ts new file mode 100644 index 00000000..867c8402 --- /dev/null +++ b/packages/cli/src/commands/data-action/archive.ts @@ -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, + ); + }, +}); diff --git a/packages/cli/src/commands/data-action/create.ts b/packages/cli/src/commands/data-action/create.ts new file mode 100644 index 00000000..f4993266 --- /dev/null +++ b/packages/cli/src/commands/data-action/create.ts @@ -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, + ); + }, +}); diff --git a/packages/cli/src/commands/data-action/delete.ts b/packages/cli/src/commands/data-action/delete.ts new file mode 100644 index 00000000..dcb7f37a --- /dev/null +++ b/packages/cli/src/commands/data-action/delete.ts @@ -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, + }); + }, +}); diff --git a/packages/cli/src/commands/data-action/execute.ts b/packages/cli/src/commands/data-action/execute.ts new file mode 100644 index 00000000..f88e50b1 --- /dev/null +++ b/packages/cli/src/commands/data-action/execute.ts @@ -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, + ); + }, +}); diff --git a/packages/cli/src/commands/data-action/get.ts b/packages/cli/src/commands/data-action/get.ts new file mode 100644 index 00000000..d7a640b7 --- /dev/null +++ b/packages/cli/src/commands/data-action/get.ts @@ -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); + }, +}); diff --git a/packages/cli/src/commands/data-action/index.ts b/packages/cli/src/commands/data-action/index.ts new file mode 100644 index 00000000..10f7402e --- /dev/null +++ b/packages/cli/src/commands/data-action/index.ts @@ -0,0 +1,19 @@ +import { defineCommandGroup } from "../group"; + +export default defineCommandGroup({ + name: "data-action", + description: "Manage Metabase data actions", + skills: [ + { skill: "data-action", purpose: "author and run data actions" }, + { skill: "native-sql", purpose: "the data action's native query and its template tags" }, + ], + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + archive: () => import("./archive").then((mod) => mod.default), + delete: () => import("./delete").then((mod) => mod.default), + execute: () => import("./execute").then((mod) => mod.default), + }, +}); diff --git a/packages/cli/src/commands/data-action/list.ts b/packages/cli/src/commands/data-action/list.ts new file mode 100644 index 00000000..cde994f8 --- /dev/null +++ b/packages/cli/src/commands/data-action/list.ts @@ -0,0 +1,22 @@ +import { DataActionCompact } from "@metabase/client/domain/data-action"; +import { dataActionView } from "../../output/views/data-action"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const DataActionListEnvelope = listEnvelopeSchema(DataActionCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List data actions" }, + requires: ["dataAction.list"], + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: DataActionListEnvelope, + examples: ["mb data-action list", "mb data-action list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data, total } = await client.dataAction.list(); + renderList(windowList(data, ctx.range, total), dataActionView, ctx); + }, +}); diff --git a/packages/cli/src/commands/data-action/update.ts b/packages/cli/src/commands/data-action/update.ts new file mode 100644 index 00000000..bdb30233 --- /dev/null +++ b/packages/cli/src/commands/data-action/update.ts @@ -0,0 +1,53 @@ +import { DataAction, DataActionUpdateInput } 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 { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; +import { + DATA_ACTION_DATASET_QUERY_LABELS, + preflightMbql5Query, + skipValidateFlag, +} from "../validate-query"; + +export default defineMetabaseCommand({ + meta: { name: "update", description: "Update a data action by id" }, + details: + "The JSON body carries only the fields to change. A new MBQL 5 `dataset_query` is checked against a bundled JSON Schema before sending; pass --skip-validate to bypass.", + skills: [{ skill: "data-action", purpose: "the fields of a data action" }], + requires: ["dataAction.update"], + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + ...skipValidateFlag, + id: { type: "positional", description: "Data action id", required: true }, + }, + inputSchema: DataActionUpdateInput, + outputSchema: DataAction, + examples: [ + "cat patch.json | mb data-action update 1", + "mb data-action update 1 --file patch.json", + 'mb data-action update 1 --body \'{"name":"Rename an order"}\'', + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, DataActionUpdateInput); + if (body.dataset_query !== undefined) { + preflightMbql5Query(body.dataset_query, DATA_ACTION_DATASET_QUERY_LABELS, { + skip: args["skip-validate"] === true, + }); + } + const client = await getClient(); + const updated = await client.dataAction.update(id, body); + renderSummary( + updated, + dataActionView, + `Updated data action ${updated.id} "${updated.name}".`, + ctx, + ); + }, +}); diff --git a/packages/cli/src/commands/validate-query.ts b/packages/cli/src/commands/validate-query.ts index 6168d615..d5a1166f 100644 --- a/packages/cli/src/commands/validate-query.ts +++ b/packages/cli/src/commands/validate-query.ts @@ -24,6 +24,11 @@ export const CARD_DATASET_QUERY_LABELS: PreflightLabels = { bodyNoun: "dataset_query", }; +export const DATA_ACTION_DATASET_QUERY_LABELS: PreflightLabels = { + contextLabel: "dataAction.dataset_query validation failed", + bodyNoun: "dataset_query", +}; + export const TRANSFORM_SOURCE_QUERY_LABELS: PreflightLabels = { contextLabel: "transform.source.query validation failed", bodyNoun: "source.query", diff --git a/packages/cli/src/core/skills.test.ts b/packages/cli/src/core/skills.test.ts index 83ccde55..9911b161 100644 --- a/packages/cli/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -678,7 +678,11 @@ describe("the shipped skills", () => { all.filter((skill) => skill.requires.length > 0).map((skill) => [skill.name, skill.requires]), ); - expect(bound).toEqual({ "git-sync": ["remoteSync"], transform: ["transforms"] }); + expect(bound).toEqual({ + "data-action": ["dataActionsWithoutModel"], + "git-sync": ["remoteSync"], + transform: ["transforms"], + }); const withMarkersLeft = all .filter((skill) => { const content = readSkillContent(skill, { includeExtras: true, profile: profileAt(58) }); @@ -688,6 +692,7 @@ describe("the shipped skills", () => { expect(all.map((skill) => skill.name)).toEqual([ "core", "dashboard", + "data-action", "data-workflow", "document", "git-sync", diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index b58d51c0..2340c5e3 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -22,6 +22,7 @@ const main: CommandDef = defineCommand({ card: () => import("./commands/card").then((mod) => mod.default), dashboard: () => import("./commands/dashboard").then((mod) => mod.default), subscription: () => import("./commands/subscription").then((mod) => mod.default), + "data-action": () => import("./commands/data-action").then((mod) => mod.default), alert: () => import("./commands/alert").then((mod) => mod.default), collection: () => import("./commands/collection").then((mod) => mod.default), library: () => import("./commands/library").then((mod) => mod.default), diff --git a/packages/cli/src/output/views/data-action.ts b/packages/cli/src/output/views/data-action.ts new file mode 100644 index 00000000..633375e1 --- /dev/null +++ b/packages/cli/src/output/views/data-action.ts @@ -0,0 +1,24 @@ +import { + type DataAction, + DataActionCompact, + DataActionExecuteResult, +} from "@metabase/client/domain/data-action"; + +import type { ResourceView } from "../view"; + +export const dataActionView: ResourceView = { + compactPick: DataActionCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "type", label: "Type" }, + { key: "collection_id", label: "Collection" }, + { key: "database_id", label: "Database" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const dataActionExecuteResultView: ResourceView = { + compactPick: DataActionExecuteResult, + tableColumns: [{ key: "rows-affected", label: "Rows affected" }], +}; diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index bcbbdb96..7319e381 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -360,6 +360,13 @@ const ALL_COMMANDS = [ "subscription create", "subscription update", "subscription archive", + "data-action list", + "data-action get", + "data-action create", + "data-action update", + "data-action archive", + "data-action delete", + "data-action execute", "alert list", "alert get", "alert create", @@ -482,6 +489,9 @@ const BODY_COMMANDS = [ "dashboard update-dashcard", "subscription create", "subscription update", + "data-action create", + "data-action update", + "data-action execute", "alert create", "alert update", "collection create", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 2b01acb3..ded24947 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -3,6 +3,7 @@ import { cardResource } from "./resources/card"; import { collectionResource } from "./resources/collection"; import { contentTranslationResource } from "./resources/content-translation"; import { dashboardResource } from "./resources/dashboard"; +import { dataActionResource } from "./resources/data-action"; import { databaseResource } from "./resources/database"; import { datasetResource } from "./resources/dataset"; import { dependencyResource } from "./resources/dependency"; @@ -49,6 +50,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions) collection: collectionResource(transport), contentTranslation: contentTranslationResource(transport), dashboard: dashboardResource(transport), + dataAction: dataActionResource(transport), database: databaseResource(transport), dataset: datasetResource(transport), dependency: dependencyResource(transport), diff --git a/packages/client/src/domain/data-action.ts b/packages/client/src/domain/data-action.ts new file mode 100644 index 00000000..933af00a --- /dev/null +++ b/packages/client/src/domain/data-action.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +import type { Features } from "../version/features"; + +import { DatasetQuery } from "./query"; + +export const DataActionParameter = z + .object({ + id: z.string(), + type: z.string(), + name: z.string().optional(), + slug: z.string().optional(), + target: z.unknown().optional(), + required: z.boolean().optional(), + }) + .loose(); +export type DataActionParameter = z.infer; + +const DataActionBase = z + .object({ + id: z.number().int(), + name: z.string(), + description: z.string().nullable(), + type: z.string(), + model_id: z.number().int().nullable(), + // A query-type data action carries its query and database; an implicit one has neither key. + database_id: z.number().int().nullable().optional(), + dataset_query: z.unknown().optional(), + parameters: z.array(DataActionParameter).nullable(), + visualization_settings: z.unknown(), + archived: z.boolean(), + public_uuid: z.string().nullable(), + entity_id: z.string().nullable(), + creator_id: z.number().int(), + created_at: z.string(), + updated_at: z.string(), + }) + .loose(); + +export const DataAction = DataActionBase.extend({ + collection_id: z.number().int().nullable(), +}); +export type DataAction = z.infer; + +function withoutCollection(wire: z.infer): DataAction { + return { ...wire, collection_id: null }; +} + +/** The shape every data action endpoint answers on a server with `features`, read as `DataAction`. */ +export function dataActionSchema(features: Features): z.ZodType { + return features.dataActionCollections ? DataAction : DataActionBase.transform(withoutCollection); +} + +export const DataActionCompact = DataAction.pick({ + id: true, + name: true, + type: true, + collection_id: true, + database_id: true, + archived: true, +}).strip(); +export type DataActionCompact = z.infer; + +const DataActionParameterInput = z + .object({ + id: z.string().min(1), + type: z.string().min(1), + name: z.string().optional(), + slug: z.string().optional(), + target: z.unknown(), + required: z.boolean().optional(), + }) + .loose(); + +export const DataActionCreateInput = z + .object({ + name: z.string().min(1), + type: z.literal("query"), + database_id: z.number().int().positive(), + dataset_query: DatasetQuery, + parameters: z.array(DataActionParameterInput).optional(), + collection_id: z.number().int().positive().nullable().optional(), + description: z.string().nullable().optional(), + visualization_settings: z.unknown().optional(), + }) + .strict(); +export type DataActionCreateInput = z.infer; + +export const DataActionUpdateInput = z + .object({ + name: z.string().min(1).optional(), + database_id: z.number().int().positive().optional(), + dataset_query: DatasetQuery.optional(), + parameters: z.array(DataActionParameterInput).optional(), + collection_id: z.number().int().positive().nullable().optional(), + description: z.string().nullable().optional(), + visualization_settings: z.unknown().optional(), + archived: z.boolean().optional(), + }) + .strict(); +export type DataActionUpdateInput = z.infer; + +const DataActionParameterValue = z.union([z.string(), z.number(), z.boolean(), z.null()]); + +export const DataActionExecuteInput = z + .object({ parameters: z.record(z.string(), DataActionParameterValue) }) + .strict(); +export type DataActionExecuteInput = z.infer; + +export const DataActionExecuteResult = z.object({ "rows-affected": z.number().int() }).loose(); +export type DataActionExecuteResult = z.infer; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 608146fe..a92e36bf 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -14,6 +14,9 @@ import { DashboardCreateInput, DashboardUpdateInput, DashcardPatchInput, + DataActionCreateInput, + DataActionExecuteInput, + DataActionUpdateInput, DocumentCopyInput, DocumentCreateInput, DocumentUpdateInput, @@ -96,6 +99,9 @@ const writePath = { DashboardCreateInput: pin(DashboardCreateInput), DashboardUpdateInput: pin(DashboardUpdateInput), DashcardPatchInput: pin(DashcardPatchInput), + DataActionCreateInput: pin(DataActionCreateInput), + DataActionExecuteInput: pin(DataActionExecuteInput), + DataActionUpdateInput: pin(DataActionUpdateInput), DocumentCopyInput: pin(DocumentCopyInput), DocumentCreateInput: pin(DocumentCreateInput), DocumentUpdateInput: pin(DocumentUpdateInput), diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 36be6859..5019fc8f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -149,6 +149,16 @@ export { DashcardCompact, DashcardPatchInput, } from "./domain/dashboard"; +export { + DataAction, + DataActionCompact, + DataActionCreateInput, + DataActionExecuteInput, + DataActionExecuteResult, + DataActionParameter, + dataActionSchema, + DataActionUpdateInput, +} from "./domain/data-action"; export { Database, DatabaseCompact, diff --git a/packages/client/src/resources/data-action.test.ts b/packages/client/src/resources/data-action.test.ts new file mode 100644 index 00000000..e4492f83 --- /dev/null +++ b/packages/client/src/resources/data-action.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { DataActionCreateInput } from "../domain/data-action"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { CapabilityError } from "../version/preflight-error"; +import { createServerProfile, type ServerProfile } from "../version/profile"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const ACTION_V64 = { + id: 7, + name: "Rename order", + description: null, + type: "query", + model_id: null, + database_id: 2, + dataset_query: { + database: 2, + type: "native", + native: { query: "UPDATE orders SET note = {{note}} WHERE id = {{id}}" }, + }, + parameters: [{ id: "id", type: "number/=" }], + visualization_settings: null, + archived: false, + public_uuid: null, + entity_id: "ccccccccccccccccccccc", + creator_id: 1, + created_at: "2026-01-02T03:04:05.678Z", + updated_at: "2026-01-02T03:04:05.678Z", +}; + +const ACTION = { ...ACTION_V64, collection_id: 3 }; + +const CREATE_BODY: DataActionCreateInput = { + name: "Rename order", + type: "query", + database_id: 2, + collection_id: 3, + dataset_query: { + database: 2, + type: "native", + native: { query: "UPDATE orders SET note = {{note}} WHERE id = {{id}}" }, + }, + parameters: [{ id: "id", type: "number/=", target: ["variable", ["template-tag", "id"]] }], +}; + +const HEAD_SERVER = createServerProfile({ + edition: "oss", + version: { kind: "release", tag: "v0.65.0", major: 65, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +const V64_SERVER = createServerProfile({ + edition: "oss", + version: { kind: "release", tag: "v0.64.0", major: 64, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array, server: ServerProfile = HEAD_SERVER) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + server, + }); + return { mb, capture }; +} + +describe("data action resource wire requests", () => { + it("sends the list request without a query string", async () => { + const { mb, capture } = clientOver([jsonResponse([ACTION])]); + + const result = await mb.dataAction.list(); + + expect(result).toEqual({ data: [ACTION], total: null }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/action", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reads a data action without a collection on a server whose data actions have none", async () => { + const { mb } = clientOver([jsonResponse([ACTION_V64])], V64_SERVER); + + const result = await mb.dataAction.list(); + + expect(result).toEqual({ data: [{ ...ACTION_V64, collection_id: null }], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(ACTION)]); + + await mb.dataAction.get(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/action/7", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the full body", async () => { + const { mb, capture } = clientOver([jsonResponse(ACTION)]); + + await mb.dataAction.create(CREATE_BODY); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/action", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify(CREATE_BODY), + }, + ]); + }); + + it("refuses create on a server whose data actions need a model, before any request", async () => { + const { mb, capture } = clientOver([], V64_SERVER); + + await expect(mb.dataAction.create(CREATE_BODY)).rejects.toBeInstanceOf(CapabilityError); + expect(capture.calls).toEqual([]); + }); + + it("sends the update request with only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(ACTION)]); + + await mb.dataAction.update(7, { name: "Rename an order" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/action/7", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ name: "Rename an order" }), + }, + ]); + }); + + it("sends archive as an update that sets archived", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...ACTION, archived: true })]); + + await mb.dataAction.archive(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/action/7", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ archived: true }), + }, + ]); + }); + + it("sends the delete request", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.dataAction.delete(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/action/7", + method: "DELETE", + headers: { accept: "*/*", "user-agent": TEST_USER_AGENT, "x-api-key": "mb_wire_test_key" }, + body: null, + }, + ]); + }); + + it("sends the execute request with the parameter values", async () => { + const { mb, capture } = clientOver([jsonResponse({ "rows-affected": 1 })]); + + const result = await mb.dataAction.execute(7, { parameters: { id: 1, note: "rush" } }); + + expect(result).toEqual({ "rows-affected": 1 }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/action/7/execute", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ parameters: { id: 1, note: "rush" } }), + }, + ]); + }); +}); diff --git a/packages/client/src/resources/data-action.ts b/packages/client/src/resources/data-action.ts new file mode 100644 index 00000000..0c877332 --- /dev/null +++ b/packages/client/src/resources/data-action.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +import { + type DataAction, + type DataActionCreateInput, + type DataActionExecuteInput, + DataActionExecuteResult, + dataActionSchema, + type DataActionUpdateInput, +} from "../domain/data-action"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function dataActionResource(transport: Transport) { + async function readSchema(options: RequestOptions): Promise> { + const { features } = await transport.server(options); + return dataActionSchema(features); + } + + /** List the unarchived data actions the caller can see. `GET /api/action` answers a bare array. */ + async function list(options: RequestOptions = {}): Promise> { + await transport.require("dataAction.list", options); + const schema = await readSchema(options); + const data = await transport.requestParsed(z.array(schema), "/api/action", { ...options }); + return { data, total: null }; + } + + /** Get one unarchived data action by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("dataAction.get", options); + const schema = await readSchema(options); + return transport.requestParsed(schema, `/api/action/${id}`, { ...options }); + } + + /** Create a data action — a parameterized native query that writes to a database — in a collection. */ + async function create( + params: DataActionCreateInput, + options: RequestOptions = {}, + ): Promise { + await transport.require("dataAction.create", options); + const schema = await readSchema(options); + return transport.requestParsed(schema, "/api/action", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a data action by id, patching only the fields the body carries. */ + async function update( + id: number, + params: DataActionUpdateInput, + options: RequestOptions = {}, + ): Promise { + await transport.require("dataAction.update", options); + const schema = await readSchema(options); + return transport.requestParsed(schema, `/api/action/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Archive (soft-delete) a data action by id. Metabase models this as an update, not its own endpoint. */ + async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("dataAction.archive", options); + return update(id, { archived: true }, options); + } + + /** Delete a data action by id, removing it and the dashboard buttons that run it. */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.require("dataAction.delete", options); + await transport.requestRaw(`/api/action/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + /** Execute a data action by id with parameter values keyed by parameter id. */ + async function execute( + id: number, + params: DataActionExecuteInput, + options: RequestOptions = {}, + ): Promise { + await transport.require("dataAction.execute", options); + return transport.requestParsed(DataActionExecuteResult, `/api/action/${id}/execute`, { + ...options, + method: "POST", + body: params, + }); + } + + return { list, get, create, update, archive, delete: remove, execute }; +} diff --git a/packages/client/src/version/features.ts b/packages/client/src/version/features.ts index 6d7a92b1..54968a50 100644 --- a/packages/client/src/version/features.ts +++ b/packages/client/src/version/features.ts @@ -15,6 +15,8 @@ export interface FeatureRule { } export const FEATURE_RULES = { + dataActionCollections: { since: 65 }, + dataActionsWithoutModel: { since: 65 }, transforms: { since: 59 }, transformJobActivation: { since: 61 }, measures: { since: 59 }, diff --git a/packages/client/src/version/requirements.test.ts b/packages/client/src/version/requirements.test.ts index d8097bec..f395c4d7 100644 --- a/packages/client/src/version/requirements.test.ts +++ b/packages/client/src/version/requirements.test.ts @@ -12,6 +12,7 @@ import { cardResource } from "../resources/card"; import { collectionResource } from "../resources/collection"; import { contentTranslationResource } from "../resources/content-translation"; import { dashboardResource } from "../resources/dashboard"; +import { dataActionResource } from "../resources/data-action"; import { databaseResource } from "../resources/database"; import { datasetResource } from "../resources/dataset"; import { dependencyResource } from "../resources/dependency"; @@ -273,6 +274,11 @@ const DRIVES: ReadonlyArray = [ invoke: (t) => dashboardResource(t).get(1), wireError: "unexpected request: GET /api/dashboard/1", }, + { + key: "dataAction.get", + invoke: (t) => dataActionResource(t).get(1), + wireError: "unexpected request: GET /api/action/1", + }, { key: "database.schemas", invoke: (t) => databaseResource(t).schemas(1), diff --git a/packages/client/src/version/requirements.ts b/packages/client/src/version/requirements.ts index 637b003e..512d618c 100644 --- a/packages/client/src/version/requirements.ts +++ b/packages/client/src/version/requirements.ts @@ -32,6 +32,13 @@ export const METHOD_REQUIREMENTS = { "dashboard.checkCardReferences": [], "dashboard.parameterValues": [], "dashboard.searchParameterValues": [], + "dataAction.list": [], + "dataAction.get": [], + "dataAction.create": ["dataActionsWithoutModel"], + "dataAction.update": [], + "dataAction.archive": [], + "dataAction.delete": [], + "dataAction.execute": [], "database.list": [], "database.get": [], "database.schemas": [], diff --git a/tests/e2e/data-action.e2e.test.ts b/tests/e2e/data-action.e2e.test.ts new file mode 100644 index 00000000..0d8bba1e --- /dev/null +++ b/tests/e2e/data-action.e2e.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { + DataAction, + type DataActionCreateInput, + DataActionExecuteResult, +} from "@metabase/client/domain/data-action"; +import { createTransport, type Transport } from "@metabase/client/http/transport"; +import { parseJson } from "@metabase/client/json"; +import { z } from "zod"; + +import { DataActionListEnvelope } from "../../packages/cli/src/commands/data-action/list"; +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; +import { USER_AGENT } from "../../packages/cli/src/core/user-agent"; +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorCategory, cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { SEEDED } from "./seed/seeded"; +import { requireServer, serverHas } from "./server-gate"; + +const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; + +const ACTION_NAME = "Touch customer"; + +// Rewrites a column to its own value, so a run affects one row without changing the warehouse. +const TOUCH_CUSTOMER_SQL = "UPDATE customers SET full_name = full_name WHERE id = {{customer_id}}"; + +function createBody(): DataActionCreateInput { + return { + name: ACTION_NAME, + type: "query", + database_id: SEEDED.warehouseDbId, + collection_id: SEEDED.defaultCollectionId, + dataset_query: { + "lib/type": "mbql/query", + database: SEEDED.warehouseDbId, + stages: [ + { + "lib/type": "mbql.stage/native", + native: TOUCH_CUSTOMER_SQL, + "template-tags": { + customer_id: { + id: "customer_id", + name: "customer_id", + "display-name": "Customer ID", + type: "number", + required: true, + }, + }, + }, + ], + }, + parameters: [ + { + id: "customer_id", + slug: "customer_id", + name: "Customer ID", + type: "number/=", + target: ["variable", ["template-tag", "customer_id"]], + required: true, + }, + ], + }; +} + +const skipReason = requireServer("data-action › data-action e2e", ["dataActionsWithoutModel"]); + +describe.skipIf(skipReason !== null)("data-action e2e", () => { + let bootstrap: E2EBootstrap; + let adminClient: Transport; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + adminClient = createTransport( + { url: bootstrap.baseUrl, credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey } }, + { userAgent: USER_AGENT }, + ); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function cli(args: string[], stdin?: string) { + const configHome = await mkTempConfigHome(); + tempDirs.push(configHome); + return runCli({ + args, + ...(stdin === undefined ? {} : { stdin }), + configHome, + env: { MB_URL: bootstrap.baseUrl, MB_API_KEY: bootstrap.adminApiKey }, + }); + } + + async function enableActionsOnWarehouse(): Promise { + await adminClient.requestParsed(z.unknown(), `/api/database/${SEEDED.warehouseDbId}`, { + method: "PUT", + body: { settings: { "database-enable-actions": true } }, + }); + } + + async function createAction(): Promise { + const result = await cli( + ["data-action", "create", "--json", "--full"], + JSON.stringify(createBody()), + ); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, DataAction); + } + + it("create is refused while actions are off on the database", async () => { + const result = await cli(["data-action", "create", "--json"], JSON.stringify(createBody())); + + expect(result.exitCode).not.toBe(0); + expect(cliErrorMessage(result.stderr)).toContain("Actions are not enabled."); + expect(result.stdout).toBe(""); + }); + + it("create files a model-less query action in its collection, and list and get read it back", async () => { + await enableActionsOnWarehouse(); + + const created = await createAction(); + expect(created).toMatchObject({ + name: ACTION_NAME, + type: "query", + model_id: null, + database_id: SEEDED.warehouseDbId, + collection_id: SEEDED.defaultCollectionId, + archived: false, + }); + + const listed = await cli(["data-action", "list", "--json"]); + expect(listed.exitCode, listed.stderr).toBe(0); + expect(parseJson(listed.stdout, DataActionListEnvelope).data).toEqual([ + { + id: created.id, + name: ACTION_NAME, + type: "query", + collection_id: SEEDED.defaultCollectionId, + database_id: SEEDED.warehouseDbId, + archived: false, + }, + ]); + + const fetched = await cli(["data-action", "get", String(created.id), "--json", "--full"]); + expect(fetched.exitCode, fetched.stderr).toBe(0); + expect(parseJson(fetched.stdout, DataAction)).toMatchObject({ + id: created.id, + parameters: [expect.objectContaining({ id: "customer_id", type: "number/=" })], + }); + }); + + it("execute runs the write with the parameter values and reports the rows it affected", async () => { + await enableActionsOnWarehouse(); + const created = await createAction(); + + const result = await cli( + ["data-action", "execute", String(created.id), "--json"], + JSON.stringify({ parameters: { customer_id: 1 } }), + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DataActionExecuteResult)).toEqual({ "rows-affected": 1 }); + }); + + it("execute without a required parameter is refused by the server", async () => { + await enableActionsOnWarehouse(); + const created = await createAction(); + + const result = await cli( + ["data-action", "execute", String(created.id), "--json"], + JSON.stringify({ parameters: {} }), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toBe(""); + }); + + it("update renames, archive hides it from list, and delete removes it", async () => { + await enableActionsOnWarehouse(); + const created = await createAction(); + + const renamed = await cli( + ["data-action", "update", String(created.id), "--json", "--full"], + JSON.stringify({ name: "Touch a customer" }), + ); + expect(renamed.exitCode, renamed.stderr).toBe(0); + expect(parseJson(renamed.stdout, DataAction).name).toBe("Touch a customer"); + + const archived = await cli(["data-action", "archive", String(created.id), "--json", "--full"]); + expect(archived.exitCode, archived.stderr).toBe(0); + expect(parseJson(archived.stdout, DataAction).archived).toBe(true); + + const listed = await cli(["data-action", "list", "--json"]); + expect(listed.exitCode, listed.stderr).toBe(0); + expect(parseJson(listed.stdout, DataActionListEnvelope).data).toEqual([]); + + const deleted = await cli(["data-action", "delete", String(created.id), "--yes", "--json"]); + expect(deleted.exitCode, deleted.stderr).toBe(0); + expect(parseJson(deleted.stdout, DeleteResult)).toEqual({ + id: created.id, + deleted: true, + aborted: false, + }); + }); +}); + +describe.skipIf(serverHas("dataActionsWithoutModel"))( + "data-action on servers that need a model", + () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + it("create refuses with CapabilityError (exit 2) after a live probe, before sending the body", async () => { + const configHome = await mkTempConfigHome(); + tempDirs.push(configHome); + + const result = await runCli({ + args: ["data-action", "create", "--json"], + stdin: JSON.stringify(createBody()), + configHome, + env: { MB_URL: bootstrap.baseUrl, MB_API_KEY: bootstrap.adminApiKey }, + }); + + if (bootstrap.server.version === null) { + throw new Error( + "a server with no parsed version is placed past the window, never below it", + ); + } + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("capability"); + expect(cliErrorMessage(result.stderr)).toBe( + `This operation requires Metabase v65+ (this server is ${bootstrap.server.version.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`, + ); + expect(result.stdout).toBe(""); + }); + }, +); diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index c316796f..704a6368 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -25,6 +25,7 @@ const BUNDLED_VISIBLE = discoverSkills([SKILL_DATA_DIR]).filter((skill) => !skil const BUNDLED_VISIBLE_NAMES = [ "core", "dashboard", + "data-action", "data-workflow", "document", "git-sync", @@ -94,6 +95,19 @@ const TRANSFORM_UNAVAILABLE_ON_58 = { }, }; +const DATA_ACTION_UNAVAILABLE_ON_58 = { + name: "data-action", + failure: { + reason: "version-too-old", + detail: + "This operation requires Metabase v65+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "dataActionsWithoutModel", + since: 65, + tokenFeature: null, + serverVersion: "v0.58.0", + }, +}; + const GIT_SYNC_UNAVAILABLE_ON_58 = { name: "git-sync", failure: { @@ -107,6 +121,8 @@ const GIT_SYNC_UNAVAILABLE_ON_58 = { }, }; +const FEATURE_BOUND_ON_58 = new Set(["data-action", "git-sync", "transform"]); + describe("skills e2e", () => { const tempDirs: string[] = []; @@ -120,11 +136,11 @@ describe("skills e2e", () => { return dir; } - it("ships exactly the eleven visible skills this suite names", () => { + it("ships exactly the twelve visible skills this suite names", () => { expect(BUNDLED_VISIBLE.map((skill) => skill.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); }); - it("list returns the eleven bundled non-hidden skills, sorted by name, with `unavailable: null` and a clean stderr when there is no cached probe", async () => { + it("list returns the twelve bundled non-hidden skills, sorted by name, with `unavailable: null` and a clean stderr when there is no cached probe", async () => { const result = await runCli({ args: ["skills", "list", "--json"], configHome: await makeIsolatedConfigHome(), @@ -153,12 +169,14 @@ describe("skills e2e", () => { const result = await runCli({ args: ["skills", "list", "--json"], configHome }); expect(result.exitCode, result.stderr).toBe(0); - const usable = BUNDLED_VISIBLE.filter( - (skill) => skill.name !== "git-sync" && skill.name !== "transform", - ); + const usable = BUNDLED_VISIBLE.filter((skill) => !FEATURE_BOUND_ON_58.has(skill.name)); expect(parseJson(result.stdout, SkillListEnvelope)).toEqual({ ...fullList(usable), - unavailable: [GIT_SYNC_UNAVAILABLE_ON_58, TRANSFORM_UNAVAILABLE_ON_58], + unavailable: [ + DATA_ACTION_UNAVAILABLE_ON_58, + GIT_SYNC_UNAVAILABLE_ON_58, + TRANSFORM_UNAVAILABLE_ON_58, + ], }); expect(result.stderr).toBe(""); }); @@ -185,10 +203,11 @@ describe("skills e2e", () => { expect(result.exitCode).toBe(0); expect(namesInTextListing(result.stdout)).toEqual( - BUNDLED_VISIBLE_NAMES.filter((name) => name !== "git-sync" && name !== "transform"), + BUNDLED_VISIBLE_NAMES.filter((name) => !FEATURE_BOUND_ON_58.has(name)), ); expect(result.stderr).toBe( [ + `Skipped skill "data-action": ${DATA_ACTION_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, `Skipped skill "git-sync": ${GIT_SYNC_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, `Skipped skill "transform": ${TRANSFORM_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, ].join("\n"),