Skip to content
Open
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,55 @@ mb transform-tag delete 5 --yes
| ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). |

## Transform indexes

Manage indexes on a transform's target table via `/api/index`. Requires Metabase v64 or newer. An index request declares a physical index (or clustering/sort/dist key, depending on the warehouse) on a transform's output; Metabase reapplies it on each full transform run. Create, update and delete only set a pending state — the physical index is created or dropped when the target table is next rebuilt.

### `mb transform-index list <transform-id>`

Lists a transform's indexes: those physically in the warehouse, merged with its managed requests.

```sh
mb transform-index list 1 --json
```

### `mb transform-index get <id>`

```sh
mb transform-index get 1 --json
```

### `mb transform-index create`

The body is `{ transform_id, structured }`, where `structured` is a definition dispatched on `kind` (`btree`, `hash`, `sortkey`, `distkey`, `clustering`, `order-by`, `skip-index`, …).

```sh
mb transform-index create --body '{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}'
```

| Flag | Description |
| --------------- | ----------------------- |
| `--body <json>` | Inline JSON body. |
| `--file <path>` | Path to JSON body file. |

### `mb transform-index update <id>`

Replaces the `structured` definition and marks the request update-pending. The index name, kind and type are fixed at creation — delete and recreate to change those.

```sh
mb transform-index update 1 --body '{"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"},{"name":"created_at"}]}}'
```

### `mb transform-index delete <id>`

```sh
mb transform-index delete 1 --yes
```

| Flag | Description |
| ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). |

## Databases

Read warehouse metadata from `/api/database`. The `db` group exposes the full database list, the per-database record with optional table/field hydration, schema and table inspection, and the two manual-sync triggers.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metabase/cli",
"version": "0.3.0",
"version": "0.3.1",
"description": "Metabase CLI",
"license": "AGPL-3.0",
"repository": {
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/skill-data/transform/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,21 @@ mb transform delete <id> --yes --profile <name>

Removes the definition. Whether the materialized table is dropped depends on the server — check with `mb table list --db-id <db-id> --profile <name> --json` if it matters. Same `--yes` rule and message as `delete-table`.

## Indexes on the target table

Metabase v64+. An **index request** (`transform-index`) declares a physical index — or a clustering / sort / dist key, whichever the warehouse speaks — on a transform's output table, and reapplies it on every full run. Create, update and delete only move the request's `status`; the warehouse changes when the target table is next rebuilt in full, so a fresh request reads `create-pending` and `present_in_warehouse: false` until then.

```bash
mb transform-index list <transform-id> --profile <name> --json # warehouse indexes merged with managed requests
mb transform-index create --body '{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}' --profile <name> --json
mb transform-index update <id> --body '{"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"},{"name":"created_at"}]}}' --profile <name> --json
mb transform-index delete <id> --yes --profile <name>
```

`structured` dispatches on `kind`: `btree`/`hash`/`gin`/`gist`/`brin`/`spgist`/`fulltext`/`spatial`/`clustered`/`nonclustered`/`columnstore` take `name` + `columns` (plus optional `include`, `unique`); `sortkey` and `distkey` take a `style`; `clustering` and `order-by` take `columns` alone; `skip-index` adds a `type` of `minmax` or `bloom_filter`.

Two rules the server enforces: an index name is unique per transform, and `update` may not change a request's name, kind or type — delete it and create another instead. Both answer 400. Read `status` and `error_message` off `transform-index get <id>` to see how the last rebuild went.

## Transform jobs (schedules)

A schedule lives in a separate resource (`transform-job`). A job carries **tags** (`tag_ids`), not transform ids: each run executes every transform carrying one of the job's tags. You add a transform to a job by tagging the transform (`transform update <id> --body '{"tag_ids":[…]}'`), not by listing it on the job. Create/update with the same body-input pattern (`--file body.json`).
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/commands/transform-index/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
TransformIndexCreateInput,
TransformIndexRequest,
} from "@metabase/client/domain/transform-index";

