-
Notifications
You must be signed in to change notification settings - Fork 1
Add downstream lineage command for warehouse tables #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } |
| 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), | ||
| }, | ||
| }); |
| 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}`] ?? []; | ||
| }); | ||
|
|
||
| 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 }, | ||
| ]); | ||
| }); | ||
| }); | ||
| 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"; | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -200Repository: 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)));
}
JSRepository: metabase/metabase-cli Length of output: 1845 Fail on malformed lineage paths instead of dropping entries. The 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| }, | ||
| ], | ||
| }; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/srcRepository: 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);
}
}
JSRepository: metabase/metabase-cli Length of output: 11029 Parse dependency payloads with named schemas.
🤖 Prompt for AI AgentsSource: 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/domainRepository: 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 || trueRepository: metabase/metabase-cli Length of output: 6999 Export the required dependency domain compact schemas.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
There was a problem hiding this comment.
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
Source: Coding guidelines