@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,257 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { type DuckDBConnection, DuckDBInstance } from "@duckdb/node-api"
3
+ import type { DatasetColumnDefinition } from "@sixb/core"
4
+ import type {
5
+ AnalyzeSqlTransformSource,
6
+ SqlTransformColumnRef,
7
+ SqlTransformLineage,
8
+ } from "@sixb/core/lake-storage"
9
+ import { datasetColumnToDuckDbSql } from "../schema"
10
+ import { quoteIdentifier, quoteSqlString } from "../sql"
11
+ import {
12
+ type DuckLakeSqlTransformTemplate,
13
+ pickSourceColumns,
14
+ renderSourceRelation,
15
+ } from "../sql-transform-relations"
16
+ import { parseSerializerOutput } from "./plan-json"
17
+ import { type PlanSourceTables, planLineage } from "./plan-walker"
18
+ import { rejectPlanFormat } from "./rejection"
19
+ import { assertSingleSelect, readsOnlySources } from "./statement-gate"
20
+
21
+ interface AnalyzeSqlTransformLineageInput {
22
+ readonly sources: Readonly<Record<string, AnalyzeSqlTransformSource>>
23
+ readonly template: DuckLakeSqlTransformTemplate
24
+ }
25
+
26
+ /**
27
+ * `SqlTransformAnalysis` with the result's column names in order, which the lineage record cannot
28
+ * carry: object keys that look like integers enumerate first.
29
+ */
30
+ export type SqlLineageAnalysis =
31
+ | {
32
+ readonly supported: true
33
+ readonly lineage: SqlTransformLineage
34
+ readonly outputColumns: readonly string[]
35
+ }
36
+ | { readonly supported: false; readonly reason: string; readonly readsOnlySources: boolean }
37
+
38
+ interface AnalyzedSql {
39
+ readonly lineage: SqlTransformLineage
40
+ readonly outputColumns: readonly string[]
41
+ }
42
+
43
+ /** An empty table standing in for one source, under a name the transform cannot guess. */
44
+ interface AnalysisTable {
45
+ readonly sourceName: string
46
+ readonly tableName: string
47
+ readonly columns: readonly DatasetColumnDefinition[]
48
+ readonly relationSql: string
49
+ }
50
+
51
+ /**
52
+ * No file, network, extension, or setting is reachable from the analysis instance; the transform
53
+ * only binds against its sources' empty tables.
54
+ */
55
+ const ISOLATED_INSTANCE_CONFIG = {
56
+ enable_external_access: "false",
57
+ autoinstall_known_extensions: "false",
58
+ autoload_known_extensions: "false",
59
+ }
60
+
61
+ /**
62
+ * Column lineage of a SQL transform, derived without reading any data.
63
+ *
64
+ * A fresh in-memory DuckDB instance creates one empty table per source from its column schema,
65
+ * the transform renders against those tables in the same relation shape it runs with on the lake,
66
+ * and DuckDB binds it. The bound, unoptimized plan is then walked operator by operator. Whatever
67
+ * the walker does not understand exactly is rejected rather than guessed; a rejection or a bind
68
+ * error is an unsupported analysis, while an invalid source pick throws. An unsupported analysis
69
+ * of SQL that parsed still says whether its parse tree reads anything but the sources.
70
+ */
71
+ export async function analyzeSqlTransformLineage(
72
+ input: AnalyzeSqlTransformLineageInput
73
+ ): Promise<SqlLineageAnalysis> {
74
+ const tables = analysisTables(input.sources)
75
+ const sql = input.template.render(
76
+ new Map(tables.map((table) => [table.sourceName, table.relationSql]))
77
+ )
78
+
79
+ try {
80
+ return await withAnalysisConnection(tables, async (connection) => {
81
+ const statements = await serialize(
82
+ connection,
83
+ `json_serialize_sql(${quoteSqlString(sql)}, skip_null := false, skip_empty := false)`,
84
+ "statements",
85
+ "DuckDB could not parse the transform"
86
+ )
87
+
88
+ try {
89
+ return { supported: true, ...(await analyzeSql(connection, sql, statements, tables)) }
90
+ } catch (error) {
91
+ const sourceTables = new Set(tables.map((table) => table.tableName))
92
+ const functions = await builtinFunctions(connection).catch(() => undefined)
93
+ return unsupported(
94
+ error,
95
+ functions !== undefined && readsOnlySources(statements, sourceTables, functions)
96
+ )
97
+ }
98
+ })
99
+ } catch (error) {
100
+ return unsupported(error, false)
101
+ }
102
+ }
103
+
104
+ /** The functions DuckDB defines, the same in every fresh instance: read from the first one. */
105
+ let builtins: Promise<ReadonlySet<string>> | undefined
106
+
107
+ function builtinFunctions(connection: DuckDBConnection): Promise<ReadonlySet<string>> {
108
+ if (builtins) return builtins
109
+
110
+ const read = connection
111
+ .runAndReadAll("SELECT DISTINCT lower(function_name) FROM duckdb_functions()")
112
+ .then((reader) => new Set(reader.getRows().map(([name]) => String(name))))
113
+
114
+ // A failed read is not kept: the next analysis reads again.
115
+ builtins = read
116
+ read.catch(() => {
117
+ if (builtins === read) builtins = undefined
118
+ })
119
+ return read
120
+ }
121
+
122
+ function unsupported(error: unknown, readsOnlySources: boolean): SqlLineageAnalysis {
123
+ return {
124
+ supported: false,
125
+ reason: error instanceof Error ? error.message : String(error),
126
+ readsOnlySources,
127
+ }
128
+ }
129
+
130
+ function analysisTables(
131
+ sources: Readonly<Record<string, AnalyzeSqlTransformSource>>
132
+ ): readonly AnalysisTable[] {
133
+ const token = randomUUID().replaceAll("-", "")
134
+
135
+ return Object.entries(sources).map(([sourceName, source], index) => {
136
+ const tableName = `sixb_lineage_${token}_${index}`
137
+ return {
138
+ sourceName,
139
+ tableName,
140
+ columns: pickSourceColumns(sourceName, source.dataset, source.columns),
141
+ relationSql: renderSourceRelation(quoteIdentifier(tableName), source.columns),
142
+ }
143
+ })
144
+ }
145
+
146
+ async function withAnalysisConnection<T>(
147
+ tables: readonly AnalysisTable[],
148
+ use: (connection: DuckDBConnection) => Promise<T>
149
+ ): Promise<T> {
150
+ const instance = await DuckDBInstance.create(":memory:", ISOLATED_INSTANCE_CONFIG)
151
+ try {
152
+ const connection = await instance.connect()
153
+ try {
154
+ for (const table of tables) {
155
+ const columnsSql = table.columns.map((column) => datasetColumnToDuckDbSql(column))
156
+ await connection.run(
157
+ `CREATE TABLE ${quoteIdentifier(table.tableName)} (${columnsSql.join(", ")})`
158
+ )
159
+ }
160
+
161
+ // The transform cannot loosen the restrictions above through SET or a setting function.
162
+ await connection.run("SET lock_configuration = true")
163
+ return await use(connection)
164
+ } finally {
165
+ connection.closeSync()
166
+ }
167
+ } finally {
168
+ instance.closeSync()
169
+ }
170
+ }
171
+
172
+ async function analyzeSql(
173
+ connection: DuckDBConnection,
174
+ sql: string,
175
+ statements: readonly unknown[],
176
+ tables: readonly AnalysisTable[]
177
+ ): Promise<AnalyzedSql> {
178
+ assertSingleSelect(statements)
179
+
180
+ const plans = await serialize(
181
+ connection,
182
+ `json_serialize_plan(${quoteSqlString(sql)}, skip_null := false, skip_empty := false, optimize := false)`,
183
+ "plans",
184
+ "DuckDB could not bind the transform"
185
+ )
186
+ if (plans.length !== 1) {
187
+ rejectPlanFormat(`expected one plan, got ${plans.length}`)
188
+ }
189
+
190
+ const sourceColumns = numberSourceColumns(tables)
191
+ const lineage = planLineage(plans[0], sourceColumns.ids)
192
+ const names = await outputColumnNames(connection, sql)
193
+ if (lineage.columns.length !== names.length) {
194
+ rejectPlanFormat(`the plan has ${lineage.columns.length} columns, the result ${names.length}`)
195
+ }
196
+
197
+ const refs = (ids: readonly number[]): SqlTransformColumnRef[] =>
198
+ ids.map((id) => sourceColumns.refs[id])
199
+
200
+ return {
201
+ lineage: {
202
+ rows: refs(lineage.rows),
203
+ columns: Object.fromEntries(names.map((name, index) => [name, refs(lineage.columns[index])])),
204
+ },
205
+ outputColumns: names,
206
+ }
207
+ }
208
+
209
+ /** Numbers every column of every table, in source then column order, for the plan walker. */
210
+ function numberSourceColumns(tables: readonly AnalysisTable[]): {
211
+ readonly ids: PlanSourceTables
212
+ readonly refs: readonly SqlTransformColumnRef[]
213
+ } {
214
+ const refs: SqlTransformColumnRef[] = []
215
+ const ids = new Map(
216
+ tables.map((table) => {
217
+ const columnIds = table.columns.map((column) => {
218
+ refs.push({ source: table.sourceName, column: column.name })
219
+ return [column.name, refs.length - 1] as const
220
+ })
221
+ return [table.tableName, new Map(columnIds)] as const
222
+ })
223
+ )
224
+
225
+ return { ids, refs }
226
+ }
227
+
228
+ /**
229
+ * Names as the executor materializes them: selected through a subquery, where DuckDB renames
230
+ * duplicate output names (`id`, `id_1`).
231
+ */
232
+ async function outputColumnNames(
233
+ connection: DuckDBConnection,
234
+ sql: string
235
+ ): Promise<readonly string[]> {
236
+ const prepared = await connection.prepare(`SELECT * FROM (${sql}) AS sixb_lineage_result`)
237
+ try {
238
+ return Array.from({ length: prepared.columnCount }, (_, index) => prepared.columnName(index))
239
+ } finally {
240
+ prepared.destroySync()
241
+ }
242
+ }
243
+
244
+ async function serialize(
245
+ connection: DuckDBConnection,
246
+ serializerSql: string,
247
+ listKey: string,
248
+ failure: string
249
+ ): Promise<readonly unknown[]> {
250
+ const reader = await connection.runAndReadAll(`SELECT ${serializerSql}`)
251
+ const text = reader.getRows()[0]?.[0]
252
+ if (typeof text !== "string") {
253
+ rejectPlanFormat("the serializer returned no JSON")
254
+ }
255
+
256
+ return parseSerializerOutput(text, listKey, failure)
257
+ }
@@ -0,0 +1,208 @@
1
+ import {
2
+ asObject,
3
+ booleanField,
4
+ field,
5
+ indexField,
6
+ isJsonObject,
7
+ type JsonObject,
8
+ listField,
9
+ nullableObjectField,
10
+ objectField,
11
+ objectListField,
12
+ stringField,
13
+ } from "./plan-json"
14
+ import { addAll, bindingKey, itemAt, type PlanColumn } from "./plan-relation"
15
+ import { reject, rejectPlanFormat } from "./rejection"
16
+
17
+ /** Source columns whose values flow into an expression evaluated over an operator's `input`. */
18
+ export function expressionDependencies(
19
+ expression: unknown,
20
+ input: readonly PlanColumn[]
21
+ ): Set<number> {
22
+ const dependencies = new Set<number>()
23
+ addExpression(dependencies, expression, input)
24
+ return dependencies
25
+ }
26
+
27
+ /** The sort expressions of an ORDER BY list (`{ type, null_order, expression }` entries). */
28
+ export function orderExpressions(orders: readonly unknown[]): readonly unknown[] {
29
+ return orders.map((order) => field(asObject(order, "sort order"), "expression"))
30
+ }
31
+
32
+ /** `{ orders: [...] }` or null, as aggregates and DISTINCT ON write their ORDER BY. */
33
+ export function nestedOrderExpressions(object: JsonObject, key: string): readonly unknown[] {
34
+ const orderBy = nullableObjectField(object, key)
35
+ return orderBy === null ? [] : orderExpressions(listField(orderBy, "orders"))
36
+ }
37
+
38
+ function addExpression(target: Set<number>, value: unknown, input: readonly PlanColumn[]): void {
39
+ const expression = asObject(value, "expression")
40
+ const expressionClass = stringField(expression, "expression_class")
41
+
42
+ switch (expressionClass) {
43
+ case "BOUND_REF":
44
+ // Positional: the index-th column of the operator's input.
45
+ addAll(target, itemAt(input, indexField(expression, "index"), "column").dependencies)
46
+ return
47
+
48
+ case "BOUND_COLUMN_REF":
49
+ addAll(target, columnByBinding(expression, input).dependencies)
50
+ return
51
+
52
+ case "BOUND_CONSTANT":
53
+ return
54
+
55
+ case "BOUND_CAST":
56
+ case "BOUND_UNNEST":
57
+ addExpression(target, field(expression, "child"), input)
58
+ return
59
+
60
+ case "BOUND_FUNCTION":
61
+ // A lambda body lives in the bind data with its own column numbering, and every column it
62
+ // captures is also passed as an extra child.
63
+ assertNoHiddenExpressions(expression, "lambda_expr")
64
+ addExpressions(target, listField(expression, "children"), input)
65
+ return
66
+
67
+ case "BOUND_AGGREGATE":
68
+ // FILTER and ORDER BY shape the aggregated value, as a CASE inside the aggregate would.
69
+ assertNoHiddenExpressions(expression)
70
+ addExpressions(target, listField(expression, "children"), input)
71
+ addOptionalExpression(target, field(expression, "filter"), input)
72
+ addExpressions(target, nestedOrderExpressions(expression, "order_bys"), input)
73
+ return
74
+
75
+ case "BOUND_WINDOW":
76
+ addWindowExpression(target, expression, input)
77
+ return
78
+
79
+ case "BOUND_COMPARISON":
80
+ addExpression(target, field(expression, "left"), input)
81
+ addExpression(target, field(expression, "right"), input)
82
+ return
83
+
84
+ case "BOUND_BETWEEN":
85
+ addExpression(target, field(expression, "input"), input)
86
+ addExpression(target, field(expression, "lower"), input)
87
+ addExpression(target, field(expression, "upper"), input)
88
+ return
89
+
90
+ case "BOUND_CONJUNCTION":
91
+ case "BOUND_OPERATOR":
92
+ addExpressions(target, listField(expression, "children"), input)
93
+ return
94
+
95
+ case "BOUND_CASE":
96
+ for (const check of objectListField(expression, "case_checks")) {
97
+ addExpression(target, field(check, "when_expr"), input)
98
+ addExpression(target, field(check, "then_expr"), input)
99
+ }
100
+
101
+ addExpression(target, field(expression, "else_expr"), input)
102
+ return
103
+
104
+ default:
105
+ // Subqueries are always flattened into joins by the planner; parameters, lambda
106
+ // references outside a lambda body, and anything newer are not understood.
107
+ reject(`expression '${expressionClass}' is not supported`)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * PARTITION BY and ORDER BY are value dependencies of the window column; the window operator also
113
+ * adds them to the row dependencies. FILTER, frame bounds, offsets and defaults shape the value.
114
+ */
115
+ function addWindowExpression(
116
+ target: Set<number>,
117
+ expression: JsonObject,
118
+ input: readonly PlanColumn[]
119
+ ): void {
120
+ // Only window aggregates serialize a function, with its bind data; ranking and value functions
121
+ // such as row_number() or lag() have none.
122
+ if (stringField(expression, "type") === "WINDOW_AGGREGATE") {
123
+ assertNoHiddenExpressions(expression)
124
+ }
125
+
126
+ addExpressions(target, listField(expression, "children"), input)
127
+ addExpressions(target, listField(expression, "partitions"), input)
128
+ addExpressions(target, orderExpressions(listField(expression, "orders")), input)
129
+ addExpressions(target, orderExpressions(listField(expression, "arg_orders")), input)
130
+
131
+ for (const key of ["filters", "start_expr", "end_expr", "offset_expr", "default_expr"]) {
132
+ addOptionalExpression(target, field(expression, key), input)
133
+ }
134
+ }
135
+
136
+ function addExpressions(
137
+ target: Set<number>,
138
+ expressions: readonly unknown[],
139
+ input: readonly PlanColumn[]
140
+ ): void {
141
+ for (const expression of expressions) {
142
+ addExpression(target, expression, input)
143
+ }
144
+ }
145
+
146
+ function addOptionalExpression(
147
+ target: Set<number>,
148
+ expression: unknown,
149
+ input: readonly PlanColumn[]
150
+ ): void {
151
+ if (expression !== null) {
152
+ addExpression(target, expression, input)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * DuckDB leaves the subtree under a table function's input unresolved: columns are referenced by
158
+ * their plan binding instead of their position.
159
+ */
160
+ function columnByBinding(expression: JsonObject, input: readonly PlanColumn[]): PlanColumn {
161
+ if (indexField(expression, "depth") !== 0) {
162
+ reject("correlated column references that the planner did not flatten are not supported")
163
+ }
164
+
165
+ const binding = objectField(expression, "binding")
166
+ const key = bindingKey(indexField(binding, "table_index"), indexField(binding, "column_index"))
167
+ const column = input.find((candidate) => candidate.binding === key)
168
+ if (column === undefined) {
169
+ rejectPlanFormat(`column binding ${key} does not resolve`)
170
+ }
171
+
172
+ return column
173
+ }
174
+
175
+ /**
176
+ * Function bind data is opaque to the analysis. It may hold the lambda body of a list function,
177
+ * whose captures are also children; any other expression in it would be a hidden dependency.
178
+ */
179
+ function assertNoHiddenExpressions(expression: JsonObject, allowedKey?: string): void {
180
+ // Functions without a serializer rebind from their children: there is no bind data to read.
181
+ if (!booleanField(expression, "has_serialize")) {
182
+ return
183
+ }
184
+
185
+ const bindData = field(expression, "function_data")
186
+ const inspected =
187
+ isJsonObject(bindData) && allowedKey !== undefined
188
+ ? Object.entries(bindData).flatMap(([key, value]) => (key === allowedKey ? [] : [value]))
189
+ : [bindData]
190
+
191
+ if (inspected.some(containsExpression)) {
192
+ reject(
193
+ `function '${stringField(expression, "name")}' keeps expressions in its bind data, which is not supported`
194
+ )
195
+ }
196
+ }
197
+
198
+ function containsExpression(value: unknown): boolean {
199
+ if (Array.isArray(value)) {
200
+ return value.some((item) => containsExpression(item))
201
+ }
202
+
203
+ if (!isJsonObject(value)) {
204
+ return false
205
+ }
206
+
207
+ return Object.hasOwn(value, "expression_class") || Object.values(value).some(containsExpression)
208
+ }
@@ -0,0 +1,130 @@
1
+ import { reject, rejectPlanFormat } from "./rejection"
2
+
3
+ /**
4
+ * Strict readers for DuckDB's JSON serializers (`json_serialize_sql`, `json_serialize_plan`).
5
+ *
6
+ * The format is not a stable DuckDB API. Every field the analysis reads must be present with the
7
+ * expected shape, so a renamed or retyped field in a future release rejects the transform instead
8
+ * of silently dropping a dependency.
9
+ */
10
+ export type JsonObject = Readonly<Record<string, unknown>>
11
+
12
+ export function isJsonObject(value: unknown): value is JsonObject {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value)
14
+ }
15
+
16
+ export function asObject(value: unknown, what: string): JsonObject {
17
+ if (!isJsonObject(value)) {
18
+ rejectPlanFormat(`${what} is not an object`)
19
+ }
20
+
21
+ return value
22
+ }
23
+
24
+ /** A field the serializer always writes. It may be null. */
25
+ export function field(object: JsonObject, key: string): unknown {
26
+ if (!Object.hasOwn(object, key)) {
27
+ rejectPlanFormat(`missing field '${key}'`)
28
+ }
29
+
30
+ return object[key]
31
+ }
32
+
33
+ export function objectField(object: JsonObject, key: string): JsonObject {
34
+ return asObject(field(object, key), `field '${key}'`)
35
+ }
36
+
37
+ export function nullableObjectField(object: JsonObject, key: string): JsonObject | null {
38
+ const value = field(object, key)
39
+ return value === null ? null : asObject(value, `field '${key}'`)
40
+ }
41
+
42
+ export function stringField(object: JsonObject, key: string): string {
43
+ const value = field(object, key)
44
+ if (typeof value !== "string") {
45
+ rejectPlanFormat(`field '${key}' is not a string`)
46
+ }
47
+
48
+ return value
49
+ }
50
+
51
+ export function booleanField(object: JsonObject, key: string): boolean {
52
+ const value = field(object, key)
53
+ if (typeof value !== "boolean") {
54
+ rejectPlanFormat(`field '${key}' is not a boolean`)
55
+ }
56
+
57
+ return value
58
+ }
59
+
60
+ /**
61
+ * Indexes and table indexes. DuckDB writes 2^64-1 as an "unset" sentinel, which `JSON.parse`
62
+ * rounds; it stays an integer far past any column count, which is all the analysis compares.
63
+ */
64
+ export function indexField(object: JsonObject, key: string): number {
65
+ return asIndex(field(object, key), `field '${key}'`)
66
+ }
67
+
68
+ function asIndex(value: unknown, what: string): number {
69
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
70
+ rejectPlanFormat(`${what} is not an index`)
71
+ }
72
+
73
+ return value
74
+ }
75
+
76
+ /** A list field. The serializer writes an absent list as null or as an empty array. */
77
+ export function listField(object: JsonObject, key: string): readonly unknown[] {
78
+ return asList(field(object, key), `field '${key}'`)
79
+ }
80
+
81
+ export function asList(value: unknown, what: string): readonly unknown[] {
82
+ if (value === null) {
83
+ return []
84
+ }
85
+
86
+ if (!Array.isArray(value)) {
87
+ rejectPlanFormat(`${what} is not a list`)
88
+ }
89
+
90
+ return value
91
+ }
92
+
93
+ export function objectListField(object: JsonObject, key: string): readonly JsonObject[] {
94
+ return listField(object, key).map((item) => asObject(item, `an item of '${key}'`))
95
+ }
96
+
97
+ export function indexListField(object: JsonObject, key: string): readonly number[] {
98
+ return asIndexList(field(object, key), `field '${key}'`)
99
+ }
100
+
101
+ export function asIndexList(value: unknown, what: string): readonly number[] {
102
+ return asList(value, what).map((item) => asIndex(item, `an item of ${what}`))
103
+ }
104
+
105
+ export function stringListField(object: JsonObject, key: string): readonly string[] {
106
+ return listField(object, key).map((item) => {
107
+ if (typeof item !== "string") {
108
+ rejectPlanFormat(`an item of '${key}' is not a string`)
109
+ }
110
+
111
+ return item
112
+ })
113
+ }
114
+
115
+ /**
116
+ * Both serializers answer `{ "error": false, <listKey>: [...] }`, or `{ "error": true,
117
+ * "error_message": ... }` when DuckDB could not parse or bind the query.
118
+ */
119
+ export function parseSerializerOutput(
120
+ text: string,
121
+ listKey: string,
122
+ failure: string
123
+ ): readonly unknown[] {
124
+ const output = asObject(JSON.parse(text), "serializer output")
125
+ if (booleanField(output, "error")) {
126
+ reject(`${failure}: ${stringField(output, "error_message")}`)
127
+ }
128
+
129
+ return listField(output, listKey)
130
+ }
@@ -0,0 +1,59 @@
1
+ import { rejectPlanFormat } from "./rejection"
2
+
3
+ /** Source columns, as ids assigned by the analysis session, that something depends on. */
4
+ export type Dependencies = ReadonlySet<number>
5
+
6
+ /**
7
+ * A planner-internal column past the end of a table's columns: `rowid`, or the placeholder a
8
+ * `count(*)` scan reads. Scanning one is allowed; depending on one rejects the transform.
9
+ */
10
+ export const VIRTUAL_COLUMN = -1
11
+
12
+ /** A column flowing out of a plan operator. */
13
+ export interface PlanColumn {
14
+ readonly dependencies: Dependencies
15
+ /**
16
+ * `table_index.column_index` in the plan. Most expressions reference their input by position
17
+ * (BOUND_REF); the subtree under a table function's input keeps binding references instead.
18
+ */
19
+ readonly binding?: string
20
+ }
21
+
22
+ /** The output of a plan operator: its columns, and what decides which rows exist and their order. */
23
+ export interface PlanRelation {
24
+ readonly columns: readonly PlanColumn[]
25
+ readonly rows: Dependencies
26
+ }
27
+
28
+ export function bindingKey(tableIndex: number, columnIndex: number): string {
29
+ return `${tableIndex}.${columnIndex}`
30
+ }
31
+
32
+ /** Positional access into an operator's columns or index lists. */
33
+ export function itemAt<T>(items: readonly T[], index: number, what: string): T {
34
+ const item = items[index]
35
+ if (item === undefined) {
36
+ rejectPlanFormat(`${what} ${index} is out of range`)
37
+ }
38
+
39
+ return item
40
+ }
41
+
42
+ export function unionOf(...sets: readonly Dependencies[]): Set<number> {
43
+ const union = new Set<number>()
44
+ for (const set of sets) {
45
+ addAll(union, set)
46
+ }
47
+
48
+ return union
49
+ }
50
+
51
+ export function addAll(target: Set<number>, source: Iterable<number>): void {
52
+ for (const item of source) {
53
+ target.add(item)
54
+ }
55
+ }
56
+
57
+ export function sameDependencies(left: Dependencies, right: Dependencies): boolean {
58
+ return left.size === right.size && [...left].every((item) => right.has(item))
59
+ }