import { renderSummary } from "../../output/render";
import { transformIndexRequestView } from "../../output/views/transform-index";
import { readBody } from "../../runtime/body";
import { bodyInputFlags } from "../body-flags";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "create", description: "Create an index request on a transform's target table" },
details:
"Body keys: `transform_id` and `structured`, a definition dispatched on `kind` (`btree`, `hash`, `sortkey`, `distkey`, `clustering`, `order-by`, `skip-index`, …). The request lands pending — the physical index is created the next time the target table is rebuilt in full.",
capabilities: { minVersion: 64 },
args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags },
inputSchema: TransformIndexCreateInput,
outputSchema: TransformIndexRequest,
examples: [
'mb transform-index create --body \'{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}\'',
"mb transform-index create --file index.json",
'echo \'{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}\' | mb transform-index create',
],
async run({ args, ctx, getClient }) {
const body = await readBody({ flag: args.body, file: args.file }, TransformIndexCreateInput);
const client = await getClient();
const created = await client.transformIndex.create(body);
renderSummary(
created,
transformIndexRequestView,
`Created index request ${created.id} "${created.index_name}".`,
ctx,
);
},
});
31 changes: 31 additions & 0 deletions packages/cli/src/commands/transform-index/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: "Mark an index request for deletion by id" },
capabilities: { minVersion: 64 },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
yes: { type: "boolean", description: "Skip confirmation", default: false },
id: { type: "positional", description: "Index request id", required: true },
},
outputSchema: DeleteResult,
examples: ["mb transform-index delete 1 --yes", "mb transform-index delete 1"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
await confirmAndDelete({
id,
yes: args.yes,
promptMessage: `Delete index request ${id}? The physical index drops on the next full rebuild.`,
successMessage: `Marked index request ${id} for deletion; it drops on the next full rebuild.`,
abortMessage: `Aborted; index request ${id} was not deleted.`,
deleteResource: () => client.transformIndex.delete(id),
ctx,
});
},
});
26 changes: 26 additions & 0 deletions packages/cli/src/commands/transform-index/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TransformIndexRequest } from "@metabase/client/domain/transform-index";

import { renderItem } from "../../output/render";
import { transformIndexRequestView } from "../../output/views/transform-index";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "get", description: "Get a transform index request by id" },
capabilities: { minVersion: 64 },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
id: { type: "positional", description: "Index request id", required: true },
},
outputSchema: TransformIndexRequest,
examples: ["mb transform-index get 1", "mb transform-index get 1 --json"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
const request = await client.transformIndex.get(id);
renderItem(request, transformIndexRequestView, ctx);
},
});
14 changes: 14 additions & 0 deletions packages/cli/src/commands/transform-index/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineCommandGroup } from "../group";

export default defineCommandGroup({
name: "transform-index",
description: "Manage indexes on a transform's target table",
skills: [{ skill: "transform", purpose: "indexes reapplied on each full transform run" }],
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),
delete: () => import("./delete").then((mod) => mod.default),
},
});
33 changes: 33 additions & 0 deletions packages/cli/src/commands/transform-index/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { TransformIndexCompact } from "@metabase/client/domain/transform-index";

import { renderList } from "../../output/render";
import { listEnvelopeSchema } from "../../output/types";
import { transformIndexView } from "../../output/views/transform-index";
import { windowList } from "../../output/window";
import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export const TransformIndexListEnvelope = listEnvelopeSchema(TransformIndexCompact);

export default defineMetabaseCommand({
meta: { name: "list", description: "List a transform's target-table indexes" },
details:
"Returns the indexes observed in the warehouse merged with the index requests Metabase manages for the transform, so a request that has not been applied yet shows up with `present_in_warehouse: false`.",
capabilities: { minVersion: 64 },
args: {
...outputFlags,
...listFlags,
...profileFlag,
...connectionFlags,
transformId: { type: "positional", description: "Transform id", required: true },
},
outputSchema: TransformIndexListEnvelope,
examples: ["mb transform-index list 1", "mb transform-index list 1 --json"],
async run({ args, ctx, getClient }) {
const transformId = parseId(args.transformId);
const client = await getClient();
const { data } = await client.transformIndex.list(transformId);
renderList(windowList(data, ctx.range), transformIndexView, ctx);
},
});
44 changes: 44 additions & 0 deletions packages/cli/src/commands/transform-index/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
TransformIndexRequest,
TransformIndexUpdateInput,
} from "@metabase/client/domain/transform-index";

