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
107 changes: 107 additions & 0 deletions packages/cli/src/commands/lineage/dependents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { ConfigError } from "@metabase/client/errors";
import {
type EntityRef,
LineageType,
type LineageType as LineageTypeValue,
walkDependents,
} from "../../core/lineage";
import { renderList } from "../../output/render";
import { warn } from "../../output/notice";
import { listEnvelopeSchema } from "../../output/types";
import { dependentView, DependentCompact } from "../../output/views/dependent";
import { windowList } from "../../output/window";
import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";

export const DependentsEnvelope = listEnvelopeSchema(DependentCompact);

export default defineMetabaseCommand({
meta: { name: "dependents", description: "List Metabase entities downstream of a table" },
details:
"Finds a physical table by schema and name, then walks every downstream dependency. --types filters the returned entities without pruning intermediate paths.",
capabilities: { minVersion: 61, tokenFeature: "dependencies" },
args: {
...outputFlags,
...listFlags,
...profileFlag,
...connectionFlags,
database: { type: "string", description: "Metabase database id or name (optional)" },
schema: { type: "string", description: "Physical table schema", required: true },
table: { type: "string", description: "Physical table name", required: true },
types: {
type: "string",
description: "Returned dependency types, comma separated (default: all)",
},
},
outputSchema: DependentsEnvelope,
examples: [
"mb lineage dependents --schema reporting --table orders",
"mb lineage dependents --database Analytics --schema reporting --table orders --types question,dashboard",
],
async run({ args, ctx, getClient }) {
const requestedTypes = parseTypes(args.types);
const client = await getClient();
const databases = (await client.database.list()).data.filter((database) =>
matchesDatabase(database.id, database.name, args.database),
);
if (databases.length === 0) {
throw new ConfigError(`no Metabase database matches --database "${args.database}"`);
}

const candidates = (
await Promise.all(
databases.map((database) => client.database.schemaTables(database.id, args.schema)),
)
).flatMap(({ data }) => data.filter((table) => equalName(table.name, args.table)));
if (candidates.length === 0) {
throw new ConfigError(`table not found: ${args.schema}.${args.table}`);
}
if (candidates.length > 1) {
const ids = candidates.map((table) => `${table.db_id}:${table.id}`).join(", ");
throw new ConfigError(
`multiple Metabase tables match ${args.schema}.${args.table} (${ids}); pass --database`,
);
}

const table = candidates[0];
if (table === undefined) {
throw new ConfigError(`table not found: ${args.schema}.${args.table}`);
}
const root: EntityRef = { type: "table", id: table.id };
const dependents = await walkDependents(client.dependency.dependents, root);
const filtered = dependents.filter((dependent) => requestedTypes.has(dependent.type));

const status = await client.dependency.backfillStatus();
if (!status.complete) {
warn("Metabase is still indexing dependencies; dependents may be incomplete.");
}
renderList(windowList(filtered, ctx.range), dependentView, ctx);
},
});

function parseTypes(raw: string | undefined): Set<LineageTypeValue> {
if (raw === undefined) {
return new Set(LineageType);
}
const values = raw.split(",").map((value) => value.trim());
const invalid = values.filter((value) => !isLineageType(value));
if (invalid.length > 0 || values.length === 0) {
throw new ConfigError(`invalid --types value: "${raw}" (expected: ${LineageType.join(", ")})`);
}
return new Set(values.filter(isLineageType));
}

function isLineageType(value: string): value is LineageTypeValue {
return LineageType.some((candidate) => candidate === value);
}

function equalName(left: string, right: string): boolean {
return left.localeCompare(right, undefined, { sensitivity: "accent" }) === 0;
}

function matchesDatabase(id: number, name: string, filter: string | undefined): boolean {
if (filter === undefined) {
return true;
}
return String(id) === filter || equalName(name, filter);
}
9 changes: 9 additions & 0 deletions packages/cli/src/commands/lineage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineCommandGroup } from "../group";

export default defineCommandGroup({
name: "lineage",
description: "Inspect dependencies between Metabase and warehouse entities",
subCommands: {
dependents: () => import("./dependents").then((mod) => mod.default),
},
});
45 changes: 45 additions & 0 deletions packages/cli/src/core/lineage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";

import type { DependencyNode } from "@metabase/client/domain/dependency";

import { lineageType, walkDependents } from "./lineage";

function node(id: number, type: DependencyNode["type"], data: DependencyNode["data"] = {}) {
return { id, type, data };
}

