squirreling 0.12.9 → 0.12.11
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/package.json +1 -1
- package/src/execute/aggregates.js +40 -8
- package/src/execute/sort.js +114 -85
- package/src/execute/utils.js +13 -0
- package/src/expression/evaluate.js +37 -1
- package/src/expression/strings.js +19 -4
- package/src/parse/primary.js +10 -0
- package/src/plan/columns.js +21 -0
- package/src/plan/plan.js +41 -16
- package/src/plan/types.d.ts +1 -0
- package/src/validation/functions.js +1 -1
- package/src/validation/tables.js +21 -16
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { derivedAlias } from '../expression/alias.js'
|
|
2
2
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
3
3
|
import { executePlan, selectColumnNames } from './execute.js'
|
|
4
|
+
import { sortEntriesByTerms } from './sort.js'
|
|
4
5
|
import { keyify } from './utils.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -50,6 +51,22 @@ function projectAggregateColumns(selectColumns, group, context) {
|
|
|
50
51
|
return { columns, cells }
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Builds the row visible to post-aggregation expressions such as HAVING and
|
|
56
|
+
* grouped ORDER BY: source group columns plus aggregate output aliases.
|
|
57
|
+
*
|
|
58
|
+
* @param {AsyncRow[]} group
|
|
59
|
+
* @param {AsyncRow} aggregateRow
|
|
60
|
+
* @returns {AsyncRow}
|
|
61
|
+
*/
|
|
62
|
+
function aggregateContextRow(group, aggregateRow) {
|
|
63
|
+
const baseRow = group[0] ?? { columns: [], cells: {} }
|
|
64
|
+
return {
|
|
65
|
+
columns: [...baseRow.columns, ...aggregateRow.columns],
|
|
66
|
+
cells: { ...baseRow.cells, ...aggregateRow.cells },
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
/**
|
|
54
71
|
* Executes a hash aggregate operation (GROUP BY)
|
|
55
72
|
*
|
|
@@ -85,27 +102,42 @@ export function executeHashAggregate(plan, context) {
|
|
|
85
102
|
group.push(row)
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
|
|
105
|
+
/** @type {{ row: AsyncRow, group: AsyncRow[], contextRow: AsyncRow }[]} */
|
|
106
|
+
const aggregateRows = []
|
|
107
|
+
|
|
89
108
|
for (const group of groups.values()) {
|
|
90
109
|
const asyncRow = projectAggregateColumns(plan.columns, group, context)
|
|
110
|
+
const contextRow = aggregateContextRow(group, asyncRow)
|
|
91
111
|
|
|
92
112
|
// Apply HAVING filter
|
|
93
113
|
if (plan.having) {
|
|
94
|
-
/** @type {AsyncRow} */
|
|
95
|
-
const havingRow = {
|
|
96
|
-
columns: [...group[0].columns, ...asyncRow.columns],
|
|
97
|
-
cells: { ...group[0].cells, ...asyncRow.cells },
|
|
98
|
-
}
|
|
99
114
|
const passes = await evaluateExpr({
|
|
100
115
|
node: plan.having,
|
|
101
|
-
row:
|
|
116
|
+
row: contextRow,
|
|
102
117
|
rows: group,
|
|
103
118
|
context,
|
|
104
119
|
})
|
|
105
120
|
if (!passes) continue
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
|
|
123
|
+
aggregateRows.push({ row: asyncRow, group, contextRow })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (plan.orderBy?.length) {
|
|
127
|
+
const sortedRows = await sortEntriesByTerms({
|
|
128
|
+
entries: aggregateRows.map((aggregateRow, idx) => ({
|
|
129
|
+
row: aggregateRow.contextRow,
|
|
130
|
+
rows: aggregateRow.group,
|
|
131
|
+
idx,
|
|
132
|
+
})),
|
|
133
|
+
orderBy: plan.orderBy,
|
|
134
|
+
context,
|
|
135
|
+
})
|
|
136
|
+
aggregateRows.splice(0, aggregateRows.length, ...sortedRows.map(({ idx }) => aggregateRows[idx]))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const { row } of aggregateRows) {
|
|
140
|
+
yield row
|
|
109
141
|
}
|
|
110
142
|
},
|
|
111
143
|
}
|
package/src/execute/sort.js
CHANGED
|
@@ -4,12 +4,117 @@ import { executePlan } from './execute.js'
|
|
|
4
4
|
import { compareForTerm } from './utils.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* @import { AsyncRow, ExecuteContext, QueryResults, SqlPrimitive } from '../types.js'
|
|
7
|
+
* @import { AsyncRow, ExecuteContext, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
|
|
8
8
|
* @import { SortNode } from '../plan/types.js'
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const MAX_CHUNK = 256
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{
|
|
15
|
+
* row: AsyncRow,
|
|
16
|
+
* rows?: AsyncRow[],
|
|
17
|
+
* }} SortEntry
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sorts rows by ORDER BY terms while evaluating async sort keys in concurrent
|
|
22
|
+
* chunks and delaying later terms until earlier terms tie.
|
|
23
|
+
*
|
|
24
|
+
* @template {SortEntry} T
|
|
25
|
+
* @param {{
|
|
26
|
+
* entries: T[],
|
|
27
|
+
* orderBy: OrderByItem[],
|
|
28
|
+
* context: ExecuteContext,
|
|
29
|
+
* cacheValues?: boolean,
|
|
30
|
+
* }} options
|
|
31
|
+
* @returns {Promise<T[]>}
|
|
32
|
+
*/
|
|
33
|
+
export async function sortEntriesByTerms({ entries, orderBy, context, cacheValues = false }) {
|
|
34
|
+
if (entries.length === 0) return []
|
|
35
|
+
|
|
36
|
+
/** @type {(SqlPrimitive | undefined)[][]} */
|
|
37
|
+
const evaluatedValues = entries.map(() => Array(orderBy.length))
|
|
38
|
+
|
|
39
|
+
/** @type {number[][]} */
|
|
40
|
+
let groups = [entries.map((_, i) => i)]
|
|
41
|
+
|
|
42
|
+
for (let orderByIdx = 0; orderByIdx < orderBy.length; orderByIdx++) {
|
|
43
|
+
const term = orderBy[orderByIdx]
|
|
44
|
+
/** @type {number[][]} */
|
|
45
|
+
const nextGroups = []
|
|
46
|
+
|
|
47
|
+
for (const group of groups) {
|
|
48
|
+
if (group.length <= 1) {
|
|
49
|
+
nextGroups.push(group)
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const alias = derivedAlias(term.expr)
|
|
54
|
+
/** @type {number[]} */
|
|
55
|
+
const missing = []
|
|
56
|
+
for (const idx of group) {
|
|
57
|
+
if (evaluatedValues[idx][orderByIdx] === undefined) missing.push(idx)
|
|
58
|
+
}
|
|
59
|
+
let chunkSize = 1
|
|
60
|
+
let start = 0
|
|
61
|
+
while (start < missing.length) {
|
|
62
|
+
if (context.signal?.aborted) return []
|
|
63
|
+
const chunk = missing.slice(start, start + chunkSize)
|
|
64
|
+
const values = await Promise.all(chunk.map(idx =>
|
|
65
|
+
evaluateExpr({
|
|
66
|
+
node: term.expr,
|
|
67
|
+
row: entries[idx].row,
|
|
68
|
+
rows: entries[idx].rows,
|
|
69
|
+
context,
|
|
70
|
+
})
|
|
71
|
+
))
|
|
72
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
73
|
+
const idx = chunk[i]
|
|
74
|
+
const value = values[i]
|
|
75
|
+
evaluatedValues[idx][orderByIdx] = value
|
|
76
|
+
if (cacheValues && !(alias in entries[idx].row.cells)) {
|
|
77
|
+
entries[idx].row.cells[alias] = () => Promise.resolve(value)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
start += chunk.length
|
|
81
|
+
chunkSize = Math.min(chunkSize * 2, MAX_CHUNK)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
group.sort((aIdx, bIdx) => {
|
|
85
|
+
const av = evaluatedValues[aIdx][orderByIdx]
|
|
86
|
+
const bv = evaluatedValues[bIdx][orderByIdx]
|
|
87
|
+
return compareForTerm(av, bv, term)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
if (orderByIdx < orderBy.length - 1) {
|
|
91
|
+
/** @type {number[]} */
|
|
92
|
+
let currentSubGroup = [group[0]]
|
|
93
|
+
for (let i = 1; i < group.length; i++) {
|
|
94
|
+
const prevIdx = group[i - 1]
|
|
95
|
+
const currIdx = group[i]
|
|
96
|
+
const prevVal = evaluatedValues[prevIdx][orderByIdx]
|
|
97
|
+
const currVal = evaluatedValues[currIdx][orderByIdx]
|
|
98
|
+
|
|
99
|
+
if (compareForTerm(prevVal, currVal, term) === 0) {
|
|
100
|
+
currentSubGroup.push(currIdx)
|
|
101
|
+
} else {
|
|
102
|
+
nextGroups.push(currentSubGroup)
|
|
103
|
+
currentSubGroup = [currIdx]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
nextGroups.push(currentSubGroup)
|
|
107
|
+
} else {
|
|
108
|
+
nextGroups.push(group)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
groups = nextGroups
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return groups.flat().map(idx => entries[idx])
|
|
116
|
+
}
|
|
117
|
+
|
|
13
118
|
/**
|
|
14
119
|
* Executes a sort operation (ORDER BY)
|
|
15
120
|
*
|
|
@@ -32,92 +137,16 @@ export function executeSort(plan, context) {
|
|
|
32
137
|
rows.push(row)
|
|
33
138
|
}
|
|
34
139
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/** @type {number[][]} */
|
|
42
|
-
let groups = [rows.map((_, i) => i)]
|
|
43
|
-
|
|
44
|
-
for (let orderByIdx = 0; orderByIdx < plan.orderBy.length; orderByIdx++) {
|
|
45
|
-
const term = plan.orderBy[orderByIdx]
|
|
46
|
-
/** @type {number[][]} */
|
|
47
|
-
const nextGroups = []
|
|
48
|
-
|
|
49
|
-
for (const group of groups) {
|
|
50
|
-
if (group.length <= 1) {
|
|
51
|
-
nextGroups.push(group)
|
|
52
|
-
continue
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Evaluate this column for all rows in the group, in parallel
|
|
56
|
-
// chunks that double up to MAX_CHUNK so a slow UDF doesn't serialize.
|
|
57
|
-
// Cache each value back into the row so downstream projection can
|
|
58
|
-
// reuse it instead of re-invoking the expression.
|
|
59
|
-
const alias = derivedAlias(term.expr)
|
|
60
|
-
/** @type {number[]} */
|
|
61
|
-
const missing = []
|
|
62
|
-
for (const idx of group) {
|
|
63
|
-
if (evaluatedValues[idx][orderByIdx] === undefined) missing.push(idx)
|
|
64
|
-
}
|
|
65
|
-
let chunkSize = 1
|
|
66
|
-
let start = 0
|
|
67
|
-
while (start < missing.length) {
|
|
68
|
-
if (context.signal?.aborted) return
|
|
69
|
-
const chunk = missing.slice(start, start + chunkSize)
|
|
70
|
-
const values = await Promise.all(chunk.map(idx =>
|
|
71
|
-
evaluateExpr({ node: term.expr, row: rows[idx], context })
|
|
72
|
-
))
|
|
73
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
74
|
-
const idx = chunk[i]
|
|
75
|
-
const value = values[i]
|
|
76
|
-
evaluatedValues[idx][orderByIdx] = value
|
|
77
|
-
if (!(alias in rows[idx].cells)) {
|
|
78
|
-
rows[idx].cells[alias] = () => Promise.resolve(value)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
start += chunk.length
|
|
82
|
-
chunkSize = Math.min(chunkSize * 2, MAX_CHUNK)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Sort the group by this column
|
|
86
|
-
group.sort((aIdx, bIdx) => {
|
|
87
|
-
const av = evaluatedValues[aIdx][orderByIdx]
|
|
88
|
-
const bv = evaluatedValues[bIdx][orderByIdx]
|
|
89
|
-
return compareForTerm(av, bv, term)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
// Split into sub-groups based on ties
|
|
93
|
-
if (orderByIdx < plan.orderBy.length - 1) {
|
|
94
|
-
/** @type {number[]} */
|
|
95
|
-
let currentSubGroup = [group[0]]
|
|
96
|
-
for (let i = 1; i < group.length; i++) {
|
|
97
|
-
const prevIdx = group[i - 1]
|
|
98
|
-
const currIdx = group[i]
|
|
99
|
-
const prevVal = evaluatedValues[prevIdx][orderByIdx]
|
|
100
|
-
const currVal = evaluatedValues[currIdx][orderByIdx]
|
|
101
|
-
|
|
102
|
-
if (compareForTerm(prevVal, currVal, term) === 0) {
|
|
103
|
-
currentSubGroup.push(currIdx)
|
|
104
|
-
} else {
|
|
105
|
-
nextGroups.push(currentSubGroup)
|
|
106
|
-
currentSubGroup = [currIdx]
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
nextGroups.push(currentSubGroup)
|
|
110
|
-
} else {
|
|
111
|
-
nextGroups.push(group)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
groups = nextGroups
|
|
116
|
-
}
|
|
140
|
+
const sortedRows = await sortEntriesByTerms({
|
|
141
|
+
entries: rows.map(row => ({ row })),
|
|
142
|
+
orderBy: plan.orderBy,
|
|
143
|
+
context,
|
|
144
|
+
cacheValues: true,
|
|
145
|
+
})
|
|
117
146
|
|
|
118
147
|
// Yield sorted rows
|
|
119
|
-
for (const
|
|
120
|
-
yield
|
|
148
|
+
for (const { row } of sortedRows) {
|
|
149
|
+
yield row
|
|
121
150
|
}
|
|
122
151
|
},
|
|
123
152
|
}
|
package/src/execute/utils.js
CHANGED
|
@@ -121,6 +121,19 @@ export function maxBounds(a, b) {
|
|
|
121
121
|
return a ?? b
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Returns true for plain object SqlPrimitive values, excluding null, arrays, and Dates.
|
|
126
|
+
*
|
|
127
|
+
* @param {SqlPrimitive} value
|
|
128
|
+
* @returns {value is Record<string, SqlPrimitive>}
|
|
129
|
+
*/
|
|
130
|
+
export function isPlainObject(value) {
|
|
131
|
+
return value != null
|
|
132
|
+
&& typeof value === 'object'
|
|
133
|
+
&& !Array.isArray(value)
|
|
134
|
+
&& !(value instanceof Date)
|
|
135
|
+
}
|
|
136
|
+
|
|
124
137
|
/**
|
|
125
138
|
* @param {SqlPrimitive} value
|
|
126
139
|
* @returns {string}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { executeStatement } from '../execute/execute.js'
|
|
2
|
-
import { keyify, stringify } from '../execute/utils.js'
|
|
2
|
+
import { isPlainObject, keyify, stringify } from '../execute/utils.js'
|
|
3
3
|
import { ArgValueError, ExecutionError } from '../validation/executionErrors.js'
|
|
4
4
|
import { isAggregateFunc, isMathFunc, isRegexpFunc, isSpatialFunc, isStringFunc } from '../validation/functions.js'
|
|
5
5
|
import { UnknownFunctionError } from '../validation/parseErrors.js'
|
|
@@ -39,6 +39,25 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
39
39
|
if (qualified in row.cells) {
|
|
40
40
|
return row.cells[qualified]()
|
|
41
41
|
}
|
|
42
|
+
const prefix = node.prefix + '.'
|
|
43
|
+
const prefixedColumns = row.columns.filter(col => col.startsWith(prefix))
|
|
44
|
+
if (prefixedColumns.length === 1) {
|
|
45
|
+
const value = await row.cells[prefixedColumns[0]]()
|
|
46
|
+
if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(value, node.name)) {
|
|
47
|
+
return value[node.name]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Struct dot access where the prefix is itself a column name (bare or
|
|
51
|
+
// table-qualified), e.g. `item.name` reading field `name` from a struct
|
|
52
|
+
// column `item` (often introduced via UNNEST AS tc(item)).
|
|
53
|
+
const suffix = '.' + node.prefix
|
|
54
|
+
const baseColumns = row.columns.filter(col => col === node.prefix || col.endsWith(suffix))
|
|
55
|
+
if (baseColumns.length === 1) {
|
|
56
|
+
const value = await row.cells[baseColumns[0]]()
|
|
57
|
+
if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(value, node.name)) {
|
|
58
|
+
return value[node.name]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
42
61
|
// Check outer row for correlated subquery references
|
|
43
62
|
if (context.outerRow && context.outerAliases?.has(node.prefix) && node.name in context.outerRow.cells) {
|
|
44
63
|
return context.outerRow.cells[node.name]()
|
|
@@ -473,6 +492,23 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
473
492
|
if (funcName === 'ARRAY_LENGTH' || funcName === 'CARDINALITY') {
|
|
474
493
|
const arr = args[0]
|
|
475
494
|
if (!Array.isArray(arr)) return null
|
|
495
|
+
if (funcName === 'ARRAY_LENGTH' && args.length === 2) {
|
|
496
|
+
const dim = args[1]
|
|
497
|
+
if (typeof dim !== 'number' && typeof dim !== 'bigint') return null
|
|
498
|
+
const d = Number(dim)
|
|
499
|
+
if (!Number.isInteger(d) || d < 1) return null
|
|
500
|
+
let level = arr
|
|
501
|
+
for (let i = 1; i < d; i++) {
|
|
502
|
+
if (!Array.isArray(level) || level.length === 0) return null
|
|
503
|
+
const first = level[0]
|
|
504
|
+
if (!Array.isArray(first)) return null
|
|
505
|
+
for (const item of level) {
|
|
506
|
+
if (!Array.isArray(item) || item.length !== first.length) return null
|
|
507
|
+
}
|
|
508
|
+
level = first
|
|
509
|
+
}
|
|
510
|
+
return level.length
|
|
511
|
+
}
|
|
476
512
|
return arr.length
|
|
477
513
|
}
|
|
478
514
|
|
|
@@ -32,6 +32,25 @@ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
|
|
|
32
32
|
// String first arg
|
|
33
33
|
const [val] = args
|
|
34
34
|
if (val == null) return null
|
|
35
|
+
|
|
36
|
+
if (funcName === 'LENGTH') {
|
|
37
|
+
if (typeof val === 'string' || Array.isArray(val)) return val.length
|
|
38
|
+
throw new ArgValueError({
|
|
39
|
+
...node,
|
|
40
|
+
message: `expected string or array, got ${typeof val === 'object' ? val instanceof Date ? 'date' : 'object' : typeof val}`,
|
|
41
|
+
hint: 'Use CAST to convert to a string first.',
|
|
42
|
+
rowIndex,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof val === 'object' && !(val instanceof Date)) {
|
|
47
|
+
throw new ArgValueError({
|
|
48
|
+
...node,
|
|
49
|
+
message: `does not support ${Array.isArray(val) ? 'array' : 'object'} arguments`,
|
|
50
|
+
hint: 'Use CAST to convert to a string first.',
|
|
51
|
+
rowIndex,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
35
54
|
const str = String(val)
|
|
36
55
|
|
|
37
56
|
if (funcName === 'UPPER') {
|
|
@@ -42,10 +61,6 @@ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
|
|
|
42
61
|
return str.toLowerCase()
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
if (funcName === 'LENGTH') {
|
|
46
|
-
return str.length
|
|
47
|
-
}
|
|
48
|
-
|
|
49
64
|
if (funcName === 'SUBSTRING' || funcName === 'SUBSTR') {
|
|
50
65
|
const start = Number(args[1])
|
|
51
66
|
if (!Number.isInteger(start) || start < 1) {
|
package/src/parse/primary.js
CHANGED
|
@@ -157,6 +157,16 @@ export function parsePrimary(state) {
|
|
|
157
157
|
if (match(state, 'dot')) {
|
|
158
158
|
prefix = name
|
|
159
159
|
name = expect(state, 'identifier').value
|
|
160
|
+
} else if (match(state, 'bracket', '[')) {
|
|
161
|
+
// table['column'] — string subscript is equivalent to dot access
|
|
162
|
+
const fieldTok = current(state)
|
|
163
|
+
if (fieldTok.type !== 'string') {
|
|
164
|
+
throw parseError(state, 'string literal')
|
|
165
|
+
}
|
|
166
|
+
consume(state)
|
|
167
|
+
expect(state, 'bracket', ']')
|
|
168
|
+
prefix = name
|
|
169
|
+
name = fieldTok.value
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
return {
|
package/src/plan/columns.js
CHANGED
|
@@ -400,3 +400,24 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
|
|
|
400
400
|
function lookupTableColumns(table, cteColumns, tables) {
|
|
401
401
|
return cteColumns?.get(table.toLowerCase()) ?? tables?.[table]?.columns ?? []
|
|
402
402
|
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Collects bare column names exposed by a SELECT's FROM and joins. Used by
|
|
406
|
+
* validation to recognize struct-field dot access (e.g. `item.name` on a
|
|
407
|
+
* struct-valued column `item`) instead of rejecting the prefix as an unknown
|
|
408
|
+
* table.
|
|
409
|
+
*
|
|
410
|
+
* @param {object} options
|
|
411
|
+
* @param {SelectStatement} options.select
|
|
412
|
+
* @param {Map<string, string[]>} [options.cteColumns]
|
|
413
|
+
* @param {Record<string, AsyncDataSource>} [options.tables]
|
|
414
|
+
* @returns {Set<string>}
|
|
415
|
+
*/
|
|
416
|
+
export function collectScopeColumns({ select, cteColumns, tables }) {
|
|
417
|
+
const result = new Set()
|
|
418
|
+
for (const col of inferSelectSourceColumns({ select, cteColumns, tables })) {
|
|
419
|
+
const dot = col.indexOf('.')
|
|
420
|
+
result.add(dot >= 0 ? col.slice(dot + 1) : col)
|
|
421
|
+
}
|
|
422
|
+
return result
|
|
423
|
+
}
|
package/src/plan/plan.js
CHANGED
|
@@ -4,10 +4,10 @@ import { findAggregate } from '../validation/aggregates.js'
|
|
|
4
4
|
import { ParseError } from '../validation/parseErrors.js'
|
|
5
5
|
import { ColumnNotFoundError, TableNotFoundError } from '../validation/tables.js'
|
|
6
6
|
import { validateNoIdentifiers, validateScan, validateTableRefs } from '../validation/tables.js'
|
|
7
|
-
import { extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, tableFunctionColumnNames } from './columns.js'
|
|
7
|
+
import { collectScopeColumns, extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, tableFunctionColumnNames } from './columns.js'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* @import { AsyncDataSource, ExprNode, DerivedColumn, IdentifierNode, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
|
|
10
|
+
* @import { AsyncDataSource, ExprNode, DerivedColumn, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
|
|
11
11
|
* @import { QueryPlan, WindowSpec } from './types.d.ts'
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -159,11 +159,15 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
159
159
|
// Resolve aliases (and validate qualified references)
|
|
160
160
|
// Include outerScope aliases so correlated references pass validation
|
|
161
161
|
const scopeTables = Object.fromEntries([sourceAlias, ...select.joins.map(j => j.alias ?? j.table), ...outerScope ?? []].map(a => [a, true]))
|
|
162
|
+
// Bare column names in scope, so the validator can recognize struct-field
|
|
163
|
+
// dot access on a column (e.g. `item.name` where `item` is an unnested
|
|
164
|
+
// struct column) rather than rejecting `item` as an unknown table.
|
|
165
|
+
const scopeColumns = collectScopeColumns({ select, cteColumns, tables })
|
|
162
166
|
/** @type {Map<string, ExprNode>} */
|
|
163
167
|
const aliases = new Map()
|
|
164
168
|
const columns = select.columns.map(col => {
|
|
165
169
|
if (col.type === 'derived') {
|
|
166
|
-
validateTableRefs(col.expr, scopeTables)
|
|
170
|
+
validateTableRefs(col.expr, scopeTables, scopeColumns)
|
|
167
171
|
const expr = resolveAliases(col.expr, aliases)
|
|
168
172
|
if (col.alias) {
|
|
169
173
|
aliases.set(col.alias, expr)
|
|
@@ -177,18 +181,19 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
177
181
|
}
|
|
178
182
|
return col
|
|
179
183
|
})
|
|
184
|
+
const orderBy = resolveOrderByAliases(select.orderBy, aliases)
|
|
180
185
|
|
|
181
186
|
// Validate qualified references in other clauses
|
|
182
|
-
validateTableRefs(select.where, scopeTables)
|
|
183
|
-
validateTableRefs(select.having, scopeTables)
|
|
187
|
+
validateTableRefs(select.where, scopeTables, scopeColumns)
|
|
188
|
+
validateTableRefs(select.having, scopeTables, scopeColumns)
|
|
184
189
|
for (const expr of select.groupBy) {
|
|
185
|
-
validateTableRefs(expr, scopeTables)
|
|
190
|
+
validateTableRefs(expr, scopeTables, scopeColumns)
|
|
186
191
|
}
|
|
187
192
|
for (const term of select.orderBy) {
|
|
188
|
-
validateTableRefs(term.expr, scopeTables)
|
|
193
|
+
validateTableRefs(term.expr, scopeTables, scopeColumns)
|
|
189
194
|
}
|
|
190
195
|
for (const join of select.joins) {
|
|
191
|
-
validateTableRefs(join.on, scopeTables)
|
|
196
|
+
validateTableRefs(join.on, scopeTables, scopeColumns)
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
// Determine scan hints for direct table scans (WHERE and LIMIT/OFFSET are
|
|
@@ -235,7 +240,16 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
235
240
|
const groupBy = aliases.size > 0
|
|
236
241
|
? select.groupBy.map(expr => resolveAliases(expr, aliases))
|
|
237
242
|
: select.groupBy
|
|
238
|
-
|
|
243
|
+
/** @type {QueryPlan} */
|
|
244
|
+
const aggregatePlan = {
|
|
245
|
+
type: 'HashAggregate',
|
|
246
|
+
groupBy,
|
|
247
|
+
columns,
|
|
248
|
+
having: select.having,
|
|
249
|
+
child: plan,
|
|
250
|
+
}
|
|
251
|
+
if (orderBy.length) aggregatePlan.orderBy = orderBy
|
|
252
|
+
plan = aggregatePlan
|
|
239
253
|
} else if (!select.having && !select.where && plan.type === 'Scan' && isOwnScan && isAllCountStar(select.columns)) {
|
|
240
254
|
plan = { type: 'Count', table: plan.table, columns: select.columns }
|
|
241
255
|
} else {
|
|
@@ -243,8 +257,8 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
243
257
|
}
|
|
244
258
|
|
|
245
259
|
// ORDER BY (after aggregation)
|
|
246
|
-
if (select.
|
|
247
|
-
plan = { type: 'Sort', orderBy
|
|
260
|
+
if (orderBy.length && !select.groupBy.length) {
|
|
261
|
+
plan = { type: 'Sort', orderBy, child: plan }
|
|
248
262
|
}
|
|
249
263
|
|
|
250
264
|
// DISTINCT
|
|
@@ -267,10 +281,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
267
281
|
|
|
268
282
|
// ORDER BY (before projection so it can access all columns)
|
|
269
283
|
// Resolve SELECT aliases in ORDER BY expressions at plan time
|
|
270
|
-
if (
|
|
271
|
-
const orderBy = aliases.size > 0
|
|
272
|
-
? select.orderBy.map(term => ({ ...term, expr: resolveAliases(term.expr, aliases) }))
|
|
273
|
-
: select.orderBy
|
|
284
|
+
if (orderBy.length) {
|
|
274
285
|
plan = { type: 'Sort', orderBy, child: plan }
|
|
275
286
|
}
|
|
276
287
|
|
|
@@ -469,6 +480,19 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
|
|
|
469
480
|
return plan
|
|
470
481
|
}
|
|
471
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Recursively replaces identifier nodes in ORDER BY terms that match SELECT
|
|
485
|
+
* aliases with their aliased expressions.
|
|
486
|
+
*
|
|
487
|
+
* @param {OrderByItem[]} orderBy
|
|
488
|
+
* @param {Map<string, ExprNode>} aliases
|
|
489
|
+
* @returns {OrderByItem[]}
|
|
490
|
+
*/
|
|
491
|
+
function resolveOrderByAliases(orderBy, aliases) {
|
|
492
|
+
if (!aliases.size) return orderBy
|
|
493
|
+
return orderBy.map(term => ({ ...term, expr: resolveAliases(term.expr, aliases) }))
|
|
494
|
+
}
|
|
495
|
+
|
|
472
496
|
/**
|
|
473
497
|
* Recursively replaces identifier nodes that match SELECT aliases
|
|
474
498
|
* with their aliased expressions.
|
|
@@ -492,7 +516,8 @@ function resolveAliases(node, aliases) {
|
|
|
492
516
|
}
|
|
493
517
|
if (node.type === 'function') {
|
|
494
518
|
const args = node.args.map(arg => resolveAliases(arg, aliases))
|
|
495
|
-
return { ...node, args }
|
|
519
|
+
if (!node.filter) return { ...node, args }
|
|
520
|
+
return { ...node, args, filter: resolveAliases(node.filter, aliases) }
|
|
496
521
|
}
|
|
497
522
|
if (node.type === 'cast') {
|
|
498
523
|
return { ...node, expr: resolveAliases(node.expr, aliases) }
|
package/src/plan/types.d.ts
CHANGED
|
@@ -180,7 +180,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
180
180
|
ARRAY_AGG: { min: 1, max: 1, signature: 'expression' },
|
|
181
181
|
|
|
182
182
|
// Array functions
|
|
183
|
-
ARRAY_LENGTH: { min: 1, max:
|
|
183
|
+
ARRAY_LENGTH: { min: 1, max: 2, signature: 'array[, dimension]' },
|
|
184
184
|
ARRAY_POSITION: { min: 2, max: 2, signature: 'array, element' },
|
|
185
185
|
ARRAY_SORT: { min: 1, max: 1, signature: 'array' },
|
|
186
186
|
CARDINALITY: { min: 1, max: 1, signature: 'array' },
|
package/src/validation/tables.js
CHANGED
|
@@ -95,13 +95,18 @@ export function validateNoIdentifiers(expr, context) {
|
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* Validates that qualified identifiers reference known table aliases.
|
|
98
|
+
* A `prefix` may also be a bare column name in scope, in which case the
|
|
99
|
+
* identifier is struct-field access (e.g. `item.name` reads field `name`
|
|
100
|
+
* from a struct-valued column `item`).
|
|
98
101
|
*
|
|
99
102
|
* @param {ExprNode} expr
|
|
100
103
|
* @param {Record<string, any>} tables
|
|
104
|
+
* @param {Set<string>} [scopeColumns] - bare column names in scope, used to
|
|
105
|
+
* recognize struct-field dot access on a column rather than a table
|
|
101
106
|
*/
|
|
102
|
-
export function validateTableRefs(expr, tables) {
|
|
107
|
+
export function validateTableRefs(expr, tables, scopeColumns) {
|
|
103
108
|
if (!expr) return
|
|
104
|
-
if (expr.type === 'identifier' && expr.prefix && !(expr.prefix in tables)) {
|
|
109
|
+
if (expr.type === 'identifier' && expr.prefix && !(expr.prefix in tables) && !scopeColumns?.has(expr.prefix)) {
|
|
105
110
|
throw new TableNotFoundError({
|
|
106
111
|
table: expr.prefix,
|
|
107
112
|
qualified: expr.prefix + '.' + expr.name,
|
|
@@ -111,32 +116,32 @@ export function validateTableRefs(expr, tables) {
|
|
|
111
116
|
})
|
|
112
117
|
}
|
|
113
118
|
if (expr.type === 'binary') {
|
|
114
|
-
validateTableRefs(expr.left, tables)
|
|
115
|
-
validateTableRefs(expr.right, tables)
|
|
119
|
+
validateTableRefs(expr.left, tables, scopeColumns)
|
|
120
|
+
validateTableRefs(expr.right, tables, scopeColumns)
|
|
116
121
|
} else if (expr.type === 'unary') {
|
|
117
|
-
validateTableRefs(expr.argument, tables)
|
|
122
|
+
validateTableRefs(expr.argument, tables, scopeColumns)
|
|
118
123
|
} else if (expr.type === 'function') {
|
|
119
124
|
for (const arg of expr.args) {
|
|
120
|
-
validateTableRefs(arg, tables)
|
|
125
|
+
validateTableRefs(arg, tables, scopeColumns)
|
|
121
126
|
}
|
|
122
127
|
} else if (expr.type === 'window') {
|
|
123
|
-
for (const arg of expr.args) validateTableRefs(arg, tables)
|
|
124
|
-
for (const p of expr.partitionBy) validateTableRefs(p, tables)
|
|
125
|
-
for (const o of expr.orderBy) validateTableRefs(o.expr, tables)
|
|
128
|
+
for (const arg of expr.args) validateTableRefs(arg, tables, scopeColumns)
|
|
129
|
+
for (const p of expr.partitionBy) validateTableRefs(p, tables, scopeColumns)
|
|
130
|
+
for (const o of expr.orderBy) validateTableRefs(o.expr, tables, scopeColumns)
|
|
126
131
|
} else if (expr.type === 'cast') {
|
|
127
|
-
validateTableRefs(expr.expr, tables)
|
|
132
|
+
validateTableRefs(expr.expr, tables, scopeColumns)
|
|
128
133
|
} else if (expr.type === 'in valuelist') {
|
|
129
|
-
validateTableRefs(expr.expr, tables)
|
|
134
|
+
validateTableRefs(expr.expr, tables, scopeColumns)
|
|
130
135
|
for (const val of expr.values) {
|
|
131
|
-
validateTableRefs(val, tables)
|
|
136
|
+
validateTableRefs(val, tables, scopeColumns)
|
|
132
137
|
}
|
|
133
138
|
} else if (expr.type === 'case') {
|
|
134
|
-
validateTableRefs(expr.caseExpr, tables)
|
|
139
|
+
validateTableRefs(expr.caseExpr, tables, scopeColumns)
|
|
135
140
|
for (const w of expr.whenClauses) {
|
|
136
|
-
validateTableRefs(w.condition, tables)
|
|
137
|
-
validateTableRefs(w.result, tables)
|
|
141
|
+
validateTableRefs(w.condition, tables, scopeColumns)
|
|
142
|
+
validateTableRefs(w.result, tables, scopeColumns)
|
|
138
143
|
}
|
|
139
|
-
validateTableRefs(expr.elseResult, tables)
|
|
144
|
+
validateTableRefs(expr.elseResult, tables, scopeColumns)
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
|