@sixb/ducklake 0.1.8 → 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.
- package/README.md +59 -4
- package/dist/ducklake-storage.d.ts +1 -0
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +1817 -511
- package/dist/index.js.map +24 -14
- package/dist/internal/dataset-row-commit.d.ts +10 -0
- package/dist/internal/dataset-row-commit.d.ts.map +1 -1
- package/dist/internal/dataset-row-merge.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
- package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
- package/dist/internal/ducklake-maintenance.d.ts +21 -1
- package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
- package/dist/internal/ducklake-snapshot-reader.d.ts +63 -16
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-sql-executor.d.ts +9 -1
- package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
- package/dist/internal/ducklake-version-transaction.d.ts +54 -0
- package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
- package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
- package/dist/internal/lineage-encoding.d.ts +8 -0
- package/dist/internal/lineage-encoding.d.ts.map +1 -0
- package/dist/internal/sql-lineage/analysis.d.ts +32 -0
- package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
- package/dist/internal/sql-lineage/expressions.d.ts +9 -0
- package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
- package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
- package/dist/internal/sql-lineage/rejection.d.ts +11 -0
- package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
- package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
- package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
- package/dist/internal/sql-transform-relations.d.ts +20 -5
- package/dist/internal/sql-transform-relations.d.ts.map +1 -1
- package/dist/internal/versions.d.ts +10 -1
- package/dist/internal/versions.d.ts.map +1 -1
- package/dist/types.d.ts +10 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ducklake-storage.ts +9 -2
- package/src/internal/dataset-row-commit.ts +28 -2
- package/src/internal/dataset-row-merge.ts +4 -0
- package/src/internal/ducklake-dataset-catalog.ts +162 -49
- package/src/internal/ducklake-dataset-table-ref.ts +75 -17
- package/src/internal/ducklake-maintenance.ts +110 -48
- package/src/internal/ducklake-snapshot-reader.ts +352 -304
- package/src/internal/ducklake-sql-executor.ts +214 -14
- package/src/internal/ducklake-version-transaction.ts +224 -0
- package/src/internal/ducklake-write-coordinator.ts +226 -148
- package/src/internal/lineage-encoding.ts +136 -0
- package/src/internal/sql-lineage/analysis.ts +257 -0
- package/src/internal/sql-lineage/expressions.ts +208 -0
- package/src/internal/sql-lineage/plan-json.ts +130 -0
- package/src/internal/sql-lineage/plan-relation.ts +59 -0
- package/src/internal/sql-lineage/plan-walker.ts +727 -0
- package/src/internal/sql-lineage/rejection.ts +16 -0
- package/src/internal/sql-lineage/statement-gate.ts +164 -0
- package/src/internal/sql-lineage/table-functions.ts +11 -0
- package/src/internal/sql-transform-relations.ts +90 -40
- package/src/internal/versions.ts +37 -14
- 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
|
+
}
|