squirreling 0.9.4 → 0.10.0
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 -1
- package/package.json +2 -2
- package/src/backend/dataSource.js +32 -8
- package/src/execute/aggregates.js +10 -2
- package/src/execute/execute.js +19 -16
- package/src/execute/join.js +8 -7
- package/src/expression/date.js +60 -0
- package/src/expression/evaluate.js +14 -4
- package/src/parse/expression.js +42 -1
- package/src/plan/columns.js +36 -18
- package/src/spatial/bbox.js +53 -0
- package/src/{expression/spatial.equality.js → spatial/equality.js} +5 -5
- package/src/{expression → spatial}/geometry.d.ts +7 -0
- package/src/{expression/spatial.geometry.js → spatial/operations.js} +31 -280
- package/src/spatial/pointRelations.js +102 -0
- package/src/spatial/primitives.js +27 -0
- package/src/spatial/segments.js +139 -0
- package/src/{expression → spatial}/spatial.js +12 -10
- package/src/types.d.ts +1 -0
- package/src/validation.js +24 -1
- /package/src/{expression → spatial}/wkt.js +0 -0
package/README.md
CHANGED
|
@@ -91,6 +91,8 @@ Squirreling can work with any data source that implements the `AsyncDataSource`
|
|
|
91
91
|
|
|
92
92
|
```typescript
|
|
93
93
|
interface AsyncDataSource {
|
|
94
|
+
numRows?: number
|
|
95
|
+
columns: string[]
|
|
94
96
|
scan(options: ScanOptions): ScanResults
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -113,6 +115,8 @@ The `scan()` method returns a `ScanResults` object containing a row stream and f
|
|
|
113
115
|
|
|
114
116
|
```typescript
|
|
115
117
|
const customSource: AsyncDataSource = {
|
|
118
|
+
numRows: 1000000, // optional total row count for planning
|
|
119
|
+
columns: ['id', 'name', 'active'], // columns available in this source
|
|
116
120
|
scan({ columns, where, limit, offset, signal }) {
|
|
117
121
|
// Use hints to optimize your scan, or ignore them
|
|
118
122
|
return {
|
|
@@ -147,7 +151,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
147
151
|
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`
|
|
148
152
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
149
153
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
150
|
-
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
|
|
154
|
+
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
|
|
151
155
|
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
|
|
152
156
|
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
|
|
153
157
|
- Regex: `REGEXP_SUBSTR`, `REGEXP_REPLACE`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"test": "vitest run"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "25.3.
|
|
40
|
+
"@types/node": "25.3.5",
|
|
41
41
|
"@vitest/coverage-v8": "4.0.18",
|
|
42
42
|
"eslint": "9.39.2",
|
|
43
43
|
"eslint-plugin-jsdoc": "62.7.1",
|
|
@@ -6,27 +6,50 @@
|
|
|
6
6
|
* Creates an async row accessor that wraps a plain JavaScript object
|
|
7
7
|
*
|
|
8
8
|
* @param {Record<string, SqlPrimitive>} obj - the plain object
|
|
9
|
+
* @param {string[]} columns - list of column names (keys in the object)
|
|
9
10
|
* @returns {AsyncRow} a row accessor interface
|
|
10
11
|
*/
|
|
11
|
-
export function asyncRow(obj) {
|
|
12
|
+
export function asyncRow(obj, columns) {
|
|
12
13
|
/** @type {AsyncCells} */
|
|
13
14
|
const cells = {}
|
|
14
|
-
for (const
|
|
15
|
-
cells[key] = () => Promise.resolve(
|
|
15
|
+
for (const key of columns) {
|
|
16
|
+
cells[key] = () => Promise.resolve(obj[key])
|
|
16
17
|
}
|
|
17
|
-
return { columns
|
|
18
|
+
return { columns, cells }
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Creates an async memory-backed data source from an array of plain objects
|
|
22
23
|
*
|
|
23
|
-
* @param {
|
|
24
|
+
* @param {Object} options
|
|
25
|
+
* @param {Record<string, SqlPrimitive>[]} options.data - array of plain objects
|
|
26
|
+
* @param {string[]} [options.columns] - optional list of column names (if not provided, inferred from first row)
|
|
24
27
|
* @returns {AsyncDataSource} an async data source interface
|
|
25
28
|
*/
|
|
26
|
-
export function memorySource(data) {
|
|
29
|
+
export function memorySource({ data, columns }) {
|
|
30
|
+
if (!columns) {
|
|
31
|
+
// Columns not provided, infer from data
|
|
32
|
+
if (!data.length) {
|
|
33
|
+
throw new Error('Unknown columns: data is empty and no columns provided')
|
|
34
|
+
}
|
|
35
|
+
columns = Object.keys(data[0])
|
|
36
|
+
// Check first 1000 rows for consistent columns
|
|
37
|
+
for (let i = 1; i < data.length && i < 1000; i++) {
|
|
38
|
+
const rowColumns = Object.keys(data[i])
|
|
39
|
+
const missing = columns.find(col => !rowColumns.includes(col))
|
|
40
|
+
if (missing) {
|
|
41
|
+
throw new Error(`Inconsistent data, column "${missing}" not found in row ${i}`)
|
|
42
|
+
}
|
|
43
|
+
const extra = rowColumns.find(col => !columns.includes(col))
|
|
44
|
+
if (extra) {
|
|
45
|
+
throw new Error(`Inconsistent data, unexpected column "${extra}" found in row ${i}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
27
49
|
return {
|
|
28
50
|
numRows: data.length,
|
|
29
|
-
|
|
51
|
+
columns,
|
|
52
|
+
scan({ columns: scanColumns, where, limit, offset, signal }) {
|
|
30
53
|
// Only apply offset and limit if no where clause
|
|
31
54
|
const start = !where ? offset ?? 0 : 0
|
|
32
55
|
const end = !where && limit !== undefined ? start + limit : data.length
|
|
@@ -34,7 +57,7 @@ export function memorySource(data) {
|
|
|
34
57
|
rows: (async function* () {
|
|
35
58
|
for (let i = start; i < end && i < data.length; i++) {
|
|
36
59
|
if (signal?.aborted) break
|
|
37
|
-
yield asyncRow(data[i])
|
|
60
|
+
yield asyncRow(data[i], scanColumns ?? columns)
|
|
38
61
|
}
|
|
39
62
|
})(),
|
|
40
63
|
appliedWhere: false,
|
|
@@ -53,6 +76,7 @@ export function cachedDataSource(source) {
|
|
|
53
76
|
/** @type {Map<string, Promise<SqlPrimitive>>} */
|
|
54
77
|
const cache = new Map()
|
|
55
78
|
return {
|
|
79
|
+
...source,
|
|
56
80
|
scan(options) {
|
|
57
81
|
// Does re-run the scan, but cache avoids re-computing expensive async cells
|
|
58
82
|
// TODO: check cache first to avoid re-scanning when possible
|
|
@@ -91,7 +91,11 @@ export async function* executeHashAggregate(plan, context) {
|
|
|
91
91
|
|
|
92
92
|
// Apply HAVING filter
|
|
93
93
|
if (plan.having) {
|
|
94
|
-
|
|
94
|
+
/** @type {AsyncRow} */
|
|
95
|
+
const havingRow = {
|
|
96
|
+
columns: [...group[0].columns, ...asyncRow.columns],
|
|
97
|
+
cells: { ...group[0].cells, ...asyncRow.cells },
|
|
98
|
+
}
|
|
95
99
|
const passes = await evaluateExpr({
|
|
96
100
|
node: plan.having,
|
|
97
101
|
row: havingRow,
|
|
@@ -125,7 +129,11 @@ export async function* executeScalarAggregate(plan, context) {
|
|
|
125
129
|
|
|
126
130
|
// Apply HAVING filter
|
|
127
131
|
if (plan.having) {
|
|
128
|
-
|
|
132
|
+
/** @type {AsyncRow} */
|
|
133
|
+
const havingRow = {
|
|
134
|
+
columns: [...group[0].columns, ...asyncRow.columns],
|
|
135
|
+
cells: { ...group[0].cells, ...asyncRow.cells },
|
|
136
|
+
}
|
|
129
137
|
const passes = await evaluateExpr({
|
|
130
138
|
node: plan.having,
|
|
131
139
|
row: havingRow,
|
package/src/execute/execute.js
CHANGED
|
@@ -26,11 +26,11 @@ export async function* executeSql({ tables, query, functions, signal }) {
|
|
|
26
26
|
// Normalize tables: convert arrays to AsyncDataSource
|
|
27
27
|
/** @type {Record<string, AsyncDataSource>} */
|
|
28
28
|
const normalizedTables = {}
|
|
29
|
-
for (const [name,
|
|
30
|
-
if (Array.isArray(
|
|
31
|
-
normalizedTables[name] = memorySource(
|
|
29
|
+
for (const [name, data] of Object.entries(tables)) {
|
|
30
|
+
if (Array.isArray(data)) {
|
|
31
|
+
normalizedTables[name] = memorySource({ data })
|
|
32
32
|
} else {
|
|
33
|
-
normalizedTables[name] =
|
|
33
|
+
normalizedTables[name] = data
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -95,16 +95,19 @@ export async function* executePlan({ plan, context }) {
|
|
|
95
95
|
*/
|
|
96
96
|
async function* executeScan(plan, context) {
|
|
97
97
|
const { tables, signal } = context
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// check table
|
|
99
|
+
const table = tables[plan.table]
|
|
100
|
+
if (!table) {
|
|
100
101
|
throw tableNotFoundError({ tableName: plan.table })
|
|
101
102
|
}
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
throw new Error(`
|
|
103
|
+
// check columns
|
|
104
|
+
const missingColumn = plan.hints.columns?.find(col => !table.columns.includes(col))
|
|
105
|
+
if (missingColumn) {
|
|
106
|
+
throw new Error(`Column "${missingColumn}" not found. Available columns: ${table.columns.join(', ') || '[]'}`)
|
|
106
107
|
}
|
|
107
|
-
|
|
108
|
+
|
|
109
|
+
// do the scan
|
|
110
|
+
const { rows, appliedWhere, appliedLimitOffset } = table.scan({ ...plan.hints, signal })
|
|
108
111
|
|
|
109
112
|
// Applied limit/offset without applied where is invalid
|
|
110
113
|
const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
|
|
@@ -135,17 +138,17 @@ async function* executeScan(plan, context) {
|
|
|
135
138
|
* @yields {AsyncRow}
|
|
136
139
|
*/
|
|
137
140
|
async function* executeCount(plan, { tables, signal }) {
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
141
|
+
const table = tables[plan.table]
|
|
142
|
+
if (!table) {
|
|
140
143
|
throw tableNotFoundError({ tableName: plan.table })
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
// Use source numRows if available
|
|
144
|
-
let count =
|
|
145
|
-
if (
|
|
147
|
+
let count = table.numRows
|
|
148
|
+
if (table.numRows === undefined) {
|
|
146
149
|
// Fall back to counting rows via scan
|
|
147
150
|
count = 0
|
|
148
|
-
const { rows } =
|
|
151
|
+
const { rows } = table.scan({ signal })
|
|
149
152
|
// eslint-disable-next-line no-unused-vars
|
|
150
153
|
for await (const _ of rows) {
|
|
151
154
|
if (signal?.aborted) return
|
package/src/execute/join.js
CHANGED
|
@@ -203,16 +203,16 @@ export async function* executeHashJoin(plan, context) {
|
|
|
203
203
|
/**
|
|
204
204
|
* Creates a NULL-filled row with the given column names
|
|
205
205
|
*
|
|
206
|
-
* @param {string[]}
|
|
206
|
+
* @param {string[]} columns
|
|
207
207
|
* @returns {AsyncRow}
|
|
208
208
|
*/
|
|
209
|
-
function createNullRow(
|
|
209
|
+
function createNullRow(columns) {
|
|
210
210
|
/** @type {AsyncCells} */
|
|
211
211
|
const cells = {}
|
|
212
|
-
for (const col of
|
|
212
|
+
for (const col of columns) {
|
|
213
213
|
cells[col] = () => Promise.resolve(null)
|
|
214
214
|
}
|
|
215
|
-
return { columns
|
|
215
|
+
return { columns, cells }
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
@@ -234,6 +234,7 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
|
|
|
234
234
|
// Skip already-prefixed keys (from previous joins)
|
|
235
235
|
if (!key.includes('.')) {
|
|
236
236
|
const alias = `${leftTable}.${key}`
|
|
237
|
+
columns.push(alias)
|
|
237
238
|
cells[alias] = cell
|
|
238
239
|
}
|
|
239
240
|
// Also keep unqualified name for convenience
|
|
@@ -244,9 +245,9 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
|
|
|
244
245
|
// Add right table columns with prefix
|
|
245
246
|
for (const [key, cell] of Object.entries(rightRow.cells)) {
|
|
246
247
|
if (!key.includes('.')) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
cells[
|
|
248
|
+
const alias = `${rightTable}.${key}`
|
|
249
|
+
columns.push(alias)
|
|
250
|
+
cells[alias] = cell
|
|
250
251
|
}
|
|
251
252
|
// Unqualified name (overwrites if same name exists in left table)
|
|
252
253
|
columns.push(key)
|
package/src/expression/date.js
CHANGED
|
@@ -40,6 +40,66 @@ export function applyIntervalToDate(dateVal, value, unit, op) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Truncate a date to the given precision
|
|
45
|
+
* @param {SqlPrimitive} precision - the unit to truncate to (year, month, day, hour, minute, second)
|
|
46
|
+
* @param {SqlPrimitive} dateVal - the date value to truncate
|
|
47
|
+
* @returns {Date | string | null}
|
|
48
|
+
*/
|
|
49
|
+
export function dateTrunc(precision, dateVal) {
|
|
50
|
+
if (precision == null || dateVal == null) return null
|
|
51
|
+
const date = toDate(dateVal)
|
|
52
|
+
if (date == null) return null
|
|
53
|
+
|
|
54
|
+
const unit = String(precision).toUpperCase()
|
|
55
|
+
if (unit === 'YEAR') {
|
|
56
|
+
date.setUTCMonth(0, 1)
|
|
57
|
+
date.setUTCHours(0, 0, 0, 0)
|
|
58
|
+
} else if (unit === 'MONTH') {
|
|
59
|
+
date.setUTCDate(1)
|
|
60
|
+
date.setUTCHours(0, 0, 0, 0)
|
|
61
|
+
} else if (unit === 'DAY') {
|
|
62
|
+
date.setUTCHours(0, 0, 0, 0)
|
|
63
|
+
} else if (unit === 'HOUR') {
|
|
64
|
+
date.setUTCMinutes(0, 0, 0)
|
|
65
|
+
} else if (unit === 'MINUTE') {
|
|
66
|
+
date.setUTCSeconds(0, 0)
|
|
67
|
+
} else if (unit === 'SECOND') {
|
|
68
|
+
date.setUTCMilliseconds(0)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Return in same format as input
|
|
72
|
+
if (dateVal instanceof Date) return date
|
|
73
|
+
if (String(dateVal).includes('T')) {
|
|
74
|
+
return date.toISOString()
|
|
75
|
+
} else {
|
|
76
|
+
return date.toISOString().split('T')[0]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract a field from a date value
|
|
82
|
+
* @param {SqlPrimitive} field - the field to extract (YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DOW, EPOCH)
|
|
83
|
+
* @param {SqlPrimitive} dateVal - the date value to extract from
|
|
84
|
+
* @returns {number | null}
|
|
85
|
+
*/
|
|
86
|
+
export function extractField(field, dateVal) {
|
|
87
|
+
if (field == null || dateVal == null) return null
|
|
88
|
+
const date = toDate(dateVal)
|
|
89
|
+
if (date == null) return null
|
|
90
|
+
|
|
91
|
+
const unit = String(field).toUpperCase()
|
|
92
|
+
if (unit === 'YEAR') return date.getUTCFullYear()
|
|
93
|
+
if (unit === 'MONTH') return date.getUTCMonth() + 1
|
|
94
|
+
if (unit === 'DAY') return date.getUTCDate()
|
|
95
|
+
if (unit === 'HOUR') return date.getUTCHours()
|
|
96
|
+
if (unit === 'MINUTE') return date.getUTCMinutes()
|
|
97
|
+
if (unit === 'SECOND') return date.getUTCSeconds()
|
|
98
|
+
if (unit === 'DOW') return date.getUTCDay()
|
|
99
|
+
if (unit === 'EPOCH') return date.getTime() / 1000
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
43
103
|
/**
|
|
44
104
|
* @param {SqlPrimitive} val
|
|
45
105
|
* @returns {Date | null}
|
|
@@ -6,10 +6,10 @@ import { isAggregateFunc, isMathFunc, isRegexpFunc, isSpatialFunc, isStringFunc
|
|
|
6
6
|
import { aggregateError, argValueError, castError } from '../validationErrors.js'
|
|
7
7
|
import { derivedAlias } from './alias.js'
|
|
8
8
|
import { applyBinaryOp } from './binary.js'
|
|
9
|
-
import { applyIntervalToDate } from './date.js'
|
|
9
|
+
import { applyIntervalToDate, dateTrunc, extractField } from './date.js'
|
|
10
10
|
import { evaluateMathFunc } from './math.js'
|
|
11
11
|
import { evaluateRegexpFunc } from './regexp.js'
|
|
12
|
-
import { evaluateSpatialFunc } from '
|
|
12
|
+
import { evaluateSpatialFunc } from '../spatial/spatial.js'
|
|
13
13
|
import { evaluateStringFunc } from './strings.js'
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -47,7 +47,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
47
47
|
// Unknown identifier
|
|
48
48
|
throw columnNotFoundError({
|
|
49
49
|
columnName: node.name,
|
|
50
|
-
availableColumns:
|
|
50
|
+
availableColumns: row.columns,
|
|
51
51
|
positionStart: node.positionStart,
|
|
52
52
|
positionEnd: node.positionEnd,
|
|
53
53
|
rowIndex,
|
|
@@ -225,7 +225,9 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
/** @type {SqlPrimitive[]} */
|
|
228
|
-
const args =
|
|
228
|
+
const args = node.args.length === 1
|
|
229
|
+
? [await evaluateExpr({ node: node.args[0], row, rowIndex, rows, context })]
|
|
230
|
+
: await Promise.all(node.args.map(arg => evaluateExpr({ node: arg, row, rowIndex, rows, context })))
|
|
229
231
|
|
|
230
232
|
if (isStringFunc(funcName)) {
|
|
231
233
|
return evaluateStringFunc({
|
|
@@ -271,6 +273,14 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
271
273
|
return val1 == val2 ? null : val1
|
|
272
274
|
}
|
|
273
275
|
|
|
276
|
+
if (funcName === 'DATE_TRUNC') {
|
|
277
|
+
return dateTrunc(args[0], args[1])
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (funcName === 'EXTRACT' || funcName === 'DATE_PART') {
|
|
281
|
+
return extractField(args[0], args[1])
|
|
282
|
+
}
|
|
283
|
+
|
|
274
284
|
if (funcName === 'CURRENT_DATE') {
|
|
275
285
|
return new Date().toISOString().split('T')[0]
|
|
276
286
|
}
|
package/src/parse/expression.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
syntaxError,
|
|
5
5
|
unknownFunctionError,
|
|
6
6
|
} from '../parseErrors.js'
|
|
7
|
-
import { isIntervalUnit, isKnownFunction } from '../validation.js'
|
|
7
|
+
import { RESERVED_KEYWORDS, isExtractField, isIntervalUnit, isKnownFunction } from '../validation.js'
|
|
8
8
|
import { parseComparison } from './comparison.js'
|
|
9
9
|
import { parseFunctionCall } from './functions.js'
|
|
10
10
|
import { parseSelectInternal } from './parse.js'
|
|
@@ -70,6 +70,36 @@ export function parsePrimary(state) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// EXTRACT(field FROM expr)
|
|
74
|
+
if (tok.value === 'EXTRACT' && next.type === 'paren' && next.value === '(') {
|
|
75
|
+
consume(state) // EXTRACT
|
|
76
|
+
consume(state) // '('
|
|
77
|
+
const fieldTok = current(state)
|
|
78
|
+
const isValidType = fieldTok.type === 'keyword' || fieldTok.type === 'identifier'
|
|
79
|
+
if (!isValidType || !isExtractField(fieldTok.value)) {
|
|
80
|
+
throw syntaxError({
|
|
81
|
+
expected: 'extract field (YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DOW, EPOCH)',
|
|
82
|
+
received: `"${fieldTok.value}"`,
|
|
83
|
+
positionStart: fieldTok.positionStart,
|
|
84
|
+
positionEnd: fieldTok.positionEnd,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
consume(state) // field
|
|
88
|
+
expect(state, 'keyword', 'FROM')
|
|
89
|
+
const expr = parseExpression(state)
|
|
90
|
+
expect(state, 'paren', ')')
|
|
91
|
+
return {
|
|
92
|
+
type: 'function',
|
|
93
|
+
name: 'EXTRACT',
|
|
94
|
+
args: [
|
|
95
|
+
{ type: 'literal', value: fieldTok.value, positionStart: fieldTok.positionStart, positionEnd: fieldTok.positionEnd },
|
|
96
|
+
expr,
|
|
97
|
+
],
|
|
98
|
+
positionStart,
|
|
99
|
+
positionEnd: state.lastPos,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
// function call
|
|
74
104
|
if (next.type === 'paren' && next.value === '(') {
|
|
75
105
|
const funcName = tok.value
|
|
@@ -220,6 +250,17 @@ export function parsePrimary(state) {
|
|
|
220
250
|
if (tok.value === 'INTERVAL') {
|
|
221
251
|
return parseInterval(state)
|
|
222
252
|
}
|
|
253
|
+
|
|
254
|
+
// Non-reserved keywords can be used as identifiers (e.g. column aliases)
|
|
255
|
+
if (!RESERVED_KEYWORDS.has(tok.value)) {
|
|
256
|
+
consume(state)
|
|
257
|
+
return {
|
|
258
|
+
type: 'identifier',
|
|
259
|
+
name: tok.originalValue ?? tok.value,
|
|
260
|
+
positionStart,
|
|
261
|
+
positionEnd: state.lastPos,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
223
264
|
}
|
|
224
265
|
|
|
225
266
|
if (tok.type === 'operator' && tok.value === '-') {
|
package/src/plan/columns.js
CHANGED
|
@@ -39,22 +39,32 @@ export function extractColumns(select) {
|
|
|
39
39
|
// Collect all identifiers from all clauses
|
|
40
40
|
/** @type {Set<string>} */
|
|
41
41
|
const identifiers = new Set()
|
|
42
|
+
|
|
43
|
+
// Collect ORDER BY identifiers, excluding SELECT aliases (their underlying
|
|
44
|
+
// columns are already collected from select.columns expressions above)
|
|
45
|
+
/** @type {Set<string>} */
|
|
46
|
+
const selectAliases = new Set()
|
|
47
|
+
|
|
42
48
|
for (const col of select.columns) {
|
|
43
49
|
if (col.kind === 'star' && col.table) {
|
|
44
50
|
// SELECT table.* means all columns needed
|
|
45
51
|
perTable.set(col.table, undefined)
|
|
46
52
|
} else if (col.kind === 'derived') {
|
|
47
53
|
collectColumnsFromExpr(col.expr, identifiers)
|
|
54
|
+
if (col.alias) {
|
|
55
|
+
selectAliases.add(col.alias)
|
|
56
|
+
}
|
|
48
57
|
}
|
|
49
58
|
}
|
|
50
59
|
collectColumnsFromExpr(select.where, identifiers)
|
|
60
|
+
|
|
51
61
|
for (const item of select.orderBy) {
|
|
52
|
-
collectColumnsFromExpr(item.expr, identifiers)
|
|
62
|
+
collectColumnsFromExpr(item.expr, identifiers, selectAliases)
|
|
53
63
|
}
|
|
54
64
|
for (const expr of select.groupBy) {
|
|
55
65
|
collectColumnsFromExpr(expr, identifiers)
|
|
56
66
|
}
|
|
57
|
-
collectColumnsFromExpr(select.having, identifiers)
|
|
67
|
+
collectColumnsFromExpr(select.having, identifiers, selectAliases)
|
|
58
68
|
for (const join of select.joins) {
|
|
59
69
|
collectColumnsFromExpr(join.on, identifiers)
|
|
60
70
|
}
|
|
@@ -68,8 +78,13 @@ export function extractColumns(select) {
|
|
|
68
78
|
const columnName = name.substring(dotIndex + 1)
|
|
69
79
|
const set = perTable.get(tablePrefix)
|
|
70
80
|
if (set) set.add(columnName)
|
|
81
|
+
} else if (aliases.length > 1) {
|
|
82
|
+
// Unqualified in a JOIN: can't disambiguate, request all columns from all tables
|
|
83
|
+
for (const alias of aliases) {
|
|
84
|
+
perTable.set(alias, undefined)
|
|
85
|
+
}
|
|
71
86
|
} else {
|
|
72
|
-
// Unqualified: add to
|
|
87
|
+
// Unqualified, single table: add to that table
|
|
73
88
|
for (const [, set] of perTable) {
|
|
74
89
|
if (set) set.add(name)
|
|
75
90
|
}
|
|
@@ -89,40 +104,43 @@ export function extractColumns(select) {
|
|
|
89
104
|
*
|
|
90
105
|
* @param {ExprNode} expr
|
|
91
106
|
* @param {Set<string>} columns
|
|
107
|
+
* @param {Set<string>} [aliases] - aliases to exclude from columns
|
|
92
108
|
*/
|
|
93
|
-
function collectColumnsFromExpr(expr, columns) {
|
|
109
|
+
function collectColumnsFromExpr(expr, columns, aliases) {
|
|
94
110
|
if (!expr) return
|
|
95
111
|
if (expr.type === 'identifier') {
|
|
96
|
-
|
|
112
|
+
if (!aliases?.has(expr.name)) {
|
|
113
|
+
columns.add(expr.name)
|
|
114
|
+
}
|
|
97
115
|
} else if (expr.type === 'binary') {
|
|
98
|
-
collectColumnsFromExpr(expr.left, columns)
|
|
99
|
-
collectColumnsFromExpr(expr.right, columns)
|
|
116
|
+
collectColumnsFromExpr(expr.left, columns, aliases)
|
|
117
|
+
collectColumnsFromExpr(expr.right, columns, aliases)
|
|
100
118
|
} else if (expr.type === 'unary') {
|
|
101
|
-
collectColumnsFromExpr(expr.argument, columns)
|
|
119
|
+
collectColumnsFromExpr(expr.argument, columns, aliases)
|
|
102
120
|
} else if (expr.type === 'function') {
|
|
103
121
|
for (const arg of expr.args) {
|
|
104
|
-
collectColumnsFromExpr(arg, columns)
|
|
122
|
+
collectColumnsFromExpr(arg, columns, aliases)
|
|
105
123
|
}
|
|
106
|
-
collectColumnsFromExpr(expr.filter, columns)
|
|
124
|
+
collectColumnsFromExpr(expr.filter, columns, aliases)
|
|
107
125
|
} else if (expr.type === 'cast') {
|
|
108
|
-
collectColumnsFromExpr(expr.expr, columns)
|
|
126
|
+
collectColumnsFromExpr(expr.expr, columns, aliases)
|
|
109
127
|
} else if (expr.type === 'in valuelist') {
|
|
110
|
-
collectColumnsFromExpr(expr.expr, columns)
|
|
128
|
+
collectColumnsFromExpr(expr.expr, columns, aliases)
|
|
111
129
|
for (const val of expr.values) {
|
|
112
|
-
collectColumnsFromExpr(val, columns)
|
|
130
|
+
collectColumnsFromExpr(val, columns, aliases)
|
|
113
131
|
}
|
|
114
132
|
} else if (expr.type === 'in') {
|
|
115
|
-
collectColumnsFromExpr(expr.expr, columns)
|
|
133
|
+
collectColumnsFromExpr(expr.expr, columns, aliases)
|
|
116
134
|
} else if (expr.type === 'case') {
|
|
117
135
|
if (expr.caseExpr) {
|
|
118
|
-
collectColumnsFromExpr(expr.caseExpr, columns)
|
|
136
|
+
collectColumnsFromExpr(expr.caseExpr, columns, aliases)
|
|
119
137
|
}
|
|
120
138
|
for (const when of expr.whenClauses) {
|
|
121
|
-
collectColumnsFromExpr(when.condition, columns)
|
|
122
|
-
collectColumnsFromExpr(when.result, columns)
|
|
139
|
+
collectColumnsFromExpr(when.condition, columns, aliases)
|
|
140
|
+
collectColumnsFromExpr(when.result, columns, aliases)
|
|
123
141
|
}
|
|
124
142
|
if (expr.elseResult) {
|
|
125
|
-
collectColumnsFromExpr(expr.elseResult, columns)
|
|
143
|
+
collectColumnsFromExpr(expr.elseResult, columns, aliases)
|
|
126
144
|
}
|
|
127
145
|
}
|
|
128
146
|
// No columns: count(*), literal, interval, exists, not exists, subquery
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { BBox, SimpleGeometry } from './geometry.js'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const EPSILON = 1e-10
|
|
6
|
+
export const EPSILON_SQ = EPSILON * EPSILON
|
|
7
|
+
|
|
8
|
+
/** @type {WeakMap<SimpleGeometry, BBox>} */
|
|
9
|
+
const bboxCache = new WeakMap()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Test whether two bounding boxes overlap.
|
|
13
|
+
*
|
|
14
|
+
* @param {SimpleGeometry} a
|
|
15
|
+
* @param {SimpleGeometry} b
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function bboxOverlap(a, b) {
|
|
19
|
+
const aBox = bbox(a)
|
|
20
|
+
const bBox = bbox(b)
|
|
21
|
+
return aBox.minX <= bBox.maxX && aBox.maxX >= bBox.minX && aBox.minY <= bBox.maxY && aBox.maxY >= bBox.minY
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute the axis-aligned bounding box of a simple geometry.
|
|
26
|
+
* Results are cached per geometry object.
|
|
27
|
+
*
|
|
28
|
+
* @param {SimpleGeometry} geom
|
|
29
|
+
* @returns {BBox}
|
|
30
|
+
*/
|
|
31
|
+
function bbox(geom) {
|
|
32
|
+
let b = bboxCache.get(geom)
|
|
33
|
+
if (b) return b
|
|
34
|
+
if (geom.type === 'Point') {
|
|
35
|
+
const [x, y] = geom.coordinates
|
|
36
|
+
b = { minX: x, minY: y, maxX: x, maxY: y }
|
|
37
|
+
} else {
|
|
38
|
+
/** @type {number[][]} */
|
|
39
|
+
const points = geom.type === 'LineString'
|
|
40
|
+
? geom.coordinates
|
|
41
|
+
: geom.coordinates[0] // outer ring
|
|
42
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
43
|
+
for (const p of points) {
|
|
44
|
+
if (p[0] < minX) minX = p[0]
|
|
45
|
+
if (p[1] < minY) minY = p[1]
|
|
46
|
+
if (p[0] > maxX) maxX = p[0]
|
|
47
|
+
if (p[1] > maxY) maxY = p[1]
|
|
48
|
+
}
|
|
49
|
+
b = { minX, minY, maxX, maxY }
|
|
50
|
+
}
|
|
51
|
+
bboxCache.set(geom, b)
|
|
52
|
+
return b
|
|
53
|
+
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* @import { SimpleGeometry } from './geometry.js'
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { EPSILON, EPSILON_SQ, distSq } from './
|
|
5
|
+
import { EPSILON, EPSILON_SQ, distSq } from './primitives.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @param {SimpleGeometry} a
|
|
9
9
|
* @param {SimpleGeometry} b
|
|
10
10
|
* @returns {boolean}
|
|
11
11
|
*/
|
|
12
|
-
export function
|
|
12
|
+
export function geometryEqual(a, b) {
|
|
13
13
|
if (a.type === 'Point' && b.type === 'Point') {
|
|
14
14
|
return distSq(a.coordinates, b.coordinates) < EPSILON_SQ
|
|
15
15
|
} else if (a.type === 'LineString' && b.type === 'LineString') {
|
|
@@ -25,7 +25,7 @@ export function simpleGeomEqual(a, b) {
|
|
|
25
25
|
* @param {number[][]} b
|
|
26
26
|
* @returns {boolean}
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
function lineEqual(a, b) {
|
|
29
29
|
if (a.length !== b.length) return false
|
|
30
30
|
// Forward
|
|
31
31
|
let forward = true
|
|
@@ -50,7 +50,7 @@ export function lineEqual(a, b) {
|
|
|
50
50
|
* @param {number[][][]} b
|
|
51
51
|
* @returns {boolean}
|
|
52
52
|
*/
|
|
53
|
-
|
|
53
|
+
function polygonEqual(a, b) {
|
|
54
54
|
if (a.length !== b.length) return false
|
|
55
55
|
for (let i = 0; i < a.length; i++) {
|
|
56
56
|
if (!ringsEqual(a[i], b[i])) return false
|
|
@@ -65,7 +65,7 @@ export function polygonEqual(a, b) {
|
|
|
65
65
|
* @param {number[][]} ring2
|
|
66
66
|
* @returns {boolean}
|
|
67
67
|
*/
|
|
68
|
-
|
|
68
|
+
function ringsEqual(ring1, ring2) {
|
|
69
69
|
if (ring1.length !== ring2.length) return false
|
|
70
70
|
// Try every rotation
|
|
71
71
|
const n = ring1.length - 1 // closed ring, last = first
|
|
@@ -15,6 +15,13 @@ export type Geometry =
|
|
|
15
15
|
*/
|
|
16
16
|
export type SimpleGeometry = Point | LineString | Polygon
|
|
17
17
|
|
|
18
|
+
export interface BBox {
|
|
19
|
+
minX: number
|
|
20
|
+
minY: number
|
|
21
|
+
maxX: number
|
|
22
|
+
maxY: number
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* Boundary relationship between two geometries.
|
|
20
27
|
*/
|