mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
The API surface that draws a column trace moves to a follow-up PR, on
`dbt-column-lineage-surface`. It kept generating findings — a client cache
whose premise was wrong for a two-owner relation, then staleness and a lost
retry from tightening it, and a seed walk that stopped at the first boundary —
and the fix for the last of them is a transitive owner expansion, which has to
re-apply the caller's gate to every newly discovered project. That is the same
shape as the leak four reviewers caught in the pinned arm, and it wants its own
review rather than being the fourth fix at the end of this one.
What lands here stands on its own: the analysis pass, `dbt_column_edge`,
`dbt_node.column_schema`, the engine gating and the error-contract split — plus
the one user-visible half, the typed and ordered column list, which rides the
asset graph the details pane already fetches and replaces a panel that could
only show the columns an author had documented.
Also fixes a real bug in the pass, found in review: it compiled without the
build's `--full-refresh`. `is_incremental()` branches on that flag, so an
incremental model reading `{{ this }}` compiles its self-join — and any `ref()`
inside that branch — only when the flag is absent, and the pass was storing
lineage for SQL a full-refresh run never executed. The flag now comes from one
place shared with the build, and a run that overrides it gets its own graph
rather than standing as the version's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
5.7 KiB
TypeScript
163 lines
5.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AssetGraphResponse } from './types'
|
|
import {
|
|
buildColumnGraph,
|
|
colNodeId,
|
|
traceColumn,
|
|
connectedComponent,
|
|
assetColumnNodes,
|
|
computeDepths
|
|
} from './columnLineageGraph'
|
|
|
|
// Two scripts chained through an intermediate ducklake table:
|
|
// s1: orders.amount -> staging.amt
|
|
// s2: staging.amt -> daily.total
|
|
// s2: customers.name -> daily.cust (a second source into the sink)
|
|
function chainGraph(): AssetGraphResponse {
|
|
return {
|
|
assets: [],
|
|
triggers: [],
|
|
runnables: [
|
|
{
|
|
path: 's1',
|
|
usage_kind: 'script',
|
|
column_lineage: [
|
|
{
|
|
column: 'amt',
|
|
inputs: [{ from_kind: 'ducklake', from_path: 'wh/orders', from_column: 'amount' }]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
path: 's2',
|
|
usage_kind: 'script',
|
|
column_lineage: [
|
|
{
|
|
column: 'total',
|
|
inputs: [{ from_kind: 'ducklake', from_path: 'wh/staging', from_column: 'amt' }]
|
|
},
|
|
{
|
|
column: 'cust',
|
|
inputs: [{ from_kind: 'ducklake', from_path: 'wh/customers', from_column: 'name' }]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
edges: [
|
|
{
|
|
runnable_path: 's1',
|
|
runnable_kind: 'script',
|
|
asset_kind: 'ducklake',
|
|
asset_path: 'wh/staging',
|
|
access_type: 'w'
|
|
},
|
|
{
|
|
runnable_path: 's2',
|
|
runnable_kind: 'script',
|
|
asset_kind: 'ducklake',
|
|
asset_path: 'wh/daily',
|
|
access_type: 'w'
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
const ORDERS_AMOUNT = colNodeId('ducklake', 'wh/orders', 'amount')
|
|
const STAGING_AMT = colNodeId('ducklake', 'wh/staging', 'amt')
|
|
const DAILY_TOTAL = colNodeId('ducklake', 'wh/daily', 'total')
|
|
const DAILY_CUST = colNodeId('ducklake', 'wh/daily', 'cust')
|
|
const CUSTOMERS_NAME = colNodeId('ducklake', 'wh/customers', 'name')
|
|
|
|
describe('buildColumnGraph', () => {
|
|
it('stitches per-script lineage into a transitive graph via shared columns', () => {
|
|
const g = buildColumnGraph(chainGraph())
|
|
// orders.amount feeds staging.amt feeds daily.total
|
|
expect(g.up.get(STAGING_AMT)).toEqual(new Set([ORDERS_AMOUNT]))
|
|
expect(g.up.get(DAILY_TOTAL)).toEqual(new Set([STAGING_AMT]))
|
|
expect(g.down.get(ORDERS_AMOUNT)).toEqual(new Set([STAGING_AMT]))
|
|
expect(g.down.get(STAGING_AMT)).toEqual(new Set([DAILY_TOTAL]))
|
|
})
|
|
|
|
it('anchors to the // materialize target, not a guessed write-edge', () => {
|
|
const graph = chainGraph()
|
|
// s1 declares its materialize target and also has unordered extra
|
|
// ducklake writes; the lineage must anchor to the declared target.
|
|
const s1 = graph.runnables.find((r) => r.path === 's1')!
|
|
s1.materialize_target = { kind: 'ducklake', path: 'wh/staging' }
|
|
graph.edges.unshift({
|
|
runnable_path: 's1',
|
|
runnable_kind: 'script',
|
|
asset_kind: 'ducklake',
|
|
asset_path: 'wh/other',
|
|
access_type: 'w'
|
|
})
|
|
const g = buildColumnGraph(graph)
|
|
expect(g.nodes.has(STAGING_AMT)).toBe(true) // anchored to the declared target
|
|
expect(g.nodes.has(colNodeId('ducklake', 'wh/other', 'amt'))).toBe(false)
|
|
})
|
|
|
|
it('falls back to a ducklake write-edge when there is no materialize target', () => {
|
|
// chainGraph's runnables carry no materialize_target, so s1's lineage is
|
|
// anchored via its (single) ducklake write-edge.
|
|
const g = buildColumnGraph(chainGraph())
|
|
expect(g.nodes.has(STAGING_AMT)).toBe(true)
|
|
})
|
|
|
|
it('node ids are collision-proof across `#` / `:` in paths and columns', () => {
|
|
// A delimiter-concatenated id would merge these; the JSON-encoded id must not.
|
|
expect(colNodeId('ducklake', 'a#b', 'c')).not.toBe(colNodeId('ducklake', 'a', 'b#c'))
|
|
expect(colNodeId('ducklake', 'a:b', 'c')).not.toBe(colNodeId('ducklake', 'a', 'b:c'))
|
|
})
|
|
|
|
it('skips producers with no ducklake output asset (columns unanchorable)', () => {
|
|
const graph = chainGraph()
|
|
graph.edges = graph.edges.filter((e) => e.runnable_path !== 's1') // s1 loses its output edge
|
|
const g = buildColumnGraph(graph)
|
|
// staging.amt is no longer produced as a node by s1...
|
|
expect(g.up.has(STAGING_AMT)).toBe(false)
|
|
// ...but s2 still anchors daily.total ← staging.amt (staging.amt as a source).
|
|
expect(g.up.get(DAILY_TOTAL)).toEqual(new Set([STAGING_AMT]))
|
|
})
|
|
})
|
|
|
|
describe('traceColumn', () => {
|
|
it('returns the full upstream + downstream impact set of a source column', () => {
|
|
const g = buildColumnGraph(chainGraph())
|
|
// from the root source, the whole chain downstream is impacted
|
|
expect(traceColumn(ORDERS_AMOUNT, g)).toEqual(
|
|
new Set([ORDERS_AMOUNT, STAGING_AMT, DAILY_TOTAL])
|
|
)
|
|
})
|
|
|
|
it('traces backward from a sink to every contributing source', () => {
|
|
const g = buildColumnGraph(chainGraph())
|
|
expect(traceColumn(DAILY_TOTAL, g)).toEqual(new Set([DAILY_TOTAL, STAGING_AMT, ORDERS_AMOUNT]))
|
|
// the sibling output `cust` and its source are NOT in total's trace
|
|
expect(traceColumn(DAILY_TOTAL, g).has(DAILY_CUST)).toBe(false)
|
|
expect(traceColumn(DAILY_TOTAL, g).has(CUSTOMERS_NAME)).toBe(false)
|
|
})
|
|
|
|
it('traces an intermediate column both directions', () => {
|
|
const g = buildColumnGraph(chainGraph())
|
|
expect(traceColumn(STAGING_AMT, g)).toEqual(new Set([STAGING_AMT, ORDERS_AMOUNT, DAILY_TOTAL]))
|
|
})
|
|
})
|
|
|
|
describe('connectedComponent + depths', () => {
|
|
it('collects the neighborhood of an asset and lays it out by hop depth', () => {
|
|
const g = buildColumnGraph(chainGraph())
|
|
const seeds = assetColumnNodes(g, 'ducklake', 'wh/daily') // the sink asset
|
|
expect(new Set(seeds)).toEqual(new Set([DAILY_TOTAL, DAILY_CUST]))
|
|
const comp = connectedComponent(seeds, g)
|
|
expect(comp).toEqual(
|
|
new Set([DAILY_TOTAL, DAILY_CUST, STAGING_AMT, ORDERS_AMOUNT, CUSTOMERS_NAME])
|
|
)
|
|
const depths = computeDepths(comp, g)
|
|
expect(depths.get(ORDERS_AMOUNT)).toBe(0)
|
|
expect(depths.get(STAGING_AMT)).toBe(1)
|
|
expect(depths.get(DAILY_TOTAL)).toBe(2)
|
|
expect(depths.get(CUSTOMERS_NAME)).toBe(0)
|
|
expect(depths.get(DAILY_CUST)).toBe(1)
|
|
})
|
|
})
|