import { renderSummary } from "../../output/render";
import { transformIndexRequestView } from "../../output/views/transform-index";
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: "update", description: "Replace an index request's definition by id" },
details:
"Replaces the `structured` definition and marks the request update-pending. The index name, kind and type are fixed at creation — changing those means deleting the request and creating another.",
capabilities: { minVersion: 64 },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
...bodyInputFlags,
id: { type: "positional", description: "Index request id", required: true },
},
inputSchema: TransformIndexUpdateInput,
outputSchema: TransformIndexRequest,
examples: [
'mb transform-index update 1 --body \'{"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"},{"name":"created_at"}]}}\'',
"mb transform-index update 1 --file index.json",
],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const body = await readBody({ flag: args.body, file: args.file }, TransformIndexUpdateInput);
const client = await getClient();
const updated = await client.transformIndex.update(id, body);
renderSummary(
updated,
transformIndexRequestView,
`Updated index request ${updated.id} "${updated.index_name}".`,
ctx,
);
},
});
1 change: 1 addition & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const main: CommandDef = defineCommand({
transform: () => import("./commands/transform").then((mod) => mod.default),
"transform-job": () => import("./commands/transform-job").then((mod) => mod.default),
"transform-tag": () => import("./commands/transform-tag").then((mod) => mod.default),
"transform-index": () => import("./commands/transform-index").then((mod) => mod.default),
setting: () => import("./commands/setting").then((mod) => mod.default),
search: () => import("./commands/search").then((mod) => mod.default),
"git-sync": () => import("./commands/git-sync").then((mod) => mod.default),
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/output/views/transform-index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";

import { MALFORMED_CELL } from "../table";
import type { ResourceView } from "../view";
import { transformIndexRequestView, transformIndexView } from "./transform-index";

function renderCell<T>(view: ResourceView<T>, key: keyof T & string, value: unknown): string {
const column = view.tableColumns.find((candidate) => candidate.key === key);
if (column?.format === undefined) {
throw new Error(`the view declares no formatter for "${key}"`);
}
return column.format(value);
}

describe("transformIndexRequestView definition cell", () => {
it("renders a column-carrying definition as kind(columns)", () => {
expect(
renderCell(transformIndexRequestView, "structured", {
kind: "btree",
name: "idx_id",
columns: [{ name: "id" }, { name: "created_at" }],
}),
).toBe("btree(id, created_at)");
});

it("renders a definition that names no columns as the bare kind", () => {
expect(
renderCell(transformIndexRequestView, "structured", { kind: "distkey", style: "even" }),
).toBe("distkey");
});

// An index kind this Metabase writes and the CLI does not know misses the discriminated union.
// Blanking the cell would read as "this request declares nothing".
it("renders a definition that fails its schema as the malformed marker", () => {
expect(
renderCell(transformIndexRequestView, "structured", {
kind: "zorder",
columns: [{ name: "id" }],
}),
).toBe(MALFORMED_CELL);
});
});

describe("transformIndexView cells", () => {
it("renders the key columns as a comma-separated list", () => {
expect(renderCell(transformIndexView, "key_columns", ["id", "created_at"])).toBe(
"id, created_at",
);
});

it("renders a managed request as its id and status", () => {
expect(
renderCell(transformIndexView, "request", {
id: 3,
transform_id: 1,
index_name: "idx_id",
status: "create-pending",
structured: { kind: "btree", name: "idx_id", columns: [{ name: "id" }] },
error_message: null,
}),
).toBe("#3 create-pending");
});

it("renders an index Metabase does not manage as an empty request cell", () => {
expect(renderCell(transformIndexView, "request", undefined)).toBe("");
});

it("renders a request that fails its schema as the malformed marker", () => {
expect(renderCell(transformIndexView, "request", { id: 3, status: "invented-status" })).toBe(
MALFORMED_CELL,
);
});
});
Loading
Loading