squirreling 0.16.13 → 0.16.15
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 +5 -5
- package/package.json +3 -3
- package/src/ast.d.ts +1 -1
- package/src/execute/accumulator.js +22 -2
- package/src/execute/aggregates.js +12 -0
- package/src/execute/dynamicFilter.js +131 -0
- package/src/execute/execute.js +35 -1
- package/src/execute/join.js +20 -7
- package/src/execute/semiJoin.js +45 -40
- package/src/execute/streamingAggregate.js +21 -10
- package/src/expression/batch.js +6 -12
- package/src/expression/evaluate.js +12 -17
- package/src/expression/scalar.js +22 -2
- package/src/expression/strings.js +11 -0
- package/src/internalTypes.d.ts +1 -0
- package/src/parse/expression.js +12 -4
- package/src/types.d.ts +6 -1
- package/src/validation/functions.js +6 -2
package/README.md
CHANGED
|
@@ -147,7 +147,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
147
147
|
- `GROUP BY` and `HAVING` clauses, including `GROUP BY ALL`
|
|
148
148
|
- Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`
|
|
149
149
|
- `SHOW TABLES` to list the available tables
|
|
150
|
-
- Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT
|
|
150
|
+
- Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS [NOT] NULL`, `IS [NOT] TRUE`, `IS [NOT] FALSE`, string concatenation `||`
|
|
151
151
|
- Subscript access: zero-based array indexing `col[0]`, struct field access `col['field']`, and chains like `col[0].field`
|
|
152
152
|
|
|
153
153
|
### Quoting
|
|
@@ -158,13 +158,13 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
158
158
|
|
|
159
159
|
### Functions
|
|
160
160
|
|
|
161
|
-
- Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MIN_BY`, `MAX_BY`, `ANY_VALUE`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
|
|
161
|
+
- Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MIN_BY`, `MAX_BY`, `ANY_VALUE`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`, `GROUP_CONCAT`
|
|
162
162
|
- Window: `ROW_NUMBER`, `LAG`, `LEAD`
|
|
163
|
-
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`
|
|
163
|
+
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`, `CHR`
|
|
164
164
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
165
165
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
166
|
-
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
|
|
167
|
-
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_EXTRACT_STRING`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
|
|
166
|
+
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `NOW`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
|
|
167
|
+
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_EXTRACT_STRING`, `JSON_EXTRACT_SCALAR`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
|
|
168
168
|
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_CONTAINS`, `ARRAY_SORT`, `ARRAY_APPEND`, `ARRAY_CONCAT`, `LEN`, `CARDINALITY`, `SIZE`
|
|
169
169
|
- Table functions: `UNNEST`, `EXPLODE`, `JSON_EACH`
|
|
170
170
|
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`, `REGEXP_LIKE`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.15",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@eslint/js": "10.0.1",
|
|
43
|
-
"@types/node": "26.6.
|
|
43
|
+
"@types/node": "26.6.6",
|
|
44
44
|
"@vitest/coverage-v8": "5.0.5",
|
|
45
45
|
"eslint": "10.13.0",
|
|
46
|
-
"eslint-plugin-jsdoc": "65.4.
|
|
46
|
+
"eslint-plugin-jsdoc": "65.4.2",
|
|
47
47
|
"globals": "17.13.0",
|
|
48
48
|
"typescript": "7.0.2",
|
|
49
49
|
"vitest": "5.0.5"
|
package/src/ast.d.ts
CHANGED
|
@@ -92,7 +92,7 @@ export interface IdentifierNode extends AstBase {
|
|
|
92
92
|
|
|
93
93
|
export interface UnaryNode extends AstBase {
|
|
94
94
|
type: 'unary'
|
|
95
|
-
op: 'NOT' | 'IS NULL' | 'IS NOT NULL' | '-'
|
|
95
|
+
op: 'NOT' | 'IS NULL' | 'IS NOT NULL' | 'IS TRUE' | 'IS NOT TRUE' | 'IS FALSE' | 'IS NOT FALSE' | '-'
|
|
96
96
|
argument: ExprNode
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -7,13 +7,15 @@ import { keyify } from './utils.js'
|
|
|
7
7
|
/**
|
|
8
8
|
* Incremental state for one streamable aggregate. Shared by the streaming
|
|
9
9
|
* aggregate executor and the scanColumn fast path so COUNT/COUNTIF/SUM/AVG/
|
|
10
|
-
* MIN/MAX fold semantics live in one place.
|
|
10
|
+
* MIN/MAX/MIN_BY/MAX_BY fold semantics live in one place.
|
|
11
11
|
*
|
|
12
12
|
* @typedef {{
|
|
13
13
|
* count: number,
|
|
14
14
|
* sum: number,
|
|
15
15
|
* min: SqlPrimitive,
|
|
16
16
|
* max: SqlPrimitive,
|
|
17
|
+
* best?: SqlPrimitive,
|
|
18
|
+
* bestKey?: SqlPrimitive,
|
|
17
19
|
* seen: Set<unknown> | null,
|
|
18
20
|
* }} Accumulator
|
|
19
21
|
*/
|
|
@@ -41,8 +43,9 @@ export function newAccumulator(funcName, distinct) {
|
|
|
41
43
|
* @param {string} funcName
|
|
42
44
|
* @param {Accumulator} acc
|
|
43
45
|
* @param {SqlPrimitive} value
|
|
46
|
+
* @param {SqlPrimitive} [key] - comparison key for MIN_BY/MAX_BY
|
|
44
47
|
*/
|
|
45
|
-
export function updateAccumulator(funcName, acc, value) {
|
|
48
|
+
export function updateAccumulator(funcName, acc, value, key) {
|
|
46
49
|
switch (funcName) {
|
|
47
50
|
case 'COUNT':
|
|
48
51
|
if (value == null) break
|
|
@@ -58,6 +61,19 @@ export function updateAccumulator(funcName, acc, value) {
|
|
|
58
61
|
case 'MAX':
|
|
59
62
|
if (value != null && (acc.max === null || value > acc.max)) acc.max = value
|
|
60
63
|
break
|
|
64
|
+
case 'MIN_BY':
|
|
65
|
+
case 'ARG_MIN':
|
|
66
|
+
case 'MAX_BY':
|
|
67
|
+
case 'ARG_MAX': {
|
|
68
|
+
if (value == null || key == null) break
|
|
69
|
+
const isMin = funcName === 'MIN_BY' || funcName === 'ARG_MIN'
|
|
70
|
+
// Match buffered aggregation: ignore null pairs and keep the first tie.
|
|
71
|
+
if (acc.bestKey == null || (isMin ? key < acc.bestKey : key > acc.bestKey)) {
|
|
72
|
+
acc.best = value
|
|
73
|
+
acc.bestKey = key
|
|
74
|
+
}
|
|
75
|
+
break
|
|
76
|
+
}
|
|
61
77
|
default: { // SUM, AVG
|
|
62
78
|
if (value == null) break
|
|
63
79
|
const num = Number(value)
|
|
@@ -84,6 +100,10 @@ export function finalizeAccumulator(funcName, acc) {
|
|
|
84
100
|
case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
|
|
85
101
|
case 'MIN': return acc.min
|
|
86
102
|
case 'MAX': return acc.max
|
|
103
|
+
case 'MIN_BY':
|
|
104
|
+
case 'ARG_MIN':
|
|
105
|
+
case 'MAX_BY':
|
|
106
|
+
case 'ARG_MAX': return acc.best ?? null
|
|
87
107
|
default: return null
|
|
88
108
|
}
|
|
89
109
|
}
|
|
@@ -193,6 +193,18 @@ export function executeHashAggregate(plan, context) {
|
|
|
193
193
|
* @returns {QueryResults}
|
|
194
194
|
*/
|
|
195
195
|
export function executeScalarAggregate(plan, context) {
|
|
196
|
+
if (context.deferScans && plan.child.type === 'Scan') {
|
|
197
|
+
const child = executeScan(plan.child, context)
|
|
198
|
+
return {
|
|
199
|
+
columns: selectColumnNames(plan.columns, child.columns),
|
|
200
|
+
numRows: plan.having ? undefined : 1,
|
|
201
|
+
maxRows: 1,
|
|
202
|
+
async *rows() {
|
|
203
|
+
context.signal?.throwIfAborted()
|
|
204
|
+
yield* executeScalarAggregate(plan, { ...context, deferScans: false }).rows()
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
196
208
|
// Fast path: use scanColumn when available
|
|
197
209
|
const columnScan = tryColumnScanAggregate(plan, context)
|
|
198
210
|
if (columnScan?.rows) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { ExprNode, SqlPrimitive } from '../types.js'
|
|
3
|
+
* @import { QueryPlan } from '../plan/types.js'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Bound filter memory and match the session-index pruning limit.
|
|
7
|
+
const MAX_DYNAMIC_FILTER_KEYS = 256
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bounded, per-column key sets. Once a column exceeds the cap, release it and
|
|
11
|
+
* stop collecting that column. Unsupported values disable that column too.
|
|
12
|
+
*
|
|
13
|
+
* @param {number} count
|
|
14
|
+
* @param {boolean} [hash] - preserve the hash join's serialized key equivalences
|
|
15
|
+
* @returns {{ add(values: SqlPrimitive[]): void, apply(plan: QueryPlan, keys: ExprNode[]): QueryPlan }}
|
|
16
|
+
*/
|
|
17
|
+
export function dynamicFilter(count, hash = false) {
|
|
18
|
+
/** @type {(Set<SqlPrimitive> | undefined)[]} */
|
|
19
|
+
const sets = Array.from({ length: count }, () => new Set())
|
|
20
|
+
let active = true
|
|
21
|
+
return {
|
|
22
|
+
/**
|
|
23
|
+
* @param {SqlPrimitive[]} values
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
add(values) {
|
|
27
|
+
if (!active) return
|
|
28
|
+
// Hash tuple serialization uses delimiters and serializes objects. Do
|
|
29
|
+
// not prune when those encodings can collide across column boundaries.
|
|
30
|
+
if (hash && values.some(value => typeof value === 'object' && value !== null ||
|
|
31
|
+
typeof value === 'string' && (value.includes('|') || ['[', '{', '"'].includes(value[0]) || value === 'null'))) {
|
|
32
|
+
sets.fill(undefined)
|
|
33
|
+
active = false
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
for (let i = 0; i < sets.length; i++) {
|
|
37
|
+
const set = sets[i]
|
|
38
|
+
const value = values[i]
|
|
39
|
+
if (!set || value == null) continue
|
|
40
|
+
if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value) ||
|
|
41
|
+
typeof value === 'number' && !Number.isFinite(value)) {
|
|
42
|
+
sets[i] = undefined
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
set.add(value)
|
|
46
|
+
if (hash && count > 1) {
|
|
47
|
+
if (typeof value === 'boolean') set.add(String(value))
|
|
48
|
+
if (value === 'true' || value === 'false') set.add(value === 'true')
|
|
49
|
+
}
|
|
50
|
+
if (set.size > MAX_DYNAMIC_FILTER_KEYS) sets[i] = undefined
|
|
51
|
+
}
|
|
52
|
+
active = sets.some(Boolean)
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* @param {QueryPlan} plan
|
|
56
|
+
* @param {ExprNode[]} keys
|
|
57
|
+
* @returns {QueryPlan}
|
|
58
|
+
*/
|
|
59
|
+
apply(plan, keys) {
|
|
60
|
+
for (let i = 0; i < keys.length; i++) {
|
|
61
|
+
const set = sets[i]
|
|
62
|
+
if (set?.size) plan = pushFilter(plan, keys[i], [...set])
|
|
63
|
+
}
|
|
64
|
+
return plan
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Only traverse operators that preserve the meaning of a physical column.
|
|
71
|
+
* Limits, aggregates, windows and projections are deliberate barriers.
|
|
72
|
+
*
|
|
73
|
+
* @param {QueryPlan} plan
|
|
74
|
+
* @param {ExprNode} key
|
|
75
|
+
* @param {SqlPrimitive[]} values
|
|
76
|
+
* @returns {QueryPlan}
|
|
77
|
+
*/
|
|
78
|
+
function pushFilter(plan, key, values) {
|
|
79
|
+
if (key?.type !== 'identifier') return plan
|
|
80
|
+
if (plan.type === 'Scan') {
|
|
81
|
+
if (key.prefix && key.prefix !== (plan.alias ?? plan.table) ||
|
|
82
|
+
plan.hints.limit !== undefined || plan.hints.offset || plan.topK) return plan
|
|
83
|
+
const position = { positionStart: key.positionStart, positionEnd: key.positionEnd }
|
|
84
|
+
/** @type {ExprNode} */
|
|
85
|
+
const filter = {
|
|
86
|
+
type: 'in valuelist', expr: key, ...position,
|
|
87
|
+
values: values.map(value => ({ type: 'literal', value, ...position })),
|
|
88
|
+
}
|
|
89
|
+
/** @type {ExprNode} */
|
|
90
|
+
const where = plan.hints.where
|
|
91
|
+
? { type: 'binary', op: 'AND', left: plan.hints.where, right: filter, ...position }
|
|
92
|
+
: filter
|
|
93
|
+
return { ...plan, hints: { ...plan.hints, where } }
|
|
94
|
+
}
|
|
95
|
+
if (plan.type === 'Filter' || plan.type === 'Sort' && !plan.topK || plan.type === 'Distinct') {
|
|
96
|
+
return { ...plan, child: pushFilter(plan.child, key, values) }
|
|
97
|
+
}
|
|
98
|
+
if (plan.type === 'HashJoin' && key.prefix) {
|
|
99
|
+
// Restrict only preserved inputs of outer joins; restricting an optional
|
|
100
|
+
// input could turn a formerly matched row into a new null-extended row.
|
|
101
|
+
const left = plan.joinType === 'INNER' || plan.joinType === 'LEFT'
|
|
102
|
+
? pushFilter(plan.left, key, values) : plan.left
|
|
103
|
+
const right = plan.joinType === 'INNER' || plan.joinType === 'RIGHT'
|
|
104
|
+
? pushFilter(plan.right, key, values) : plan.right
|
|
105
|
+
return { ...plan, left, right }
|
|
106
|
+
}
|
|
107
|
+
return plan
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Selectivity heuristic when exact filtered cardinalities are unavailable.
|
|
112
|
+
* Count conjuncts so a date plus a LIKE wins over a lone part-type predicate.
|
|
113
|
+
*
|
|
114
|
+
* @param {QueryPlan} plan
|
|
115
|
+
* @returns {number}
|
|
116
|
+
*/
|
|
117
|
+
export function filterScore(plan) {
|
|
118
|
+
if (plan.type === 'Scan') return conjuncts(plan.hints.where)
|
|
119
|
+
if (plan.type === 'Filter') return conjuncts(plan.condition) + filterScore(plan.child)
|
|
120
|
+
if (plan.type === 'HashJoin' && plan.joinType === 'INNER') return Math.max(filterScore(plan.left), filterScore(plan.right))
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {ExprNode} expr
|
|
126
|
+
* @returns {number}
|
|
127
|
+
*/
|
|
128
|
+
function conjuncts(expr) {
|
|
129
|
+
return !expr ? 0 : expr.type === 'binary' && expr.op === 'AND'
|
|
130
|
+
? conjuncts(expr.left) + conjuncts(expr.right) : 1
|
|
131
|
+
}
|
package/src/execute/execute.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { selectedRowCount } from '../backend/batch.js'
|
|
2
|
-
import { batchesToRows } from '../backend/batchAdapters.js'
|
|
2
|
+
import { batchesToRows, rowsToBatches } from '../backend/batchAdapters.js'
|
|
3
3
|
import { dataSourceColumns, memorySource } from '../backend/dataSource.js'
|
|
4
4
|
import { derivedAlias } from '../expression/alias.js'
|
|
5
5
|
import { compileBatchExpression } from '../expression/batch.js'
|
|
@@ -98,6 +98,9 @@ export function executeStatement({ query, context, outerScope }) {
|
|
|
98
98
|
* @returns {QueryResults}
|
|
99
99
|
*/
|
|
100
100
|
export function executePlan({ plan, context }) {
|
|
101
|
+
if (context.currentTimestamp === undefined) {
|
|
102
|
+
context = { ...context, currentTimestamp: new Date().toISOString() }
|
|
103
|
+
}
|
|
101
104
|
if (plan.type === 'Scan') {
|
|
102
105
|
return executeScan(plan, context)
|
|
103
106
|
} else if (plan.type === 'SingleRow') {
|
|
@@ -318,6 +321,37 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
318
321
|
const { tables, signal } = context
|
|
319
322
|
const table = validateTable({ ...plan, tables })
|
|
320
323
|
validateScan({ ...plan, tables })
|
|
324
|
+
if (context.deferScans && !existingColumnResult) {
|
|
325
|
+
const size = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
|
|
326
|
+
const columns = plan.hints.columns ?? dataSourceColumns(table)
|
|
327
|
+
/** @type {QueryResults} */
|
|
328
|
+
const deferred = {
|
|
329
|
+
columns,
|
|
330
|
+
numRows: plan.hints.where ? undefined : size,
|
|
331
|
+
maxRows: size,
|
|
332
|
+
async *rows() {
|
|
333
|
+
context.signal?.throwIfAborted()
|
|
334
|
+
yield* executeScan(plan, { ...context, deferScans: false }).rows()
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
// Preserve native batch execution above a deferred input. Legacy fallback
|
|
338
|
+
// remains at the source boundary if a source declines its column path.
|
|
339
|
+
if (table.prepareScan && table.schema || table.scanColumn && plan.hints.columns?.length === 1) {
|
|
340
|
+
deferred.batches = async function* deferredBatches() {
|
|
341
|
+
context.signal?.throwIfAborted()
|
|
342
|
+
const result = executeScan(plan, { ...context, deferScans: false })
|
|
343
|
+
if (result.batches) {
|
|
344
|
+
// Prepared sources may return extra fields or a different order.
|
|
345
|
+
// Align vectors with the metadata exposed before opening the scan.
|
|
346
|
+
const indexes = columns.map(column => result.columns.indexOf(column))
|
|
347
|
+
for await (const batch of result.batches()) {
|
|
348
|
+
yield { ...batch, columns: indexes.map(index => batch.columns[index]) }
|
|
349
|
+
}
|
|
350
|
+
} else yield* rowsToBatches(result.rows(), columns, { signal })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return deferred
|
|
354
|
+
}
|
|
321
355
|
const { where, residual } = scanFilter(plan, dataSourceColumns(table))
|
|
322
356
|
const hints = { ...plan.hints, where }
|
|
323
357
|
if (residual) {
|
package/src/execute/join.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
2
2
|
import { keyify, maxBounds } from './utils.js'
|
|
3
3
|
import { executePlan } from './execute.js'
|
|
4
|
+
import { dynamicFilter, filterScore } from './dynamicFilter.js'
|
|
4
5
|
import { yieldToEventLoop } from './yield.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -222,14 +223,18 @@ export function executePositionalJoin(plan, context) {
|
|
|
222
223
|
* @returns {QueryResults}
|
|
223
224
|
*/
|
|
224
225
|
export function executeHashJoin(plan, context) {
|
|
225
|
-
const left = executePlan({ plan: plan.left, context })
|
|
226
|
-
const right = executePlan({ plan: plan.right, context })
|
|
227
|
-
//
|
|
228
|
-
//
|
|
226
|
+
const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
|
|
227
|
+
const right = executePlan({ plan: plan.right, context: { ...context, deferScans: true } })
|
|
228
|
+
// Prefer exact sizes, then predicate selectivity, then upper bounds.
|
|
229
|
+
// A filtered scan's table size is only an upper bound, not its cardinality.
|
|
229
230
|
// Swapping reorders output rows, which SQL leaves unspecified.
|
|
230
231
|
const leftSize = left.numRows ?? left.maxRows
|
|
231
232
|
const rightSize = right.numRows ?? right.maxRows
|
|
232
|
-
const swap =
|
|
233
|
+
const swap = left.numRows !== undefined && right.numRows !== undefined
|
|
234
|
+
? leftSize < rightSize
|
|
235
|
+
: filterScore(plan.left) !== filterScore(plan.right)
|
|
236
|
+
? filterScore(plan.left) > filterScore(plan.right)
|
|
237
|
+
: leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
|
|
233
238
|
return {
|
|
234
239
|
columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
|
|
235
240
|
async *rows() {
|
|
@@ -237,7 +242,7 @@ export function executeHashJoin(plan, context) {
|
|
|
237
242
|
const rightTable = plan.rightAlias
|
|
238
243
|
const { residual } = plan
|
|
239
244
|
const build = swap ? left : right
|
|
240
|
-
|
|
245
|
+
let probe = swap ? right : left
|
|
241
246
|
const buildKeys = swap ? plan.leftKeys : plan.rightKeys
|
|
242
247
|
const probeKeys = swap ? plan.rightKeys : plan.leftKeys
|
|
243
248
|
// Which sides must also emit their unmatched rows
|
|
@@ -260,6 +265,7 @@ export function executeHashJoin(plan, context) {
|
|
|
260
265
|
// otherwise rows with NULL join keys are released immediately.
|
|
261
266
|
/** @type {Map<string | number | bigint | boolean, AsyncRow[]>} */
|
|
262
267
|
const hashMap = new Map()
|
|
268
|
+
const filters = dynamicFilter(buildKeys.length, true)
|
|
263
269
|
/** @type {AsyncRow[] | undefined} */
|
|
264
270
|
const buildRows = buildOuter ? [] : undefined
|
|
265
271
|
/** @type {string[]} */
|
|
@@ -281,6 +287,7 @@ export function executeHashJoin(plan, context) {
|
|
|
281
287
|
// SQL semantics: NULL never equals anything, so a row with any NULL
|
|
282
288
|
// join key is excluded from the hash table.
|
|
283
289
|
if (keyValues.some(v => v == null)) continue
|
|
290
|
+
if (!probeOuter) filters.add(keyValues)
|
|
284
291
|
const key = keyify(...keyValues)
|
|
285
292
|
let bucket = hashMap.get(key)
|
|
286
293
|
if (!bucket) {
|
|
@@ -295,8 +302,14 @@ export function executeHashJoin(plan, context) {
|
|
|
295
302
|
/** @type {Set<AsyncRow> | undefined} */
|
|
296
303
|
const matchedBuildRows = buildOuter ? new Set() : undefined
|
|
297
304
|
|
|
305
|
+
if (!probeOuter && !hashMap.size && !buildOuter) return
|
|
306
|
+
if (!probeOuter && hashMap.size) {
|
|
307
|
+
const filtered = filters.apply(swap ? plan.right : plan.left, probeKeys)
|
|
308
|
+
probe = executePlan({ plan: filtered, context: { ...context, deferScans: true } })
|
|
309
|
+
}
|
|
310
|
+
|
|
298
311
|
// Probe phase: stream the other side
|
|
299
|
-
for await (const probeRow of probe.rows()) {
|
|
312
|
+
for await (const probeRow of !probeOuter && !hashMap.size ? [] : probe.rows()) {
|
|
300
313
|
context.signal?.throwIfAborted()
|
|
301
314
|
|
|
302
315
|
if (!probeCols) {
|
package/src/execute/semiJoin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
2
2
|
import { executePlan } from './execute.js'
|
|
3
|
+
import { dynamicFilter } from './dynamicFilter.js'
|
|
3
4
|
import { sqlEquals } from './utils.js'
|
|
4
5
|
import { yieldToEventLoop } from './yield.js'
|
|
5
6
|
|
|
@@ -19,7 +20,7 @@ import { yieldToEventLoop } from './yield.js'
|
|
|
19
20
|
*/
|
|
20
21
|
export function executeSemiJoin(plan, context) {
|
|
21
22
|
if (plan.keys) return executeExistsJoin(plan, context)
|
|
22
|
-
const left = executePlan({ plan: plan.left, context })
|
|
23
|
+
const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
|
|
23
24
|
return {
|
|
24
25
|
columns: left.columns,
|
|
25
26
|
maxRows: left.maxRows,
|
|
@@ -28,29 +29,30 @@ export function executeSemiJoin(plan, context) {
|
|
|
28
29
|
const values = new Map()
|
|
29
30
|
let hasNull = false
|
|
30
31
|
let count = 0
|
|
31
|
-
let built = false
|
|
32
32
|
let rowIndex = 0
|
|
33
|
-
|
|
33
|
+
const filters = dynamicFilter(1)
|
|
34
|
+
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
35
|
+
context.signal?.throwIfAborted()
|
|
36
|
+
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
37
|
+
const value = await inner.cells[inner.columns[0]]()
|
|
38
|
+
if (value == null) hasNull = true
|
|
39
|
+
else {
|
|
40
|
+
const type = typeof value
|
|
41
|
+
let group = values.get(type)
|
|
42
|
+
if (!group) values.set(type, group = new Set())
|
|
43
|
+
group.add(value)
|
|
44
|
+
if (!plan.not) filters.add([value])
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!plan.not && !values.size || plan.not && hasNull) return
|
|
48
|
+
const probe = executePlan({
|
|
49
|
+
plan: plan.not ? plan.left : filters.apply(plan.left, [plan.expr]),
|
|
50
|
+
context: { ...context, deferScans: true },
|
|
51
|
+
})
|
|
52
|
+
for await (const row of probe.rows()) {
|
|
34
53
|
context.signal?.throwIfAborted()
|
|
35
54
|
if (++rowIndex % 4000 === 0) await yieldToEventLoop()
|
|
36
55
|
const value = await evaluateExpr({ node: plan.expr, row, rowIndex, context })
|
|
37
|
-
if (!built) {
|
|
38
|
-
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
39
|
-
if (++count % 4000 === 0) {
|
|
40
|
-
await yieldToEventLoop()
|
|
41
|
-
context.signal?.throwIfAborted()
|
|
42
|
-
}
|
|
43
|
-
const value = await inner.cells[inner.columns[0]]()
|
|
44
|
-
if (value == null) hasNull = true
|
|
45
|
-
else {
|
|
46
|
-
const type = typeof value
|
|
47
|
-
let group = values.get(type)
|
|
48
|
-
if (!group) values.set(type, group = new Set())
|
|
49
|
-
group.add(value)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
built = true
|
|
53
|
-
}
|
|
54
56
|
const matched = count === 0 ? false : value == null ? null : contains(values, value) ? true : hasNull ? null : false
|
|
55
57
|
if (matched !== null && matched !== plan.not) yield row
|
|
56
58
|
}
|
|
@@ -87,36 +89,39 @@ function contains(values, value) {
|
|
|
87
89
|
* @returns {QueryResults}
|
|
88
90
|
*/
|
|
89
91
|
function executeExistsJoin(plan, context) {
|
|
90
|
-
const left = executePlan({ plan: plan.left, context })
|
|
92
|
+
const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
|
|
91
93
|
return {
|
|
92
94
|
columns: left.columns,
|
|
93
95
|
maxRows: left.maxRows,
|
|
94
96
|
async *rows() {
|
|
95
97
|
/** @type {KeyTrie} */
|
|
96
98
|
const root = { groups: new Map() }
|
|
97
|
-
let built = false
|
|
98
99
|
let count = 0
|
|
99
|
-
|
|
100
|
+
const filters = dynamicFilter(plan.keys?.length ?? 0)
|
|
101
|
+
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
100
102
|
context.signal?.throwIfAborted()
|
|
101
103
|
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
let child = group.get(value)
|
|
114
|
-
if (!child) group.set(value, child = { groups: new Map() })
|
|
115
|
-
node = child
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
built = true
|
|
104
|
+
const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
|
|
105
|
+
if (values.some(value => value == null)) continue
|
|
106
|
+
if (!plan.not) filters.add(values)
|
|
107
|
+
let node = root
|
|
108
|
+
for (const value of values) {
|
|
109
|
+
const type = typeof value
|
|
110
|
+
let group = node.groups.get(type)
|
|
111
|
+
if (!group) node.groups.set(type, group = new Map())
|
|
112
|
+
let child = group.get(value)
|
|
113
|
+
if (!child) group.set(value, child = { groups: new Map() })
|
|
114
|
+
node = child
|
|
119
115
|
}
|
|
116
|
+
}
|
|
117
|
+
if (!plan.not && !root.groups.size) return
|
|
118
|
+
const probe = executePlan({
|
|
119
|
+
plan: plan.not ? plan.left : filters.apply(plan.left, plan.keys ?? []),
|
|
120
|
+
context: { ...context, deferScans: true },
|
|
121
|
+
})
|
|
122
|
+
for await (const row of probe.rows()) {
|
|
123
|
+
context.signal?.throwIfAborted()
|
|
124
|
+
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
120
125
|
const values = await Promise.all((plan.keys ?? []).map(node => evaluateExpr({ node, row, context })))
|
|
121
126
|
const matched = !values.some(value => value == null) && matchesKeys(root, values, 0)
|
|
122
127
|
if (matched !== plan.not) yield row
|
|
@@ -24,7 +24,7 @@ const CHUNK_SIZE = 4000
|
|
|
24
24
|
// Aggregate functions whose state can be accumulated one row at a time with
|
|
25
25
|
// bounded memory. Aggregates outside this set (MEDIAN, ARRAY_AGG, STDDEV, ...)
|
|
26
26
|
// need the full value set, so their queries buffer rows instead.
|
|
27
|
-
const STREAMABLE_FUNCS = new Set(['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX'])
|
|
27
|
+
const STREAMABLE_FUNCS = new Set(['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'MAX_BY', 'ARG_MIN', 'ARG_MAX'])
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Specs are keyed by aggregate node identity, so two aggregates that differ
|
|
@@ -178,6 +178,7 @@ export function planStreamingAggregates({ columns, having, orderBy, groupBy }, c
|
|
|
178
178
|
}
|
|
179
179
|
if (!STREAMABLE_FUNCS.has(funcName)) return false
|
|
180
180
|
const star = node.args[0]?.type === 'star'
|
|
181
|
+
if (star && node.args.length > 1) return false
|
|
181
182
|
if (lazy && !(star && !node.filter)) return false
|
|
182
183
|
if (!star && !node.args.every(arg => isScalarExpr(arg))) return false
|
|
183
184
|
if (node.filter && !isScalarExpr(node.filter)) return false
|
|
@@ -401,7 +402,7 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
|
|
|
401
402
|
rows: chunk,
|
|
402
403
|
signal: context.signal,
|
|
403
404
|
// One flat tuple per row: group key values, then per spec its FILTER
|
|
404
|
-
// result and its
|
|
405
|
+
// result and its arguments. Arguments are only evaluated for rows that
|
|
405
406
|
// pass the FILTER, matching the buffered path.
|
|
406
407
|
evaluate(row) {
|
|
407
408
|
const pending = groupBy.map(expr => evaluateExpr({ node: expr, row, context }))
|
|
@@ -410,10 +411,14 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
|
|
|
410
411
|
const passes = evaluateExpr({ node: node.filter, row, context })
|
|
411
412
|
pending.push(passes)
|
|
412
413
|
if (!star) {
|
|
413
|
-
|
|
414
|
+
for (const argument of node.args) {
|
|
415
|
+
pending.push(passes.then(pass => pass ? evaluateExpr({ node: argument, row, context }) : null))
|
|
416
|
+
}
|
|
414
417
|
}
|
|
415
418
|
} else if (!star) {
|
|
416
|
-
|
|
419
|
+
for (const argument of node.args) {
|
|
420
|
+
pending.push(evaluateExpr({ node: argument, row, context }))
|
|
421
|
+
}
|
|
417
422
|
}
|
|
418
423
|
}
|
|
419
424
|
return Promise.all(pending)
|
|
@@ -431,11 +436,12 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
|
|
|
431
436
|
const spec = specs[s]
|
|
432
437
|
const passes = spec.node.filter ? values[slot++] : true
|
|
433
438
|
const arg = spec.star ? null : values[slot++]
|
|
439
|
+
const comparison = spec.node.args.length > 1 ? values[slot++] : undefined
|
|
434
440
|
if (!passes) continue
|
|
435
441
|
if (spec.star && spec.funcName === 'COUNT') {
|
|
436
442
|
group.accumulators[s].count++
|
|
437
443
|
} else {
|
|
438
|
-
updateAccumulator(spec.funcName, group.accumulators[s], arg)
|
|
444
|
+
updateAccumulator(spec.funcName, group.accumulators[s], arg, comparison)
|
|
439
445
|
}
|
|
440
446
|
}
|
|
441
447
|
},
|
|
@@ -470,16 +476,18 @@ function compileBatchAggregateInputs(groupBy, specs, columns, context) {
|
|
|
470
476
|
for (const spec of specs) {
|
|
471
477
|
if (spec.node.filter && !spec.star) return undefined
|
|
472
478
|
if (spec.node.filter && referencesRowScope(spec.node.filter, columns, context)) return undefined
|
|
473
|
-
if (!spec.star &&
|
|
479
|
+
if (!spec.star && spec.node.args.some(argument => referencesRowScope(argument, columns, context))) return undefined
|
|
474
480
|
const filter = spec.node.filter
|
|
475
481
|
? compileBatchExpression(spec.node.filter, columns)
|
|
476
482
|
: undefined
|
|
477
483
|
const argument = spec.star
|
|
478
484
|
? undefined
|
|
479
485
|
: compileBatchExpression(spec.node.args[0], columns)
|
|
480
|
-
|
|
486
|
+
const comparison = spec.node.args[1] && compileBatchExpression(spec.node.args[1], columns)
|
|
487
|
+
if (spec.node.filter && !filter || !spec.star && !argument || spec.node.args[1] && !comparison) return undefined
|
|
481
488
|
filters.push(filter)
|
|
482
489
|
args.push(argument)
|
|
490
|
+
if (spec.node.args[1]) args.push(comparison)
|
|
483
491
|
}
|
|
484
492
|
return {
|
|
485
493
|
keys,
|
|
@@ -563,15 +571,18 @@ async function accumulateBatch({ batch, inputs, specs, groups, context, rowOffse
|
|
|
563
571
|
}
|
|
564
572
|
groups.set(key, group)
|
|
565
573
|
}
|
|
574
|
+
let argumentIndex = 0
|
|
566
575
|
for (let specIndex = 0; specIndex < specs.length; specIndex++) {
|
|
576
|
+
const spec = specs[specIndex]
|
|
577
|
+
const argument = args[argumentIndex++]
|
|
578
|
+
const comparison = spec.node.args[1] ? args[argumentIndex++] : undefined
|
|
567
579
|
const filter = filters[specIndex]
|
|
568
580
|
if (filter && !valueAt(filter, rowIndex)) continue
|
|
569
|
-
const spec = specs[specIndex]
|
|
570
581
|
if (spec.star && spec.funcName === 'COUNT') {
|
|
571
582
|
group.accumulators[specIndex].count++
|
|
572
583
|
} else {
|
|
573
|
-
|
|
574
|
-
|
|
584
|
+
updateAccumulator(spec.funcName, group.accumulators[specIndex],
|
|
585
|
+
argument ? valueAt(argument, rowIndex) : null, comparison ? valueAt(comparison, rowIndex) : undefined)
|
|
575
586
|
}
|
|
576
587
|
}
|
|
577
588
|
}
|
package/src/expression/batch.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isStringFunc } from '../validation/functions.js'
|
|
|
5
5
|
import { ColumnNotFoundError } from '../validation/tables.js'
|
|
6
6
|
import { applyBinaryOp } from './binary.js'
|
|
7
7
|
import { evaluateRegexpLike } from './regexp.js'
|
|
8
|
-
import { applyCast, evaluateJsonExtract } from './scalar.js'
|
|
8
|
+
import { applyCast, applyUnaryOp, evaluateJsonExtract } from './scalar.js'
|
|
9
9
|
import { evaluateStringFunc } from './strings.js'
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -52,10 +52,7 @@ function compileEvaluator(node, columns) {
|
|
|
52
52
|
const vector = await argument.evaluate(context)
|
|
53
53
|
return evaluateValues(context, function unaryValue(rowIndex) {
|
|
54
54
|
const value = valueAt(vector, rowIndex)
|
|
55
|
-
|
|
56
|
-
if (node.op === 'NOT') return value == null ? null : !value
|
|
57
|
-
if (node.op === 'IS NULL') return value == null
|
|
58
|
-
return value != null
|
|
55
|
+
return applyUnaryOp(node.op, value)
|
|
59
56
|
})
|
|
60
57
|
},
|
|
61
58
|
}
|
|
@@ -184,10 +181,7 @@ function compileValueKernel(node, state) {
|
|
|
184
181
|
if (!argument) return undefined
|
|
185
182
|
return function unaryValue(vectors, rowIndex, streamRowIndex) {
|
|
186
183
|
const value = argument(vectors, rowIndex, streamRowIndex)
|
|
187
|
-
|
|
188
|
-
if (node.op === 'NOT') return value == null ? null : !value
|
|
189
|
-
if (node.op === 'IS NULL') return value == null
|
|
190
|
-
return value != null
|
|
184
|
+
return applyUnaryOp(node.op, value)
|
|
191
185
|
}
|
|
192
186
|
}
|
|
193
187
|
|
|
@@ -278,7 +272,7 @@ function compileFunctionKernel(node, state) {
|
|
|
278
272
|
return sqlEquals(first, second) ? null : first
|
|
279
273
|
}
|
|
280
274
|
}
|
|
281
|
-
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
|
|
275
|
+
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
|
|
282
276
|
return function jsonValue(vectors, rowIndex, streamRowIndex) {
|
|
283
277
|
const args = arguments_.map(function argumentValue(argument) {
|
|
284
278
|
return argument(vectors, rowIndex, streamRowIndex)
|
|
@@ -327,7 +321,7 @@ function compileFunctionEvaluator(node, columns) {
|
|
|
327
321
|
}
|
|
328
322
|
}
|
|
329
323
|
if (funcName !== 'NULLIF' && funcName !== 'JSON_VALUE' && funcName !== 'JSON_QUERY' &&
|
|
330
|
-
funcName !== 'JSON_EXTRACT' && funcName !== 'JSON_EXTRACT_STRING' && funcName !== 'REGEXP_LIKE' &&
|
|
324
|
+
funcName !== 'JSON_EXTRACT' && funcName !== 'JSON_EXTRACT_STRING' && funcName !== 'JSON_EXTRACT_SCALAR' && funcName !== 'REGEXP_LIKE' &&
|
|
331
325
|
!isStringFunc(funcName)) return undefined
|
|
332
326
|
const regexpCache = funcName === 'REGEXP_LIKE' && node.args[1]?.type === 'literal' ? {} : undefined
|
|
333
327
|
return {
|
|
@@ -338,7 +332,7 @@ function compileFunctionEvaluator(node, columns) {
|
|
|
338
332
|
return evaluateValues(context, function functionValue(rowIndex, streamRowIndex) {
|
|
339
333
|
const args = vectors.map(function argumentValue(vector) { return valueAt(vector, rowIndex) })
|
|
340
334
|
if (funcName === 'NULLIF') return sqlEquals(args[0], args[1]) ? null : args[0]
|
|
341
|
-
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
|
|
335
|
+
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
|
|
342
336
|
return evaluateJsonExtract({ funcName, node, args, rowIndex: streamRowIndex + 1 })
|
|
343
337
|
}
|
|
344
338
|
if (funcName === 'REGEXP_LIKE') {
|
|
@@ -10,7 +10,7 @@ import { applyBinaryOp } from './binary.js'
|
|
|
10
10
|
import { applyIntervalToDate, dateDiff, dateTrunc, extractField } from './date.js'
|
|
11
11
|
import { evaluateMathFunc } from './math.js'
|
|
12
12
|
import { evaluateRegexpFunc } from './regexp.js'
|
|
13
|
-
import { applyCast, evaluateJsonExtract } from './scalar.js'
|
|
13
|
+
import { applyCast, applyUnaryOp, evaluateJsonExtract } from './scalar.js'
|
|
14
14
|
import { evaluateSpatialFunc } from '../spatial/spatial.js'
|
|
15
15
|
import { evaluateStringFunc } from './strings.js'
|
|
16
16
|
|
|
@@ -171,15 +171,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
171
171
|
// Unary operators
|
|
172
172
|
if (node.type === 'unary') {
|
|
173
173
|
const val = await evaluateExpr({ node: node.argument, row, rowIndex, rows, context })
|
|
174
|
-
|
|
175
|
-
if (val == null) return null
|
|
176
|
-
return -val
|
|
177
|
-
}
|
|
178
|
-
// NOT UNKNOWN is UNKNOWN: JS ! would flip null to true and return rows
|
|
179
|
-
// SQL excludes, e.g. WHERE NOT (nullable < 300)
|
|
180
|
-
if (node.op === 'NOT') return val == null ? null : !val
|
|
181
|
-
if (node.op === 'IS NULL') return val == null
|
|
182
|
-
if (node.op === 'IS NOT NULL') return val != null
|
|
174
|
+
return applyUnaryOp(node.op, val)
|
|
183
175
|
}
|
|
184
176
|
|
|
185
177
|
// Binary operators
|
|
@@ -411,9 +403,12 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
411
403
|
}
|
|
412
404
|
}
|
|
413
405
|
|
|
414
|
-
if (funcName === 'STRING_AGG') {
|
|
406
|
+
if (funcName === 'STRING_AGG' || funcName === 'GROUP_CONCAT') {
|
|
415
407
|
const separatorNode = node.args[1]
|
|
416
|
-
const
|
|
408
|
+
const separatorValue = separatorNode
|
|
409
|
+
? await evaluateExpr({ node: separatorNode, row: filteredRows[0] ?? { columns: [], cells: {} }, context })
|
|
410
|
+
: ','
|
|
411
|
+
const separator = funcName === 'GROUP_CONCAT' && separatorValue == null ? '' : String(separatorValue)
|
|
417
412
|
/** @type {string[]} */
|
|
418
413
|
const values = []
|
|
419
414
|
const allValues = await evaluateAll(argNode, filteredRows, context)
|
|
@@ -506,15 +501,15 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
506
501
|
}
|
|
507
502
|
|
|
508
503
|
if (funcName === 'CURRENT_DATE') {
|
|
509
|
-
return new Date().toISOString().split('T')[0]
|
|
504
|
+
return (context.currentTimestamp ?? new Date().toISOString()).split('T')[0]
|
|
510
505
|
}
|
|
511
506
|
|
|
512
507
|
if (funcName === 'CURRENT_TIME') {
|
|
513
|
-
return new Date().toISOString().split('T')[1].replace('Z', '')
|
|
508
|
+
return (context.currentTimestamp ?? new Date().toISOString()).split('T')[1].replace('Z', '')
|
|
514
509
|
}
|
|
515
510
|
|
|
516
|
-
if (funcName === 'CURRENT_TIMESTAMP') {
|
|
517
|
-
return new Date().toISOString()
|
|
511
|
+
if (funcName === 'CURRENT_TIMESTAMP' || funcName === 'NOW') {
|
|
512
|
+
return context.currentTimestamp ?? new Date().toISOString()
|
|
518
513
|
}
|
|
519
514
|
|
|
520
515
|
if (funcName === 'JSON_OBJECT') {
|
|
@@ -676,7 +671,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
676
671
|
})
|
|
677
672
|
}
|
|
678
673
|
|
|
679
|
-
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
|
|
674
|
+
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
|
|
680
675
|
return evaluateJsonExtract({ funcName, node, args, rowIndex })
|
|
681
676
|
}
|
|
682
677
|
|
package/src/expression/scalar.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ArgValueError, ExecutionError } from '../validation/executionErrors.js'
|
|
|
3
3
|
import { toDate } from './date.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* @import { CastNode, FunctionNode, SqlPrimitive } from '../types.js'
|
|
6
|
+
* @import { CastNode, FunctionNode, SqlPrimitive, UnaryNode } from '../types.js'
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -72,7 +72,7 @@ export function evaluateJsonExtract({ funcName, node, args, rowIndex }) {
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
if (typeof json !== 'object' || json instanceof Date) {
|
|
75
|
+
if (funcName !== 'JSON_EXTRACT_SCALAR' && typeof json !== 'object' || json instanceof Date) {
|
|
76
76
|
throw new ArgValueError({
|
|
77
77
|
...node,
|
|
78
78
|
message: `first argument must be JSON string or object, got ${typeof json}`,
|
|
@@ -98,8 +98,28 @@ export function evaluateJsonExtract({ funcName, node, args, rowIndex }) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
if (current == null) return null
|
|
101
|
+
if (funcName === 'JSON_EXTRACT_SCALAR') {
|
|
102
|
+
return typeof current === 'object' ? null : String(current)
|
|
103
|
+
}
|
|
101
104
|
if (funcName === 'JSON_EXTRACT_STRING') {
|
|
102
105
|
return typeof current === 'object' ? JSON.stringify(current) : String(current)
|
|
103
106
|
}
|
|
104
107
|
return current
|
|
105
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Applies unary predicates identically in row and batch execution.
|
|
112
|
+
* @param {UnaryNode['op']} op
|
|
113
|
+
* @param {SqlPrimitive} value
|
|
114
|
+
* @returns {SqlPrimitive}
|
|
115
|
+
*/
|
|
116
|
+
export function applyUnaryOp(op, value) {
|
|
117
|
+
if (op === '-') return value == null ? null : -value
|
|
118
|
+
if (op === 'NOT') return value == null ? null : !value
|
|
119
|
+
if (op === 'IS NULL') return value == null
|
|
120
|
+
if (op === 'IS NOT NULL') return value != null
|
|
121
|
+
if (op === 'IS TRUE') return value === true
|
|
122
|
+
if (op === 'IS NOT TRUE') return value !== true
|
|
123
|
+
if (op === 'IS FALSE') return value === false
|
|
124
|
+
return value !== false
|
|
125
|
+
}
|
|
@@ -35,6 +35,17 @@ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
|
|
|
35
35
|
const [val] = args
|
|
36
36
|
if (val == null) return null
|
|
37
37
|
|
|
38
|
+
if (funcName === 'CHR') {
|
|
39
|
+
const codePoint = typeof val === 'number' || typeof val === 'bigint' ? Number(val) : NaN
|
|
40
|
+
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff ||
|
|
41
|
+
codePoint >= 0xd800 && codePoint <= 0xdfff) {
|
|
42
|
+
throw new ArgValueError({
|
|
43
|
+
...node, message: 'expected a Unicode scalar value (0..0x10ffff, excluding surrogates)', rowIndex,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return String.fromCodePoint(codePoint)
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
if (funcName === 'LENGTH') {
|
|
39
50
|
if (typeof val === 'string' || Array.isArray(val)) return val.length
|
|
40
51
|
throw new ArgValueError({
|
package/src/internalTypes.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface CompileState {
|
|
|
23
23
|
export interface BatchAggregateInputs {
|
|
24
24
|
keys: CompiledBatchExpression[]
|
|
25
25
|
filters: (CompiledBatchExpression | undefined)[]
|
|
26
|
+
// One value slot per aggregate, followed by its key for MIN_BY/MAX_BY.
|
|
26
27
|
args: (CompiledBatchExpression | undefined)[]
|
|
27
28
|
}
|
|
28
29
|
|
package/src/parse/expression.js
CHANGED
|
@@ -5,7 +5,7 @@ import { parseStatement } from './parse.js'
|
|
|
5
5
|
import { consume, current, expect, match } from './state.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @import { ExprNode, ParserState } from '../types.js'
|
|
8
|
+
* @import { ExprNode, ParserState, UnaryNode } from '../types.js'
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
// Precedence (lowest to highest):
|
|
@@ -91,10 +91,18 @@ function parseComparison(state) {
|
|
|
91
91
|
const left = parseConcat(state)
|
|
92
92
|
const { positionStart } = left
|
|
93
93
|
|
|
94
|
-
// IS [NOT] NULL
|
|
94
|
+
// IS [NOT] NULL/TRUE/FALSE
|
|
95
95
|
if (match(state, 'keyword', 'IS')) {
|
|
96
|
-
const
|
|
97
|
-
|
|
96
|
+
const not = match(state, 'keyword', 'NOT')
|
|
97
|
+
const target = current(state)
|
|
98
|
+
if (target.type !== 'keyword' || !['NULL', 'TRUE', 'FALSE'].includes(target.value)) {
|
|
99
|
+
throw new SyntaxError({ expected: 'NULL, TRUE, or FALSE', after: not ? 'NOT' : 'IS', ...target })
|
|
100
|
+
}
|
|
101
|
+
consume(state)
|
|
102
|
+
/** @type {UnaryNode['op']} */
|
|
103
|
+
const op = target.value === 'TRUE' ? not ? 'IS NOT TRUE' : 'IS TRUE'
|
|
104
|
+
: target.value === 'FALSE' ? not ? 'IS NOT FALSE' : 'IS FALSE'
|
|
105
|
+
: not ? 'IS NOT NULL' : 'IS NULL'
|
|
98
106
|
return {
|
|
99
107
|
type: 'unary',
|
|
100
108
|
op,
|
package/src/types.d.ts
CHANGED
|
@@ -212,6 +212,10 @@ export interface ExecuteContext {
|
|
|
212
212
|
// CTE references in subqueries re-planned during execution
|
|
213
213
|
ctePlans?: Map<string, QueryPlan>
|
|
214
214
|
cteColumns?: Map<string, string[]>
|
|
215
|
+
// UTC timestamp shared by current-time functions throughout one execution.
|
|
216
|
+
currentTimestamp?: string
|
|
217
|
+
// Delay opening join input scans until their rows are consumed.
|
|
218
|
+
deferScans?: boolean
|
|
215
219
|
}
|
|
216
220
|
|
|
217
221
|
// AsyncRow represents a row with async cell values
|
|
@@ -312,7 +316,7 @@ export interface UserDefinedFunction {
|
|
|
312
316
|
arguments: FunctionSignature
|
|
313
317
|
}
|
|
314
318
|
|
|
315
|
-
export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'MIN_BY' | 'ARG_MIN' | 'MAX_BY' | 'ARG_MAX' | 'ANY_VALUE' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
|
|
319
|
+
export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'MIN_BY' | 'ARG_MIN' | 'MAX_BY' | 'ARG_MAX' | 'ANY_VALUE' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG' | 'GROUP_CONCAT'
|
|
316
320
|
|
|
317
321
|
export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES' | 'REGEXP_LIKE'
|
|
318
322
|
|
|
@@ -345,6 +349,7 @@ export type MathFunc =
|
|
|
345
349
|
| 'RANDOM'
|
|
346
350
|
|
|
347
351
|
export type StringFunc =
|
|
352
|
+
| 'CHR'
|
|
348
353
|
| 'UPPER'
|
|
349
354
|
| 'LOWER'
|
|
350
355
|
| 'CONCAT'
|
|
@@ -11,7 +11,7 @@ export const niladicFuncs = ['CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP'
|
|
|
11
11
|
* @returns {name is AggregateFunc}
|
|
12
12
|
*/
|
|
13
13
|
export function isAggregateFunc(name) {
|
|
14
|
-
return ['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'ARG_MIN', 'MAX_BY', 'ARG_MAX', 'ANY_VALUE', 'ARRAY_AGG', 'LIST', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
|
|
14
|
+
return ['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'ARG_MIN', 'MAX_BY', 'ARG_MAX', 'ANY_VALUE', 'ARRAY_AGG', 'LIST', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG', 'GROUP_CONCAT'].includes(name)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -96,7 +96,7 @@ export function isCastType(name) {
|
|
|
96
96
|
export function isStringFunc(name) {
|
|
97
97
|
return [
|
|
98
98
|
'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'OCTET_LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM',
|
|
99
|
-
'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS', 'SPLIT_PART', 'STRING_SPLIT',
|
|
99
|
+
'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS', 'SPLIT_PART', 'STRING_SPLIT', 'CHR',
|
|
100
100
|
].includes(name)
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -114,6 +114,7 @@ export function isBinaryOp(op) {
|
|
|
114
114
|
*/
|
|
115
115
|
export const FUNCTION_SIGNATURES = {
|
|
116
116
|
// String functions
|
|
117
|
+
CHR: { min: 1, max: 1, signature: 'code_point' },
|
|
117
118
|
UPPER: { min: 1, max: 1, signature: 'string' },
|
|
118
119
|
LOWER: { min: 1, max: 1, signature: 'string' },
|
|
119
120
|
LENGTH: { min: 1, max: 1, signature: 'string' },
|
|
@@ -137,6 +138,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
137
138
|
REGEXP_LIKE: { min: 2, max: 2, signature: 'string, pattern' },
|
|
138
139
|
|
|
139
140
|
// Date/time functions
|
|
141
|
+
NOW: { min: 0, max: 0, signature: '' },
|
|
140
142
|
RANDOM: { min: 0, max: 0, signature: '' },
|
|
141
143
|
RAND: { min: 0, max: 0, signature: '' },
|
|
142
144
|
CURRENT_DATE: { min: 0, max: 0, signature: '' },
|
|
@@ -176,6 +178,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
176
178
|
PI: { min: 0, max: 0, signature: '' },
|
|
177
179
|
|
|
178
180
|
// JSON functions
|
|
181
|
+
JSON_EXTRACT_SCALAR: { min: 2, max: 2, signature: 'expression, path' },
|
|
179
182
|
JSON_VALUE: { min: 2, max: 2, signature: 'expression, path' },
|
|
180
183
|
JSON_QUERY: { min: 2, max: 2, signature: 'expression, path' },
|
|
181
184
|
JSON_EXTRACT: { min: 2, max: 2, signature: 'expression, path' },
|
|
@@ -217,6 +220,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
217
220
|
LEAST: { min: 1, signature: 'value1[, value2, ...]' },
|
|
218
221
|
|
|
219
222
|
// Aggregate functions
|
|
223
|
+
GROUP_CONCAT: { min: 1, max: 2, signature: 'expression[, separator]' },
|
|
220
224
|
COUNT: { min: 1, max: 1, signature: 'expression' },
|
|
221
225
|
COUNTIF: { min: 1, max: 1, signature: 'condition' },
|
|
222
226
|
SUM: { min: 1, max: 1, signature: 'expression' },
|