@sixb/ducklake 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +60 -5
  2. package/dist/ducklake-storage.d.ts +3 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1963 -480
  5. package/dist/index.js.map +25 -15
  6. package/dist/internal/dataset-row-commit.d.ts +10 -0
  7. package/dist/internal/dataset-row-commit.d.ts.map +1 -1
  8. package/dist/internal/dataset-row-merge.d.ts.map +1 -1
  9. package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
  10. package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
  11. package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
  12. package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
  13. package/dist/internal/ducklake-maintenance.d.ts +21 -1
  14. package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
  15. package/dist/internal/ducklake-snapshot-reader.d.ts +81 -9
  16. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-sql-executor.d.ts +9 -1
  18. package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
  19. package/dist/internal/ducklake-version-transaction.d.ts +54 -0
  20. package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
  21. package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
  22. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  23. package/dist/internal/lineage-encoding.d.ts +8 -0
  24. package/dist/internal/lineage-encoding.d.ts.map +1 -0
  25. package/dist/internal/sql-lineage/analysis.d.ts +32 -0
  26. package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
  27. package/dist/internal/sql-lineage/expressions.d.ts +9 -0
  28. package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
  29. package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
  30. package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
  31. package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
  32. package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
  33. package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
  34. package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
  35. package/dist/internal/sql-lineage/rejection.d.ts +11 -0
  36. package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
  37. package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
  38. package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
  39. package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
  40. package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
  41. package/dist/internal/sql-transform-relations.d.ts +20 -5
  42. package/dist/internal/sql-transform-relations.d.ts.map +1 -1
  43. package/dist/internal/sql.d.ts +11 -2
  44. package/dist/internal/sql.d.ts.map +1 -1
  45. package/dist/internal/versions.d.ts +10 -1
  46. package/dist/internal/versions.d.ts.map +1 -1
  47. package/dist/types.d.ts +10 -1
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/ducklake-storage.ts +17 -2
  51. package/src/internal/dataset-row-commit.ts +28 -2
  52. package/src/internal/dataset-row-merge.ts +4 -0
  53. package/src/internal/ducklake-dataset-catalog.ts +162 -49
  54. package/src/internal/ducklake-dataset-table-ref.ts +75 -17
  55. package/src/internal/ducklake-maintenance.ts +110 -48
  56. package/src/internal/ducklake-snapshot-reader.ts +549 -254
  57. package/src/internal/ducklake-sql-executor.ts +214 -14
  58. package/src/internal/ducklake-version-transaction.ts +224 -0
  59. package/src/internal/ducklake-write-coordinator.ts +226 -148
  60. package/src/internal/lineage-encoding.ts +136 -0
  61. package/src/internal/sql-lineage/analysis.ts +257 -0
  62. package/src/internal/sql-lineage/expressions.ts +208 -0
  63. package/src/internal/sql-lineage/plan-json.ts +130 -0
  64. package/src/internal/sql-lineage/plan-relation.ts +59 -0
  65. package/src/internal/sql-lineage/plan-walker.ts +727 -0
  66. package/src/internal/sql-lineage/rejection.ts +16 -0
  67. package/src/internal/sql-lineage/statement-gate.ts +164 -0
  68. package/src/internal/sql-lineage/table-functions.ts +11 -0
  69. package/src/internal/sql-transform-relations.ts +90 -40
  70. package/src/internal/sql.ts +35 -6
  71. package/src/internal/versions.ts +37 -14
  72. package/src/types.ts +11 -1