describe("lineage", () => {
it("uses formal card subtypes for questions, models, and metrics", () => {
expect(lineageType(node(1, "card", { type: "model" }))).toBe("model");
expect(lineageType(node(2, "card", { type: "metric" }))).toBe("metric");
expect(lineageType(node(3, "card", { type: "question" }))).toBe("question");
});

it("walks breadth-first, keeps shortest paths, and breaks cycles", async () => {
const read = vi.fn(async (type: DependencyNode["type"], id: number) => {
const graph: Record<string, DependencyNode[]> = {
"table:1": [
node(2, "card", { type: "model", name: "Orders model" }),
node(3, "card", { name: "Question" }),
],
"card:2": [node(4, "dashboard", { name: "Operations" })],
"card:3": [node(4, "dashboard", { name: "Operations" })],
"dashboard:4": [node(2, "card", { type: "model", name: "Orders model" })],
};
return graph[`${type}:${id}`] ?? [];
});
Comment on lines +19 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make unexpected graph reads fail.

?? [] turns an unmodeled dependency read into a leaf, which can hide traversal regressions. Throw for unknown graph keys instead.

As per coding guidelines, “Do not use placeholder fallbacks such as ?? "", ?? 0, ?? [], or ?? {} when the semantic state is missing.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/core/lineage.test.ts` around lines 19 - 30, The mock read
function should throw when a dependency graph key is not defined instead of
treating it as an empty leaf. Update the graph lookup in the read function so
known keys still return their arrays, while unknown `${type}:${id}` keys fail
explicitly.

Source: Coding guidelines


const result = await walkDependents(read, { type: "table", id: 1 });

expect(result.map(({ type, id, distance }) => ({ type, id, distance }))).toEqual([
{ type: "model", id: 2, distance: 1 },
{ type: "question", id: 3, distance: 1 },
{ type: "dashboard", id: 4, distance: 2 },
]);
expect(result[2]?.path).toEqual([
{ type: "table", id: 1 },
{ type: "card", id: 2 },
{ type: "dashboard", id: 4 },
]);
});
});
91 changes: 91 additions & 0 deletions packages/cli/src/core/lineage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type {
DependencyCardType,
DependencyEntityType,
DependencyNode,
} from "@metabase/client/domain/dependency";

export const LineageType = [
"question",
"model",
"metric",
"table",
"transform",
"snippet",
"dashboard",
"document",
"sandbox",
"segment",
"measure",
] as const;
export type LineageType = (typeof LineageType)[number];

export interface EntityRef {
id: number;
type: DependencyEntityType;
}

export interface Dependent {
id: number;
type: LineageType;
name: string | null;
distance: number;
path: EntityRef[];
}

export interface DependencyReader {
(type: EntityRef["type"], id: number): Promise<DependencyNode[]>;
}

export async function walkDependents(read: DependencyReader, root: EntityRef) {
const queue: Array<{ entity: EntityRef; path: EntityRef[] }> = [{ entity: root, path: [root] }];
const seen = new Set([entityKey(root)]);
const dependents: Dependent[] = [];

while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
const direct = await read(current.entity.type, current.entity.id);
for (const node of direct) {
const entity: EntityRef = { type: node.type, id: node.id };
const key = entityKey(entity);
if (seen.has(key)) {
continue;
}
seen.add(key);
const path = [...current.path, entity];
dependents.push(buildDependent(node, path));
queue.push({ entity, path });
}
}
return dependents;
}

export function entityKey(entity: EntityRef): string {
return `${entity.type}:${entity.id}`;
}

export function lineageType(node: DependencyNode): LineageType {
if (node.type !== "card") {
return node.type;
}
const cardType = node.data["type"];
return isCardType(cardType) ? cardType : "question";
}

export function buildDependent(node: DependencyNode, path: EntityRef[]): Dependent {
const rawName =
node.type === "table" ? (node.data["display_name"] ?? node.data["name"]) : node.data["name"];
return {
id: node.id,
type: lineageType(node),
name: typeof rawName === "string" ? rawName : null,
distance: path.length - 1,
path,
};
}

function isCardType(value: unknown): value is DependencyCardType {
return value === "question" || value === "model" || value === "metric";
}
1 change: 1 addition & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const main: CommandDef = defineCommand({
auth: () => import("./commands/auth").then((mod) => mod.default),
db: () => import("./commands/db").then((mod) => mod.default),
table: () => import("./commands/table").then((mod) => mod.default),
lineage: () => import("./commands/lineage").then((mod) => mod.default),
field: () => import("./commands/field").then((mod) => mod.default),
upload: () => import("./commands/upload").then((mod) => mod.default),
card: () => import("./commands/card").then((mod) => mod.default),
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/output/views/dependent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { z } from "zod";
import { DependencyEntityType } from "@metabase/client/domain/dependency";

import type { Dependent } from "../../core/lineage";
import { LineageType } from "../../core/lineage";
import type { ResourceView } from "../view";

const EntityRef = z.object({ id: z.number().int().positive(), type: DependencyEntityType });

export const DependentCompact = z.object({
id: z.number().int().positive(),
type: z.enum(LineageType),
name: z.string().nullable(),
distance: z.number().int().positive(),
path: z.array(EntityRef),
});

export const dependentView: ResourceView<Dependent> = {
compactPick: DependentCompact,
tableColumns: [
{ key: "type", label: "Type" },
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "distance", label: "Distance" },
{
key: "path",
label: "Path",
format: (value) =>
Array.isArray(value)
? value
.filter((item): item is { type: string; id: number } =>
Boolean(
typeof item === "object" &&
item !== null &&
"type" in item &&
"id" in item &&
typeof item.type === "string" &&
typeof item.id === "number",
),
)
.map((item) => `${item.type}:${item.id}`)
.join(" -> ")
: "",
Comment on lines +28 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline packages/cli/src/output/views/descendant.ts --view expanded || true

echo "== descendant.ts =="
cat -n packages/cli/src/output/views/descendant.ts

echo "== nearby schema definitions/usages =="
rg -n "EntityRef|descendantView|format:" packages/cli/src packages/client/src -g '*.ts' -g '*.tsx' | head -200

Repository: metabase/metabase-cli

Length of output: 12259


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ResourceView and compactPick handling =="
cat -n packages/cli/src/output/types.ts | sed -n '1,150p'

echo "== renderList compactPick integration =="
rg -n "compactPick|ResourceView|renderList" packages/cli/src/output -g '*.ts' -C 4

echo "== lineage Descendant interface =="
cat -n packages/cli/src/core/lineage.ts | sed -n '1,120p'

Repository: metabase/metabase-cli

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== compactPick safeParse path =="
sed -n '50,62p' packages/cli/src/output/projection.ts

echo "== projection fallback and column formatting =="
sed -n '42,95p' packages/cli/src/output/projection.ts
sed -n '1,95p' packages/cli/src/output/table.ts

echo "== dependency types =="
cat -n packages/client/src/domain/dependency.ts | sed -n '1,120p'

Repository: metabase/metabase-cli

Length of output: 5323


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== projection pickPath projection and column type usage =="
sed -n '62,180p' packages/cli/src/output/projection.ts

echo "== read-only semantic verifier for formatter behavior =="
node - <<'JS'
function currentFormat(value) {
  return Array.isArray(value)
    ? value
        .filter((item) =>
          Boolean(
            typeof item === "object" &&
            item !== null &&
            "type" in item &&
            "id" in item &&
            typeof item.type === "string" &&
            typeof item.id === "number",
          ),
        )
        .map((item) => `${item.type}:${item.id}`)
        .join(" -> ")
    : "";
}
for (const value of [
  null,
  undefined,
  123,
  [{ type: "card", id: 42 }],
  [{ type: "card", id: 42 }, { type: 123, id: 7 }, { type: null, id: 8 }],
]) {
  console.log(JSON.stringify(value), "=>", JSON.stringify(currentFormat(value)));
}
JS

Repository: metabase/metabase-cli

Length of output: 1845


Fail on malformed lineage paths instead of dropping entries.

The path column is part of DescendantCompact, but this formatter still accepts any unknown legacy value and silently truncates malformed items to "". Make the projection/schema path fail first, and use the inferred EntityRef type for the formatter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/output/views/descendant.ts` around lines 28 - 43, Update the
path projection/schema for DescendantCompact to reject malformed lineage entries
instead of filtering them out. Change the path column’s format callback to
accept the schema-inferred EntityRef type and map validated references directly
to their type/id representation, preserving the existing arrow-separated output
without the unknown-value fallback.

Source: Coding guidelines

},
],
};
1 change: 1 addition & 0 deletions packages/cli/src/runtime/command-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ const ALL_COMMANDS = [
"table get",
"table fields",
"table update",
"lineage dependents",
"field get",
"field values",
"field summary",
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { collectionResource } from "./resources/collection";
import { dashboardResource } from "./resources/dashboard";
import { databaseResource } from "./resources/database";
import { datasetResource } from "./resources/dataset";
import { dependencyResource } from "./resources/dependency";
import { documentResource } from "./resources/document";
import { eidTranslationResource } from "./resources/eid-translation";
import { fieldResource } from "./resources/field";
Expand Down Expand Up @@ -37,6 +38,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions)
dashboard: dashboardResource(transport),
database: databaseResource(transport),
dataset: datasetResource(transport),
dependency: dependencyResource(transport),
document: documentResource(transport),
eidTranslation: eidTranslationResource(transport),
field: fieldResource(transport),
Expand Down
28 changes: 28 additions & 0 deletions packages/client/src/domain/dependency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from "zod";

export const DependencyEntityType = z.enum([
"card",
"table",
"transform",
"snippet",
"dashboard",
"document",
"sandbox",
"segment",
"measure",
]);
export type DependencyEntityType = z.infer<typeof DependencyEntityType>;

export const DependencyCardType = z.enum(["question", "model", "metric"]);
export type DependencyCardType = z.infer<typeof DependencyCardType>;

export const DependencyNode = z.object({
id: z.number().int().positive(),
type: DependencyEntityType,
data: z.record(z.string(), z.unknown()),
dependents_count: z.record(z.string(), z.number().int().nonnegative()).nullable().optional(),
Comment on lines +19 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 3 'DependencyNode|DependencyCardType|\.data\b' packages/client/src packages/cli/src

Repository: metabase/metabase-cli

Length of output: 32713


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== dependency domain =="
cat -n packages/client/src/domain/dependency.ts

echo "== dependency resource =="
cat -n packages/client/src/resources/dependency.ts

echo "== dependency-related files =="
fd -i 'dependency' packages/client packages/cli -t f | sed -n '1,120p'

echo "== dependency tests/usages =="
rg -n -C 2 'dependents|dependencyResource|DependencyNodeList|DependencyNode' packages/client/src packages/cli/src tests -g '!node_modules' || true

echo "== behavioral schema probe =="
node - <<'JS'
try {
  const z = require('zod');
  const schema = z.object({
    id: z.number().int().positive(),
    type: z.enum(['card', 'table', 'dataset', 'database', 'metric', 'model', 'question']),
    data: z.record(z.string(), z.unknown()),
  });
  const payload = {
    id: 1,
    type: 'card',
    data: { fakeField: true, arbitraryNested: { a: 1 }, cardType: 'banana' },
  };
  console.log('record unknown accepts arbitrary payload:', schema.safeParse(payload).success);
  const union = z.discriminatedUnion('type', [
    z.object({ type: z.literal('card'), cardType: z.enum(['question', 'model', 'metric']), id: z.number(), name: z.string() }),
    z.object({ type: z.literal('table'), id: z.number(), name: z.string() }),
  ]);
  console.log('discriminated union rejects wrong card type:', !union.safeParse({ type: 'card', cardType: 'banana' }).success);
  console.log('discriminated union rejects extra unknown card field:', !union.safeParse({ type: 'card', cardType: 'question', fakeField: true }).success);
} catch (error) {
  if (error.code === 'MODULE_NOT_FOUND') {
    console.log('zod not available');
  } else {
    console.log(error);
  }
}
JS

Repository: metabase/metabase-cli

Length of output: 11029


Parse dependency payloads with named schemas.

data: z.record(z.string(), z.unknown()) lets /api/ee/dependencies/graph/dependents responses pass without validating card fields or the type/card-type values. Define named dependency-node data schemas, preferably a discriminated union on type, instead of Record<string, unknown>.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/domain/dependency.ts` around lines 19 - 23, Replace the
unvalidated data record in DependencyNode with named schemas for each dependency
entity/card type, using a discriminated union keyed by type and validating the
corresponding card fields and type values. Reuse the existing
DependencyEntityType definitions where applicable, and keep dependents_count
validation unchanged.

Source: Coding guidelines

});
export type DependencyNode = z.infer<typeof DependencyNode>;

export const DependencyBackfillStatus = z.object({ complete: z.boolean() });
export type DependencyBackfillStatus = z.infer<typeof DependencyBackfillStatus>;
Comment on lines +19 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 2 'z\.looseObject|export const .*Compact|\.pick\(' packages/client/src/domain

Repository: metabase/metabase-cli

Length of output: 15588


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "dependency.ts outline:"
ast-grep outline packages/client/src/domain/dependency.ts || true

echo
echo "dependency.ts content:"
cat -n packages/client/src/domain/dependency.ts

echo
echo "imports/usages of dependency domain:"
rg -n 'Dependency|dependency' packages/client/src packages/cli 2>/dev/null || true

Repository: metabase/metabase-cli

Length of output: 6999


Export the required dependency domain compact schemas.

packages/client/src/domain/dependency.ts exports DependencyNode and DependencyBackfillStatus without matched <Resource>Compact schemas. Add .pick(...).strip() exports for the endpoint data shapes this resource uses, or split the contracts if full/compact payloads differ.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/domain/dependency.ts` around lines 19 - 28, Add exported
compact schemas for the dependency domain alongside DependencyNode and
DependencyBackfillStatus, using pick(...).strip() to define the
endpoint-specific compact shapes. Ensure each compact schema includes only the
fields required by its resource and preserves the existing full schemas and
inferred types.

Source: Coding guidelines

Loading