squirreling 0.9.1 → 0.9.2
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 +7 -6
- package/package.json +3 -3
- package/src/backend/dataSource.js +1 -0
- package/src/execute/execute.js +44 -4
- package/src/expression/alias.js +1 -1
- package/src/expression/evaluate.js +5 -6
- package/src/expression/regexp.js +2 -2
- package/src/expression/strings.js +2 -2
- package/src/parse/expression.js +2 -0
- package/src/parse/functions.js +2 -3
- package/src/parseErrors.js +2 -2
- package/src/plan/columns.js +18 -38
- package/src/plan/plan.js +48 -19
- package/src/plan/types.d.ts +9 -1
- package/src/types.d.ts +6 -0
- package/src/validationErrors.js +1 -1
package/README.md
CHANGED
|
@@ -47,8 +47,8 @@ const asyncRows: AsyncIterable<AsyncRow> = executeSql({
|
|
|
47
47
|
})
|
|
48
48
|
|
|
49
49
|
// Process rows as they arrive (streaming)
|
|
50
|
-
for await (const {
|
|
51
|
-
console.log(`User id=${await id()}, name=${await name()}`)
|
|
50
|
+
for await (const { cells } of asyncRows) {
|
|
51
|
+
console.log(`User id=${await cells.id()}, name=${await cells.name()}`)
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -95,7 +95,7 @@ interface AsyncDataSource {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
interface ScanOptions {
|
|
98
|
-
columns?: string[]
|
|
98
|
+
columns?: string[] // columns to scan (undefined means all)
|
|
99
99
|
where?: ExprNode
|
|
100
100
|
limit?: number
|
|
101
101
|
offset?: number
|
|
@@ -128,11 +128,12 @@ const customSource: AsyncDataSource = {
|
|
|
128
128
|
|
|
129
129
|
Squirreling mostly follows the SQL standard. The following features are supported:
|
|
130
130
|
|
|
131
|
-
- `SELECT` statements with `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`
|
|
131
|
+
- `SELECT` statements with `DISTINCT`, `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`
|
|
132
132
|
- `WITH` clause for Common Table Expressions (CTEs)
|
|
133
133
|
- Subqueries in `SELECT`, `FROM`, and `WHERE` clauses
|
|
134
|
-
- `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `POSITIONAL JOIN`
|
|
134
|
+
- `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `CROSS JOIN`, `POSITIONAL JOIN`
|
|
135
135
|
- `GROUP BY` and `HAVING` clauses
|
|
136
|
+
- Expressions: `CASE`, `CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`
|
|
136
137
|
|
|
137
138
|
### Quoting
|
|
138
139
|
|
|
@@ -142,7 +143,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
142
143
|
|
|
143
144
|
### Functions
|
|
144
145
|
|
|
145
|
-
- Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG`
|
|
146
|
+
- Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `STDDEV_POP`, `STDDEV_SAMP`, `JSON_ARRAYAGG`
|
|
146
147
|
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`
|
|
147
148
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
148
149
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"test": "vitest run"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "25.
|
|
40
|
+
"@types/node": "25.3.0",
|
|
41
41
|
"@vitest/coverage-v8": "4.0.18",
|
|
42
42
|
"eslint": "9.39.2",
|
|
43
|
-
"eslint-plugin-jsdoc": "62.6.
|
|
43
|
+
"eslint-plugin-jsdoc": "62.6.1",
|
|
44
44
|
"typescript": "5.9.3",
|
|
45
45
|
"vitest": "4.0.18"
|
|
46
46
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -11,7 +11,7 @@ import { stableRowKey } from './utils.js'
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* @import { AsyncCells, AsyncDataSource, AsyncRow, ExecuteContext, ExecuteSqlOptions, ExprNode, SelectStatement } from '../types.js'
|
|
14
|
-
* @import { DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode } from '../plan/types.js'
|
|
14
|
+
* @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode } from '../plan/types.js'
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -61,6 +61,8 @@ export async function* executeSelect({ select, context }) {
|
|
|
61
61
|
export async function* executePlan({ plan, context }) {
|
|
62
62
|
if (plan.type === 'Scan') {
|
|
63
63
|
yield* executeScan(plan, context)
|
|
64
|
+
} else if (plan.type === 'Count') {
|
|
65
|
+
yield* executeCount(plan, context)
|
|
64
66
|
} else if (plan.type === 'Filter') {
|
|
65
67
|
yield* executeFilter(plan, context)
|
|
66
68
|
} else if (plan.type === 'Project') {
|
|
@@ -105,15 +107,15 @@ async function* executeScan(plan, context) {
|
|
|
105
107
|
const { rows, appliedWhere, appliedLimitOffset } = scanResult
|
|
106
108
|
|
|
107
109
|
// Applied limit/offset without applied where is invalid
|
|
108
|
-
const hasLimitOffset = plan.hints
|
|
109
|
-
if (!appliedWhere && appliedLimitOffset && plan.hints
|
|
110
|
+
const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
|
|
111
|
+
if (!appliedWhere && appliedLimitOffset && plan.hints.where && hasLimitOffset) {
|
|
110
112
|
throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
let result = rows
|
|
114
116
|
|
|
115
117
|
// Apply WHERE if data source did not
|
|
116
|
-
if (!appliedWhere && plan.hints
|
|
118
|
+
if (!appliedWhere && plan.hints.where) {
|
|
117
119
|
result = filterRows(result, plan.hints.where, context)
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -125,6 +127,44 @@ async function* executeScan(plan, context) {
|
|
|
125
127
|
yield* result
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Executes a Count node using numRows when available, falling back to scan
|
|
132
|
+
*
|
|
133
|
+
* @param {CountNode} plan
|
|
134
|
+
* @param {ExecuteContext} context
|
|
135
|
+
* @yields {AsyncRow}
|
|
136
|
+
*/
|
|
137
|
+
async function* executeCount(plan, { tables, signal }) {
|
|
138
|
+
const dataSource = tables[plan.table]
|
|
139
|
+
if (dataSource === undefined) {
|
|
140
|
+
throw tableNotFoundError({ tableName: plan.table })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Use source numRows if available
|
|
144
|
+
let count = dataSource.numRows
|
|
145
|
+
if (dataSource.numRows === undefined) {
|
|
146
|
+
// Fall back to counting rows via scan
|
|
147
|
+
count = 0
|
|
148
|
+
const { rows } = dataSource.scan({ signal })
|
|
149
|
+
// eslint-disable-next-line no-unused-vars
|
|
150
|
+
for await (const _ of rows) {
|
|
151
|
+
if (signal?.aborted) return
|
|
152
|
+
count++
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @type {string[]} */
|
|
157
|
+
const columns = []
|
|
158
|
+
/** @type {AsyncCells} */
|
|
159
|
+
const cells = {}
|
|
160
|
+
for (const col of plan.columns) {
|
|
161
|
+
const alias = col.alias ?? derivedAlias(col.expr)
|
|
162
|
+
columns.push(alias)
|
|
163
|
+
cells[alias] = () => Promise.resolve(count)
|
|
164
|
+
}
|
|
165
|
+
yield { columns, cells }
|
|
166
|
+
}
|
|
167
|
+
|
|
128
168
|
/**
|
|
129
169
|
* Filters rows by a condition
|
|
130
170
|
*
|
package/src/expression/alias.js
CHANGED
|
@@ -30,7 +30,7 @@ export function derivedAlias(expr) {
|
|
|
30
30
|
}
|
|
31
31
|
if (expr.type === 'function') {
|
|
32
32
|
// Handle aggregate functions with star (COUNT(*) -> count_all)
|
|
33
|
-
if (expr.args.length === 1 && expr.args[0].type === '
|
|
33
|
+
if (expr.args.length === 1 && expr.args[0].type === 'star') {
|
|
34
34
|
return expr.name.toLowerCase() + '_all'
|
|
35
35
|
}
|
|
36
36
|
return expr.name.toLowerCase() + '_' + expr.args.map(derivedAlias).join('_')
|
|
@@ -127,14 +127,13 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
// Handle COUNT(*) special case
|
|
131
|
-
if (node.args.length === 1 && node.args[0].type === 'identifier' && funcName === 'COUNT' && node.args[0].name === '*') {
|
|
132
|
-
return filteredRows.length
|
|
133
|
-
}
|
|
134
|
-
|
|
135
130
|
const argNode = node.args[0]
|
|
136
|
-
|
|
137
131
|
if (funcName === 'COUNT') {
|
|
132
|
+
// COUNT(*) special case
|
|
133
|
+
if (argNode.type === 'star') {
|
|
134
|
+
return filteredRows.length
|
|
135
|
+
}
|
|
136
|
+
|
|
138
137
|
if (node.distinct) {
|
|
139
138
|
const seen = new Set()
|
|
140
139
|
for (const row of filteredRows) {
|
package/src/expression/regexp.js
CHANGED
|
@@ -10,8 +10,8 @@ import { argValueError } from '../validationErrors.js'
|
|
|
10
10
|
* @param {Object} options
|
|
11
11
|
* @param {string} options.funcName - Uppercase function name
|
|
12
12
|
* @param {SqlPrimitive[]} options.args - Function arguments
|
|
13
|
-
* @param {number}
|
|
14
|
-
* @param {number}
|
|
13
|
+
* @param {number} options.positionStart - Start position in SQL string for error reporting
|
|
14
|
+
* @param {number} options.positionEnd - End position in SQL string for error reporting
|
|
15
15
|
* @param {number} [options.rowIndex] - Row number for error reporting
|
|
16
16
|
* @returns {SqlPrimitive}
|
|
17
17
|
*/
|
|
@@ -10,8 +10,8 @@ import { argValueError } from '../validationErrors.js'
|
|
|
10
10
|
* @param {Object} options
|
|
11
11
|
* @param {StringFunc} options.funcName - Uppercase function name
|
|
12
12
|
* @param {SqlPrimitive[]} options.args - Function arguments
|
|
13
|
-
* @param {number}
|
|
14
|
-
* @param {number}
|
|
13
|
+
* @param {number} options.positionStart - Start position for error reporting
|
|
14
|
+
* @param {number} options.positionEnd - End position for error reporting
|
|
15
15
|
* @param {number} [options.rowIndex] - Row index for error reporting
|
|
16
16
|
* @returns {SqlPrimitive}
|
|
17
17
|
*/
|
package/src/parse/expression.js
CHANGED
package/src/parse/functions.js
CHANGED
|
@@ -38,8 +38,7 @@ export function parseFunctionCall(state, funcName, positionStart) {
|
|
|
38
38
|
const starTok = current(state)
|
|
39
39
|
consume(state)
|
|
40
40
|
args.push({
|
|
41
|
-
type: '
|
|
42
|
-
name: '*',
|
|
41
|
+
type: 'star',
|
|
43
42
|
positionStart: starTok.positionStart,
|
|
44
43
|
positionEnd: state.lastPos,
|
|
45
44
|
})
|
|
@@ -74,7 +73,7 @@ export function parseFunctionCall(state, funcName, positionStart) {
|
|
|
74
73
|
|
|
75
74
|
// Validate star argument at parse time (only COUNT supports *)
|
|
76
75
|
const funcNameUpper = funcName.toUpperCase()
|
|
77
|
-
const hasStar = args.length === 1 && args[0].type === '
|
|
76
|
+
const hasStar = args.length === 1 && args[0].type === 'star'
|
|
78
77
|
if (hasStar && isAggregateFunc(funcNameUpper) && funcNameUpper !== 'COUNT') {
|
|
79
78
|
throw new ParseError({
|
|
80
79
|
message: `${funcName} cannot be applied to "*"`,
|
package/src/parseErrors.js
CHANGED
|
@@ -139,8 +139,8 @@ export function argCountParseError({ funcName, expected, received, positionStart
|
|
|
139
139
|
* @param {Object} options
|
|
140
140
|
* @param {string} options.missing - What is missing (e.g., 'WHEN clause', 'FROM clause', 'ON condition')
|
|
141
141
|
* @param {string} options.context - Where it's missing from (e.g., 'CASE expression', 'SELECT statement', 'JOIN')
|
|
142
|
-
* @param {number}
|
|
143
|
-
* @param {number}
|
|
142
|
+
* @param {number} options.positionStart - Start position in query
|
|
143
|
+
* @param {number} options.positionEnd - End position in query
|
|
144
144
|
* @returns {ParseError}
|
|
145
145
|
*/
|
|
146
146
|
export function missingClauseError({ missing, context, positionStart, positionEnd }) {
|
package/src/plan/columns.js
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
* @returns {Map<string, string[] | undefined>}
|
|
11
11
|
*/
|
|
12
12
|
export function extractColumns(select) {
|
|
13
|
+
/** @type {Map<string, string[] | undefined>} */
|
|
14
|
+
const result = new Map()
|
|
15
|
+
|
|
13
16
|
// Build alias list from FROM + JOINs
|
|
14
17
|
const fromAlias = select.from.kind === 'table'
|
|
15
18
|
? select.from.alias ?? select.from.table
|
|
16
19
|
: select.from.alias
|
|
17
|
-
/** @type {string[]} */
|
|
18
20
|
const aliases = [fromAlias]
|
|
19
21
|
for (const join of select.joins) {
|
|
20
22
|
aliases.push(join.alias ?? join.table)
|
|
@@ -30,20 +32,18 @@ export function extractColumns(select) {
|
|
|
30
32
|
return result
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
// Track
|
|
34
|
-
/** @type {Set<string>} */
|
|
35
|
-
const
|
|
36
|
-
for (const col of select.columns) {
|
|
37
|
-
if (col.kind === 'star' && col.table) {
|
|
38
|
-
allColumnsNeeded.add(col.table)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
35
|
+
// Track per-table columns needed; undefined means all columns (table.*)
|
|
36
|
+
/** @type {Map<string, Set<string> | undefined>} */
|
|
37
|
+
const perTable = new Map(aliases.map(alias => [alias, new Set()]))
|
|
41
38
|
|
|
42
39
|
// Collect all identifiers from all clauses
|
|
43
40
|
/** @type {Set<string>} */
|
|
44
41
|
const identifiers = new Set()
|
|
45
42
|
for (const col of select.columns) {
|
|
46
|
-
if (col.kind === '
|
|
43
|
+
if (col.kind === 'star' && col.table) {
|
|
44
|
+
// SELECT table.* means all columns needed
|
|
45
|
+
perTable.set(col.table, undefined)
|
|
46
|
+
} else if (col.kind === 'derived') {
|
|
47
47
|
collectColumnsFromExpr(col.expr, identifiers)
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -59,15 +59,6 @@ export function extractColumns(select) {
|
|
|
59
59
|
collectColumnsFromExpr(join.on, identifiers)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// Initialize per-table sets (skip tables needing all columns)
|
|
63
|
-
/** @type {Map<string, Set<string>>} */
|
|
64
|
-
const perTable = new Map()
|
|
65
|
-
for (const alias of aliases) {
|
|
66
|
-
if (!allColumnsNeeded.has(alias)) {
|
|
67
|
-
perTable.set(alias, new Set())
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
62
|
// Partition identifiers by table prefix
|
|
72
63
|
for (const name of identifiers) {
|
|
73
64
|
const dotIndex = name.indexOf('.')
|
|
@@ -76,27 +67,19 @@ export function extractColumns(select) {
|
|
|
76
67
|
const tablePrefix = name.substring(0, dotIndex)
|
|
77
68
|
const columnName = name.substring(dotIndex + 1)
|
|
78
69
|
const set = perTable.get(tablePrefix)
|
|
79
|
-
if (set)
|
|
80
|
-
set.add(columnName)
|
|
81
|
-
}
|
|
70
|
+
if (set) set.add(columnName)
|
|
82
71
|
} else {
|
|
83
72
|
// Unqualified: add to all tables (ambiguous)
|
|
84
73
|
for (const [, set] of perTable) {
|
|
85
|
-
set.add(name)
|
|
74
|
+
if (set) set.add(name)
|
|
86
75
|
}
|
|
87
76
|
}
|
|
88
77
|
}
|
|
89
78
|
|
|
90
79
|
// Build result map: convert Sets to arrays, undefined for all-columns tables
|
|
91
|
-
/** @type {Map<string, string[] | undefined>} */
|
|
92
|
-
const result = new Map()
|
|
93
80
|
for (const alias of aliases) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
} else {
|
|
97
|
-
const set = perTable.get(alias)
|
|
98
|
-
result.set(alias, set ? [...set] : undefined)
|
|
99
|
-
}
|
|
81
|
+
const set = perTable.get(alias)
|
|
82
|
+
result.set(alias, set ? [...set] : undefined)
|
|
100
83
|
}
|
|
101
84
|
return result
|
|
102
85
|
}
|
|
@@ -104,15 +87,13 @@ export function extractColumns(select) {
|
|
|
104
87
|
/**
|
|
105
88
|
* Recursively collects column names (identifiers) from an expression
|
|
106
89
|
*
|
|
107
|
-
* @param {ExprNode
|
|
90
|
+
* @param {ExprNode} expr
|
|
108
91
|
* @param {Set<string>} columns
|
|
109
92
|
*/
|
|
110
93
|
function collectColumnsFromExpr(expr, columns) {
|
|
111
94
|
if (!expr) return
|
|
112
|
-
if (expr.type === 'identifier'
|
|
95
|
+
if (expr.type === 'identifier') {
|
|
113
96
|
columns.add(expr.name)
|
|
114
|
-
} else if (expr.type === 'literal') {
|
|
115
|
-
// No columns
|
|
116
97
|
} else if (expr.type === 'binary') {
|
|
117
98
|
collectColumnsFromExpr(expr.left, columns)
|
|
118
99
|
collectColumnsFromExpr(expr.right, columns)
|
|
@@ -122,6 +103,7 @@ function collectColumnsFromExpr(expr, columns) {
|
|
|
122
103
|
for (const arg of expr.args) {
|
|
123
104
|
collectColumnsFromExpr(arg, columns)
|
|
124
105
|
}
|
|
106
|
+
collectColumnsFromExpr(expr.filter, columns)
|
|
125
107
|
} else if (expr.type === 'cast') {
|
|
126
108
|
collectColumnsFromExpr(expr.expr, columns)
|
|
127
109
|
} else if (expr.type === 'in valuelist') {
|
|
@@ -131,9 +113,6 @@ function collectColumnsFromExpr(expr, columns) {
|
|
|
131
113
|
}
|
|
132
114
|
} else if (expr.type === 'in') {
|
|
133
115
|
collectColumnsFromExpr(expr.expr, columns)
|
|
134
|
-
// Subquery columns are from a different scope, don't collect
|
|
135
|
-
} else if (expr.type === 'exists' || expr.type === 'not exists') {
|
|
136
|
-
// Subquery columns are from a different scope, don't collect
|
|
137
116
|
} else if (expr.type === 'case') {
|
|
138
117
|
if (expr.caseExpr) {
|
|
139
118
|
collectColumnsFromExpr(expr.caseExpr, columns)
|
|
@@ -146,4 +125,5 @@ function collectColumnsFromExpr(expr, columns) {
|
|
|
146
125
|
collectColumnsFromExpr(expr.elseResult, columns)
|
|
147
126
|
}
|
|
148
127
|
}
|
|
128
|
+
// No columns: count(*), literal, interval, exists, not exists, subquery
|
|
149
129
|
}
|
package/src/plan/plan.js
CHANGED
|
@@ -3,7 +3,7 @@ import { findAggregate } from '../validation.js'
|
|
|
3
3
|
import { extractColumns } from './columns.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* @import { ExprNode, JoinClause, PlanSqlOptions, ScanOptions, SelectStatement } from '../types.js'
|
|
6
|
+
* @import { ExprNode, DerivedColumn, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement } from '../types.js'
|
|
7
7
|
* @import { QueryPlan } from './types.d.ts'
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -51,11 +51,19 @@ function planSelect({ select, ctePlans }) {
|
|
|
51
51
|
? select.from.alias ?? select.from.table
|
|
52
52
|
: select.from.alias
|
|
53
53
|
|
|
54
|
-
// Determine
|
|
54
|
+
// Determine scan hints for direct table scans (WHERE and LIMIT/OFFSET are
|
|
55
|
+
// included so they are only applied to fresh scans, not CTE/subquery plans)
|
|
55
56
|
/** @type {ScanOptions} */
|
|
56
57
|
const hints = {}
|
|
57
58
|
const perTableColumns = extractColumns(select)
|
|
58
59
|
hints.columns = perTableColumns.get(sourceAlias)
|
|
60
|
+
if (!select.joins.length) {
|
|
61
|
+
hints.where = select.where
|
|
62
|
+
if (!needsBuffering && !select.distinct) {
|
|
63
|
+
hints.limit = select.limit
|
|
64
|
+
hints.offset = select.offset
|
|
65
|
+
}
|
|
66
|
+
}
|
|
59
67
|
|
|
60
68
|
// Start with the data source (FROM clause)
|
|
61
69
|
/** @type {QueryPlan} */
|
|
@@ -66,27 +74,24 @@ function planSelect({ select, ctePlans }) {
|
|
|
66
74
|
plan = planJoin({ left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, perTableColumns })
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
plan.hints.where = select.where
|
|
72
|
-
if (!needsBuffering && !select.distinct) {
|
|
73
|
-
plan.hints.limit = select.limit
|
|
74
|
-
plan.hints.offset = select.offset
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
+
// Whether FROM resolved to our own direct table scan
|
|
78
|
+
const isOwnScan = plan.type === 'Scan' && plan.hints === hints
|
|
77
79
|
|
|
78
|
-
// Add WHERE filter when scan
|
|
79
|
-
|
|
80
|
-
if (select.where && !isScan) {
|
|
80
|
+
// Add WHERE filter when the scan didn't receive it
|
|
81
|
+
if (select.where && !isOwnScan) {
|
|
81
82
|
plan = { type: 'Filter', condition: select.where, child: plan }
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
if (useGrouping) {
|
|
85
86
|
// Aggregation path: GROUP BY or scalar aggregate
|
|
86
87
|
// HAVING is integrated into aggregate nodes for access to group context
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
if (select.groupBy.length) {
|
|
89
|
+
plan = { type: 'HashAggregate', groupBy: select.groupBy, columns: select.columns, having: select.having, child: plan }
|
|
90
|
+
} else if (!select.having && !select.where && plan.type === 'Scan' && isOwnScan && isAllCountStar(select.columns)) {
|
|
91
|
+
plan = { type: 'Count', table: plan.table, columns: select.columns }
|
|
92
|
+
} else {
|
|
93
|
+
plan = { type: 'ScalarAggregate', columns: select.columns, having: select.having, child: plan }
|
|
94
|
+
}
|
|
90
95
|
|
|
91
96
|
// ORDER BY (after aggregation)
|
|
92
97
|
if (select.orderBy.length) {
|
|
@@ -99,7 +104,7 @@ function planSelect({ select, ctePlans }) {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
// LIMIT/OFFSET
|
|
102
|
-
if (select.limit !== undefined || select.offset
|
|
107
|
+
if (select.limit !== undefined || select.offset) {
|
|
103
108
|
plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
|
|
104
109
|
}
|
|
105
110
|
} else {
|
|
@@ -124,13 +129,18 @@ function planSelect({ select, ctePlans }) {
|
|
|
124
129
|
// DISTINCT needs to come after projection but before LIMIT
|
|
125
130
|
// However, for streaming distinct we need to project first
|
|
126
131
|
// So the order is: Sort -> Project -> Distinct -> Limit
|
|
127
|
-
|
|
132
|
+
|
|
133
|
+
// Fast path for SELECT *
|
|
134
|
+
const isPassthrough = select.columns.length === 1 && select.columns[0].kind === 'star'
|
|
135
|
+
if (!isPassthrough) {
|
|
136
|
+
plan = { type: 'Project', columns: select.columns, child: plan }
|
|
137
|
+
}
|
|
128
138
|
|
|
129
139
|
if (select.distinct) {
|
|
130
140
|
plan = { type: 'Distinct', child: plan }
|
|
131
141
|
}
|
|
132
142
|
|
|
133
|
-
if (!(
|
|
143
|
+
if (!(isOwnScan && !needsBuffering && !select.distinct) && (select.limit !== undefined || select.offset)) {
|
|
134
144
|
plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
|
|
135
145
|
}
|
|
136
146
|
}
|
|
@@ -302,3 +312,22 @@ function extractSimpleJoinKeys({ condition, leftTable, rightTable }) {
|
|
|
302
312
|
|
|
303
313
|
return { leftKey: left, rightKey: right }
|
|
304
314
|
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Checks if every SELECT column is a plain COUNT(*).
|
|
318
|
+
*
|
|
319
|
+
* @param {SelectColumn[]} columns
|
|
320
|
+
* @returns {columns is DerivedColumn[]}
|
|
321
|
+
*/
|
|
322
|
+
function isAllCountStar(columns) {
|
|
323
|
+
if (columns.length === 0) return false
|
|
324
|
+
return columns.every(col =>
|
|
325
|
+
col.kind === 'derived' &&
|
|
326
|
+
col.expr.type === 'function' &&
|
|
327
|
+
col.expr.name.toUpperCase() === 'COUNT' &&
|
|
328
|
+
col.expr.args.length === 1 &&
|
|
329
|
+
col.expr.args[0].type === 'star' &&
|
|
330
|
+
!col.expr.distinct &&
|
|
331
|
+
!col.expr.filter
|
|
332
|
+
)
|
|
333
|
+
}
|
package/src/plan/types.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { ExprNode, JoinType, OrderByItem, ScanOptions, SelectColumn } from '../types.js'
|
|
1
|
+
import { DerivedColumn, ExprNode, JoinType, OrderByItem, ScanOptions, SelectColumn } from '../types.js'
|
|
2
2
|
|
|
3
3
|
export type QueryPlan =
|
|
4
4
|
| ScanNode
|
|
5
|
+
| CountNode
|
|
5
6
|
| FilterNode
|
|
6
7
|
| ProjectNode
|
|
7
8
|
| SortNode
|
|
@@ -20,6 +21,13 @@ export interface ScanNode {
|
|
|
20
21
|
hints: ScanOptions
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
// Count node for COUNT(*) optimization
|
|
25
|
+
export interface CountNode {
|
|
26
|
+
type: 'Count'
|
|
27
|
+
table: string
|
|
28
|
+
columns: DerivedColumn[]
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
// Single-child nodes
|
|
24
32
|
export interface FilterNode {
|
|
25
33
|
type: 'Filter'
|
package/src/types.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type Row = Record<string, SqlPrimitive>[]
|
|
|
42
42
|
* Async data source for streaming SQL execution.
|
|
43
43
|
*/
|
|
44
44
|
export interface AsyncDataSource {
|
|
45
|
+
numRows?: number
|
|
45
46
|
scan(options: ScanOptions): ScanResults
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -214,6 +215,10 @@ export interface IntervalNode extends ExprNodeBase {
|
|
|
214
215
|
unit: IntervalUnit
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
export interface StarNode extends ExprNodeBase {
|
|
219
|
+
type: 'star'
|
|
220
|
+
}
|
|
221
|
+
|
|
217
222
|
export type ExprNode =
|
|
218
223
|
| LiteralNode
|
|
219
224
|
| IdentifierNode
|
|
@@ -227,6 +232,7 @@ export type ExprNode =
|
|
|
227
232
|
| CaseNode
|
|
228
233
|
| SubqueryNode
|
|
229
234
|
| IntervalNode
|
|
235
|
+
| StarNode
|
|
230
236
|
|
|
231
237
|
export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP'
|
|
232
238
|
|
package/src/validationErrors.js
CHANGED
|
@@ -99,7 +99,7 @@ export function argValueError({ funcName, message, positionStart, positionEnd, h
|
|
|
99
99
|
*/
|
|
100
100
|
export function aggregateError({ funcName, positionStart, positionEnd }) {
|
|
101
101
|
return new ExecutionError({
|
|
102
|
-
message: `Aggregate function ${funcName}
|
|
102
|
+
message: `Aggregate function ${funcName} is not available in this context`,
|
|
103
103
|
positionStart,
|
|
104
104
|
positionEnd,
|
|
105
105
|
})
|