@@ -0,0 +1,727 @@
1
+ import { expressionDependencies, nestedOrderExpressions, orderExpressions } from "./expressions"
2
+ import {
3
+ asIndexList,
4
+ asList,
5
+ asObject,
6
+ booleanField,
7
+ field,
8
+ indexField,
9
+ indexListField,
10
+ type JsonObject,
11
+ listField,
12
+ objectField,
13
+ objectListField,
14
+ stringField,
15
+ stringListField,
16
+ } from "./plan-json"
17
+ import {
18
+ addAll,
19
+ bindingKey,
20
+ type Dependencies,
21
+ itemAt,
22
+ type PlanColumn,
23
+ type PlanRelation,
24
+ sameDependencies,
25
+ unionOf,
26
+ VIRTUAL_COLUMN,
27
+ } from "./plan-relation"
28
+ import { reject, rejectPlanFormat } from "./rejection"
29
+ import { PURE_TABLE_FUNCTIONS } from "./table-functions"
30
+
31
+ /** The tables a plan may scan: physical table name, then column name, to a source column id. */
32
+ export type PlanSourceTables = ReadonlyMap<string, ReadonlyMap<string, number>>
33
+
34
+ /** Source column ids, sorted, for the whole result and for each output column by position. */
35
+ export interface PlanLineage {
36
+ readonly rows: readonly number[]
37
+ readonly columns: readonly (readonly number[])[]
38
+ }
39
+
40
+ interface WalkContext {
41
+ readonly tables: PlanSourceTables
42
+ /** CTE definitions by table index, set where the CTE is declared. */
43
+ readonly ctes: Map<number, PlanRelation>
44
+ /** Duplicate-eliminated columns of the enclosing DELIM_JOINs, innermost last. */
45
+ readonly delimColumns: (readonly PlanColumn[])[]
46
+ }
47
+
48
+ /** Recursive CTE lineage grows monotonically; real queries settle within a few rounds. */
49
+ const MAX_RECURSIVE_CTE_ROUNDS = 64
50
+
51
+ /**
52
+ * Column lineage of one bound, unoptimized logical plan (`json_serialize_plan(sql, optimize :=
53
+ * false)`), walked bottom-up. Each operator yields its output columns with the source columns their
54
+ * values come from, plus the source columns that decide which rows exist or their order. Those row
55
+ * dependencies accumulate upwards and apply to every output column.
56
+ *
57
+ * Expressions mostly reference their operator's input by position, so each operator below states
58
+ * the order of its output columns.
59
+ */
60
+ export function planLineage(plan: unknown, tables: PlanSourceTables): PlanLineage {
61
+ const root = evaluate(asObject(plan, "plan"), { tables, ctes: new Map(), delimColumns: [] })
62
+ const rows = sortedIds(root.rows)
63
+ const columns = root.columns.map((column) => sortedIds(column.dependencies))
64
+
65
+ if ([rows, ...columns].some((ids) => ids.includes(VIRTUAL_COLUMN))) {
66
+ reject("reading virtual columns such as rowid is not supported")
67
+ }
68
+
69
+ return { rows, columns }
70
+ }
71
+
72
+ function evaluate(node: JsonObject, context: WalkContext): PlanRelation {
73
+ const type = stringField(node, "type")
74
+
75
+ switch (type) {
76
+ case "LOGICAL_GET":
77
+ return evaluateGet(node, context)
78
+ case "LOGICAL_DUMMY_SCAN":
79
+ return evaluateDummyScan(node)
80
+ case "LOGICAL_EXPRESSION_GET":
81
+ return evaluateExpressionGet(node, context)
82
+ case "LOGICAL_PROJECTION":
83
+ return evaluateProjection(node, context)
84
+ case "LOGICAL_FILTER":
85
+ return evaluateFilter(node, context)
86
+ case "LOGICAL_AGGREGATE_AND_GROUP_BY":
87
+ return evaluateAggregate(node, context)
88
+ case "LOGICAL_WINDOW":
89
+ return evaluateWindow(node, context)
90
+ case "LOGICAL_UNNEST":
91
+ return evaluateUnnest(node, context)
92
+ case "LOGICAL_ORDER_BY":
93
+ return evaluateOrderBy(node, context)
94
+ case "LOGICAL_LIMIT":
95
+ return evaluateLimit(node, context)
96
+ case "LOGICAL_SAMPLE":
97
+ // A random sample: no column decides which rows survive.
98
+ return evaluateOnlyChild(node, context)
99
+ case "LOGICAL_DISTINCT":
100
+ return evaluateDistinct(node, context)
101
+ case "LOGICAL_CROSS_PRODUCT":
102
+ case "LOGICAL_POSITIONAL_JOIN":
103
+ return evaluateCrossProduct(node, context)
104
+ case "LOGICAL_COMPARISON_JOIN":
105
+ case "LOGICAL_ASOF_JOIN":
106
+ return evaluateComparisonJoin(node, context)
107
+ case "LOGICAL_DELIM_JOIN":
108
+ return evaluateDelimJoin(node, context)
109
+ case "LOGICAL_ANY_JOIN":
110
+ return evaluateAnyJoin(node, context)
111
+ case "LOGICAL_DELIM_GET":
112
+ return evaluateDelimGet(node, context)
113
+ case "LOGICAL_UNION":
114
+ case "LOGICAL_EXCEPT":
115
+ case "LOGICAL_INTERSECT":
116
+ return evaluateSetOperation(node, context)
117
+ case "LOGICAL_MATERIALIZED_CTE":
118
+ return evaluateMaterializedCte(node, context)
119
+ case "LOGICAL_RECURSIVE_CTE":
120
+ return evaluateRecursiveCte(node, context)
121
+ case "LOGICAL_CTE_REF":
122
+ return evaluateCteRef(node, context)
123
+ default:
124
+ reject(`plan node '${type}' is not supported`)
125
+ }
126
+ }
127
+
128
+ interface ScanSource {
129
+ /** Source columns behind the scanned column at a table or function column index. */
130
+ readonly column: (index: number) => Dependencies
131
+ readonly rows: Dependencies
132
+ }
133
+
134
+ /**
135
+ * Output: the scanned columns (column_indexes), narrowed to projection_ids when present, then the
136
+ * input columns an in-out table function passes through (projected_input).
137
+ */
138
+ function evaluateGet(node: JsonObject, context: WalkContext): PlanRelation {
139
+ if (objectListField(objectField(node, "table_filters"), "filters").length > 0) {
140
+ rejectPlanFormat("an unoptimized scan has pushed-down filters")
141
+ }
142
+
143
+ const inputs = objectListField(node, "children")
144
+ if (inputs.length > 1) {
145
+ rejectPlanFormat("a scan has several inputs")
146
+ }
147
+
148
+ const input = inputs[0] === undefined ? undefined : evaluate(inputs[0], context)
149
+ const functionName = stringField(node, "name")
150
+ const source =
151
+ functionName === "seq_scan"
152
+ ? tableScanSource(node, context, input)
153
+ : tableFunctionSource(functionName, input)
154
+
155
+ const scanned = objectListField(node, "column_indexes").map((entry) =>
156
+ source.column(indexField(entry, "index"))
157
+ )
158
+
159
+ const tableIndex = indexField(node, "table_index")
160
+ const projectionIds = indexListField(node, "projection_ids")
161
+ const positions = projectionIds.length > 0 ? projectionIds : scanned.map((_, index) => index)
162
+
163
+ const columns: PlanColumn[] = positions.map((position) => ({
164
+ dependencies: itemAt(scanned, position, "scanned column"),
165
+ binding: bindingKey(tableIndex, position),
166
+ }))
167
+
168
+ for (const position of indexListField(node, "projected_input")) {
169
+ if (input === undefined) {
170
+ rejectPlanFormat("a scan passes through input columns without an input")
171
+ }
172
+
173
+ columns.push(itemAt(input.columns, position, "input column"))
174
+ }
175
+
176
+ return { columns, rows: unionOf(input?.rows ?? new Set(), source.rows) }
177
+ }
178
+
179
+ function tableScanSource(
180
+ node: JsonObject,
181
+ context: WalkContext,
182
+ input: PlanRelation | undefined
183
+ ): ScanSource {
184
+ if (input !== undefined) {
185
+ rejectPlanFormat("a table scan has an input")
186
+ }
187
+
188
+ const table = stringField(objectField(node, "function_data"), "table")
189
+ const columnIds = context.tables.get(table)
190
+ if (columnIds === undefined) {
191
+ reject(`table '${table}' is not a source of the transform`)
192
+ }
193
+
194
+ const names = stringListField(node, "names")
195
+ return {
196
+ rows: new Set(),
197
+ column: (index) => {
198
+ // Indexes past the table's columns are planner-internal virtual columns.
199
+ const name = names[index]
200
+ if (name === undefined) {
201
+ return new Set([VIRTUAL_COLUMN])
202
+ }
203
+
204
+ const id = columnIds.get(name)
205
+ if (id === undefined) {
206
+ rejectPlanFormat(`table '${table}' has no column '${name}'`)
207
+ }
208
+
209
+ return new Set([id])
210
+ },
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Without an input, a table function's arguments are bound constants. With one (LATERAL arguments,
216
+ * or a subquery as an argument), every produced column and the number of rows derive from it.
217
+ */
218
+ function tableFunctionSource(name: string, input: PlanRelation | undefined): ScanSource {
219
+ if (!PURE_TABLE_FUNCTIONS.has(name)) {
220
+ reject(`table function '${name}' is not supported`)
221
+ }
222
+
223
+ const dependencies =
224
+ input === undefined
225
+ ? new Set<number>()
226
+ : unionOf(...input.columns.map((column) => column.dependencies))
227
+
228
+ return { rows: dependencies, column: () => dependencies }
229
+ }
230
+
231
+ /** `SELECT` without FROM: one row of one placeholder column. */
232
+ function evaluateDummyScan(node: JsonObject): PlanRelation {
233
+ assertLeaf(node)
234
+ return {
235
+ columns: [{ dependencies: new Set(), binding: bindingKey(indexField(node, "table_index"), 0) }],
236
+ rows: new Set(),
237
+ }
238
+ }
239
+
240
+ /** VALUES: column i is the union of every row's i-th expression. */
241
+ function evaluateExpressionGet(node: JsonObject, context: WalkContext): PlanRelation {
242
+ const input = evaluateOnlyChild(node, context)
243
+ const width = listField(node, "expr_types").length
244
+ const values = Array.from({ length: width }, () => new Set<number>())
245
+
246
+ for (const row of listField(node, "expressions")) {
247
+ const expressions = asList(row, "a VALUES row")
248
+ if (expressions.length !== width) {
249
+ rejectPlanFormat("a VALUES row does not match the VALUES width")
250
+ }
251
+
252
+ expressions.forEach((expression, index) => {
253
+ addAll(
254
+ itemAt(values, index, "VALUES column"),
255
+ expressionDependencies(expression, input.columns)
256
+ )
257
+ })
258
+ }
259
+
260
+ const tableIndex = indexField(node, "table_index")
261
+ return {
262
+ columns: values.map((dependencies, index) => ({
263
+ dependencies,
264
+ binding: bindingKey(tableIndex, index),
265
+ })),
266
+ rows: input.rows,
267
+ }
268
+ }
269
+
270
+ function evaluateProjection(node: JsonObject, context: WalkContext): PlanRelation {
271
+ const input = evaluateOnlyChild(node, context)
272
+ const tableIndex = indexField(node, "table_index")
273
+
274
+ return {
275
+ columns: listField(node, "expressions").map((expression, index) => ({
276
+ dependencies: expressionDependencies(expression, input.columns),
277
+ binding: bindingKey(tableIndex, index),
278
+ })),
279
+ rows: input.rows,
280
+ }
281
+ }
282
+
283
+ /** Output: the window's input columns, then one column per window expression (window_index). */
284
+ function evaluateWindow(node: JsonObject, context: WalkContext): PlanRelation {
285
+ const input = evaluateOnlyChild(node, context)
286
+ const windowIndex = indexField(node, "window_index")
287
+ const rows = new Set(input.rows)
288
+
289
+ const windows = objectListField(node, "expressions").map((expression, index) => {
290
+ if (stringField(expression, "expression_class") !== "BOUND_WINDOW") {
291
+ rejectPlanFormat("a window operator computes something other than a window function")
292
+ }
293
+
294
+ // Partitions and orders decide which rows each window function compares.
295
+ addAll(rows, dependenciesOf(listField(expression, "partitions"), input.columns))
296
+ addAll(rows, dependenciesOf(orderExpressions(listField(expression, "orders")), input.columns))
297
+
298
+ return {
299
+ dependencies: expressionDependencies(expression, input.columns),
300
+ binding: bindingKey(windowIndex, index),
301
+ }
302
+ })
303
+
304
+ return { columns: [...input.columns, ...windows], rows }
305
+ }
306
+
307
+ function evaluateFilter(node: JsonObject, context: WalkContext): PlanRelation {
308
+ const input = evaluateOnlyChild(node, context)
309
+
310
+ return {
311
+ columns: projectColumns(input.columns, indexListField(node, "projection_map")),
312
+ rows: unionOf(input.rows, dependenciesOf(listField(node, "expressions"), input.columns)),
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Output: the groups (group_index), the aggregates (aggregate_index), then one GROUPING() column
318
+ * per grouping function (groupings_index). HAVING is a filter above this operator.
319
+ */
320
+ function evaluateAggregate(node: JsonObject, context: WalkContext): PlanRelation {
321
+ const input = evaluateOnlyChild(node, context)
322
+ const groups = listField(node, "groups").map((expression) =>
323
+ expressionDependencies(expression, input.columns)
324
+ )
325
+
326
+ const groupIndex = indexField(node, "group_index")
327
+ const aggregateIndex = indexField(node, "aggregate_index")
328
+ const groupingsIndex = indexField(node, "groupings_index")
329
+
330
+ const groupColumns = groups.map((dependencies, index) => ({
331
+ dependencies,
332
+ binding: bindingKey(groupIndex, index),
333
+ }))
334
+
335
+ const aggregateColumns = listField(node, "expressions").map((expression, index) => ({
336
+ dependencies: expressionDependencies(expression, input.columns),
337
+ binding: bindingKey(aggregateIndex, index),
338
+ }))
339
+
340
+ // GROUPING(a, b) tells which of the groups it lists are rolled up in a row.
341
+ const groupingColumns = listField(node, "grouping_functions").map((groupIndexes, index) => ({
342
+ dependencies: unionOf(
343
+ ...asIndexList(groupIndexes, "a grouping function").map((group) =>
344
+ itemAt(groups, group, "group")
345
+ )
346
+ ),
347
+ binding: bindingKey(groupingsIndex, index),
348
+ }))
349
+
350
+ return {
351
+ columns: [...groupColumns, ...aggregateColumns, ...groupingColumns],
352
+ rows: unionOf(input.rows, ...groups),
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Output: the input columns, then one column per UNNEST (unnest_index). Unnesting turns each row
358
+ * into as many rows as its list has items, so the lists are row dependencies too.
359
+ */
360
+ function evaluateUnnest(node: JsonObject, context: WalkContext): PlanRelation {
361
+ const input = evaluateOnlyChild(node, context)
362
+ const unnestIndex = indexField(node, "unnest_index")
363
+ const rows = new Set(input.rows)
364
+
365
+ const unnested = listField(node, "expressions").map((expression, index) => {
366
+ const dependencies = expressionDependencies(expression, input.columns)
367
+ addAll(rows, dependencies)
368
+ return { dependencies, binding: bindingKey(unnestIndex, index) }
369
+ })
370
+
371
+ return { columns: [...input.columns, ...unnested], rows }
372
+ }
373
+
374
+ /** A row dependency even without LIMIT: a materialized dataset keeps its row order. */
375
+ function evaluateOrderBy(node: JsonObject, context: WalkContext): PlanRelation {
376
+ const input = evaluateOnlyChild(node, context)
377
+
378
+ return {
379
+ columns: projectColumns(input.columns, indexListField(node, "projections")),
380
+ rows: unionOf(
381
+ input.rows,
382
+ dependenciesOf(orderExpressions(listField(node, "orders")), input.columns)
383
+ ),
384
+ }
385
+ }
386
+
387
+ function evaluateLimit(node: JsonObject, context: WalkContext): PlanRelation {
388
+ const input = evaluateOnlyChild(node, context)
389
+
390
+ return {
391
+ columns: input.columns,
392
+ rows: unionOf(
393
+ input.rows,
394
+ limitDependencies(objectField(node, "limit_val"), input.columns),
395
+ limitDependencies(objectField(node, "offset_val"), input.columns)
396
+ ),
397
+ }
398
+ }
399
+
400
+ function limitDependencies(limit: JsonObject, input: readonly PlanColumn[]): Dependencies {
401
+ const kind = stringField(limit, "type")
402
+
403
+ switch (kind) {
404
+ case "UNSET":
405
+ case "CONSTANT_VALUE":
406
+ case "CONSTANT_PERCENTAGE":
407
+ return new Set()
408
+ case "EXPRESSION_VALUE":
409
+ case "EXPRESSION_PERCENTAGE":
410
+ return expressionDependencies(field(limit, "expression"), input)
411
+ default:
412
+ reject(`LIMIT kind '${kind}' is not supported`)
413
+ }
414
+ }
415
+
416
+ /** DISTINCT, DISTINCT ON with its ORDER BY, and the deduplication of set operations. */
417
+ function evaluateDistinct(node: JsonObject, context: WalkContext): PlanRelation {
418
+ const input = evaluateOnlyChild(node, context)
419
+
420
+ return {
421
+ columns: input.columns,
422
+ rows: unionOf(
423
+ input.rows,
424
+ dependenciesOf(listField(node, "distinct_targets"), input.columns),
425
+ dependenciesOf(nestedOrderExpressions(node, "order_by"), input.columns)
426
+ ),
427
+ }
428
+ }
429
+
430
+ /** Output: the left columns, then the right columns. */
431
+ function evaluateCrossProduct(node: JsonObject, context: WalkContext): PlanRelation {
432
+ const [left, right] = evaluateTwoChildren(node, context)
433
+ return { columns: [...left.columns, ...right.columns], rows: unionOf(left.rows, right.rows) }
434
+ }
435
+
436
+ function evaluateComparisonJoin(node: JsonObject, context: WalkContext): PlanRelation {
437
+ const [left, right] = evaluateTwoChildren(node, context)
438
+ return comparisonJoinOutput(node, left, right)
439
+ }
440
+
441
+ /**
442
+ * A correlated subquery, flattened. The duplicate-eliminated columns are computed over one side
443
+ * (the left, unless delim_flipped), and every DELIM_GET on the other side reads them.
444
+ */
445
+ function evaluateDelimJoin(node: JsonObject, context: WalkContext): PlanRelation {
446
+ const [first, second] = twoChildren(node)
447
+ const flipped = booleanField(node, "delim_flipped")
448
+ const outer = evaluate(flipped ? second : first, context)
449
+
450
+ context.delimColumns.push(
451
+ listField(node, "duplicate_eliminated_columns").map((expression) => ({
452
+ dependencies: expressionDependencies(expression, outer.columns),
453
+ }))
454
+ )
455
+
456
+ const inner = evaluate(flipped ? first : second, context)
457
+ context.delimColumns.pop()
458
+
459
+ return flipped
460
+ ? comparisonJoinOutput(node, inner, outer)
461
+ : comparisonJoinOutput(node, outer, inner)
462
+ }
463
+
464
+ /** Each side of a condition reads its own input; the residual predicate reads left ++ right. */
465
+ function comparisonJoinOutput(
466
+ node: JsonObject,
467
+ left: PlanRelation,
468
+ right: PlanRelation
469
+ ): PlanRelation {
470
+ const condition = new Set<number>()
471
+
472
+ for (const comparison of objectListField(node, "conditions")) {
473
+ addAll(condition, expressionDependencies(field(comparison, "left"), left.columns))
474
+ addAll(condition, expressionDependencies(field(comparison, "right"), right.columns))
475
+ }
476
+
477
+ const predicate = field(node, "predicate")
478
+ if (predicate !== null) {
479
+ addAll(condition, expressionDependencies(predicate, [...left.columns, ...right.columns]))
480
+ }
481
+
482
+ return joinOutput(node, left, right, condition)
483
+ }
484
+
485
+ /** A join on an arbitrary condition, which reads left ++ right. */
486
+ function evaluateAnyJoin(node: JsonObject, context: WalkContext): PlanRelation {
487
+ const [left, right] = evaluateTwoChildren(node, context)
488
+ const condition = expressionDependencies(field(node, "condition"), [
489
+ ...left.columns,
490
+ ...right.columns,
491
+ ])
492
+
493
+ return joinOutput(node, left, right, condition)
494
+ }
495
+
496
+ /** Which columns a join outputs depends on its type; its condition decides its rows. */
497
+ function joinOutput(
498
+ node: JsonObject,
499
+ left: PlanRelation,
500
+ right: PlanRelation,
501
+ condition: Dependencies
502
+ ): PlanRelation {
503
+ const rows = unionOf(left.rows, right.rows, condition)
504
+ const leftColumns = projectColumns(left.columns, indexListField(node, "left_projection_map"))
505
+ const rightColumns = projectColumns(right.columns, indexListField(node, "right_projection_map"))
506
+ const joinType = stringField(node, "join_type")
507
+
508
+ switch (joinType) {
509
+ case "INNER":
510
+ case "LEFT":
511
+ case "RIGHT":
512
+ case "FULL":
513
+ case "SINGLE": // a correlated scalar subquery
514
+ return { columns: [...leftColumns, ...rightColumns], rows }
515
+ case "SEMI":
516
+ case "ANTI":
517
+ return { columns: leftColumns, rows }
518
+ case "RIGHT_SEMI":
519
+ case "RIGHT_ANTI":
520
+ return { columns: rightColumns, rows }
521
+ case "MARK": {
522
+ // EXISTS, IN or ANY used as a value: a boolean column (mark_index) telling whether the
523
+ // condition matched.
524
+ const mark = {
525
+ dependencies: condition,
526
+ binding: bindingKey(indexField(node, "mark_index"), 0),
527
+ }
528
+ return { columns: [...leftColumns, mark], rows }
529
+ }
530
+ default:
531
+ reject(`join type '${joinType}' is not supported`)
532
+ }
533
+ }
534
+
535
+ function evaluateDelimGet(node: JsonObject, context: WalkContext): PlanRelation {
536
+ assertLeaf(node)
537
+ const delimColumns = context.delimColumns.at(-1)
538
+ if (delimColumns === undefined) {
539
+ rejectPlanFormat("a DELIM_GET is outside any DELIM_JOIN")
540
+ }
541
+
542
+ if (listField(node, "chunk_types").length !== delimColumns.length) {
543
+ rejectPlanFormat("a DELIM_GET does not match its DELIM_JOIN")
544
+ }
545
+
546
+ return { columns: rebind(delimColumns, indexField(node, "table_index")), rows: new Set() }
547
+ }
548
+
549
+ /** UNION ALL chains are flattened: a set operation may have more than two inputs. */
550
+ function evaluateSetOperation(node: JsonObject, context: WalkContext): PlanRelation {
551
+ const inputs = objectListField(node, "children").map((child) => evaluate(child, context))
552
+ const width = indexField(node, "column_count")
553
+ if (inputs.length < 2 || inputs.some((input) => input.columns.length !== width)) {
554
+ rejectPlanFormat("set operation inputs do not match")
555
+ }
556
+
557
+ const tableIndex = indexField(node, "table_index")
558
+ const rows = unionOf(...inputs.map((input) => input.rows))
559
+
560
+ if (stringField(node, "type") === "LOGICAL_UNION") {
561
+ const columns = Array.from({ length: width }, (_, index) => ({
562
+ dependencies: unionOf(
563
+ ...inputs.map((input) => itemAt(input.columns, index, "column").dependencies)
564
+ ),
565
+ binding: bindingKey(tableIndex, index),
566
+ }))
567
+
568
+ // Without ALL, deduplication compares every column.
569
+ if (!booleanField(node, "setop_all")) {
570
+ addAll(rows, unionOf(...columns.map((column) => column.dependencies)))
571
+ }
572
+
573
+ return { columns, rows }
574
+ }
575
+
576
+ // EXCEPT and INTERSECT: values come from the first input; whether a row survives depends on
577
+ // every column of every input.
578
+ for (const input of inputs) {
579
+ addAll(rows, unionOf(...input.columns.map((column) => column.dependencies)))
580
+ }
581
+
582
+ return { columns: rebind(itemAt(inputs, 0, "input").columns, tableIndex), rows }
583
+ }
584
+
585
+ /**
586
+ * The first input defines the CTE, the second is the query reading it. The planner emits this node
587
+ * for every CTE, materialized or not, since inlining is an optimizer step. A CTE adds its row
588
+ * dependencies only where a CTE_REF reads it.
589
+ */
590
+ function evaluateMaterializedCte(node: JsonObject, context: WalkContext): PlanRelation {
591
+ const [definition, query] = twoChildren(node)
592
+ context.ctes.set(indexField(node, "table_index"), evaluate(definition, context))
593
+ return evaluate(query, context)
594
+ }
595
+
596
+ /**
597
+ * The first input is the anchor, the second the recursive step, which reads the CTE through CTE_REFs
598
+ * to this table_index. The step is re-evaluated against the accumulated lineage until it is stable.
599
+ */
600
+ function evaluateRecursiveCte(node: JsonObject, context: WalkContext): PlanRelation {
601
+ const [anchorNode, stepNode] = twoChildren(node)
602
+ const tableIndex = indexField(node, "table_index")
603
+ const unionAll = booleanField(node, "union_all")
604
+ const keyTargets = listField(node, "key_targets")
605
+ let current = evaluate(anchorNode, context)
606
+
607
+ for (let round = 0; round < MAX_RECURSIVE_CTE_ROUNDS; round += 1) {
608
+ context.ctes.set(tableIndex, current)
609
+ const step = evaluate(stepNode, context)
610
+ if (step.columns.length !== current.columns.length) {
611
+ rejectPlanFormat("recursive CTE branches have different widths")
612
+ }
613
+
614
+ const columns = current.columns.map((column, index) => ({
615
+ dependencies: unionOf(
616
+ column.dependencies,
617
+ itemAt(step.columns, index, "column").dependencies
618
+ ),
619
+ binding: bindingKey(tableIndex, index),
620
+ }))
621
+
622
+ const rows = unionOf(current.rows, step.rows, dependenciesOf(keyTargets, columns))
623
+
624
+ // Without ALL, deduplication compares every column.
625
+ if (!unionAll) {
626
+ addAll(rows, unionOf(...columns.map((column) => column.dependencies)))
627
+ }
628
+
629
+ const next = { columns, rows }
630
+ if (sameRelation(current, next)) {
631
+ return next
632
+ }
633
+
634
+ current = next
635
+ }
636
+
637
+ reject("recursive CTE lineage did not converge")
638
+ }
639
+
640
+ function evaluateCteRef(node: JsonObject, context: WalkContext): PlanRelation {
641
+ assertLeaf(node)
642
+ const definition = context.ctes.get(indexField(node, "cte_index"))
643
+ if (definition === undefined) {
644
+ rejectPlanFormat("a CTE_REF reads an undefined CTE")
645
+ }
646
+
647
+ if (listField(node, "chunk_types").length !== definition.columns.length) {
648
+ rejectPlanFormat("a CTE_REF does not match its CTE")
649
+ }
650
+
651
+ return {
652
+ columns: rebind(definition.columns, indexField(node, "table_index")),
653
+ rows: definition.rows,
654
+ }
655
+ }
656
+
657
+ function twoChildren(node: JsonObject): readonly [JsonObject, JsonObject] {
658
+ const children = objectListField(node, "children")
659
+ const [first, second] = children
660
+ if (children.length !== 2 || first === undefined || second === undefined) {
661
+ rejectPlanFormat(`'${stringField(node, "type")}' should have two inputs`)
662
+ }
663
+
664
+ return [first, second]
665
+ }
666
+
667
+ function evaluateTwoChildren(
668
+ node: JsonObject,
669
+ context: WalkContext
670
+ ): readonly [PlanRelation, PlanRelation] {
671
+ const [left, right] = twoChildren(node)
672
+ return [evaluate(left, context), evaluate(right, context)]
673
+ }
674
+
675
+ function evaluateOnlyChild(node: JsonObject, context: WalkContext): PlanRelation {
676
+ const children = objectListField(node, "children")
677
+ const [child] = children
678
+ if (children.length !== 1 || child === undefined) {
679
+ rejectPlanFormat(`'${stringField(node, "type")}' should have one input`)
680
+ }
681
+
682
+ return evaluate(child, context)
683
+ }
684
+
685
+ function assertLeaf(node: JsonObject): void {
686
+ if (objectListField(node, "children").length > 0) {
687
+ rejectPlanFormat(`'${stringField(node, "type")}' should have no input`)
688
+ }
689
+ }
690
+
691
+ /** An operator's projection map selects input columns by position; empty keeps them all. */
692
+ function projectColumns(
693
+ columns: readonly PlanColumn[],
694
+ projectionMap: readonly number[]
695
+ ): readonly PlanColumn[] {
696
+ return projectionMap.length === 0
697
+ ? columns
698
+ : projectionMap.map((index) => itemAt(columns, index, "projected column"))
699
+ }
700
+
701
+ /** The same columns, exposed under another operator's table index. */
702
+ function rebind(columns: readonly PlanColumn[], tableIndex: number): readonly PlanColumn[] {
703
+ return columns.map((column, index) => ({
704
+ dependencies: column.dependencies,
705
+ binding: bindingKey(tableIndex, index),
706
+ }))
707
+ }
708
+
709
+ function dependenciesOf(
710
+ expressions: readonly unknown[],
711
+ input: readonly PlanColumn[]
712
+ ): Dependencies {
713
+ return unionOf(...expressions.map((expression) => expressionDependencies(expression, input)))
714
+ }
715
+
716
+ function sameRelation(left: PlanRelation, right: PlanRelation): boolean {
717
+ return (
718
+ sameDependencies(left.rows, right.rows) &&
719
+ left.columns.every((column, index) =>
720
+ sameDependencies(column.dependencies, itemAt(right.columns, index, "column").dependencies)
721
+ )
722
+ )
723
+ }
724
+
725
+ function sortedIds(ids: Dependencies): readonly number[] {
726
+ return [...ids].sort((left, right) => left - right)
727
+ }