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 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.9.4",
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.2",
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 [key, value] of Object.entries(obj)) {
15
- cells[key] = () => Promise.resolve(value)
15
+ for (const key of columns) {
16
+ cells[key] = () => Promise.resolve(obj[key])
16
17
  }
17
- return { columns: Object.keys(obj), cells }
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 {Record<string, SqlPrimitive>[]} data - array of plain objects
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
- scan({ where, limit, offset, signal }) {
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
- const havingRow = { ...group[0], ...asyncRow }
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
- const havingRow = { ...group[0], ...asyncRow }
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,
@@ -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, source] of Object.entries(tables)) {
30
- if (Array.isArray(source)) {
31
- normalizedTables[name] = memorySource(source)
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] = source
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
- const dataSource = tables[plan.table]
99
- if (dataSource === undefined) {
98
+ // check table
99
+ const table = tables[plan.table]
100
+ if (!table) {
100
101
  throw tableNotFoundError({ tableName: plan.table })
101
102
  }
102
-
103
- const scanResult = dataSource.scan({ ...plan.hints, signal })
104
- if (!scanResult.rows) {
105
- throw new Error(`Data source "${plan.table}" scan() must return a ScanResults object with { rows, appliedWhere, appliedLimitOffset }`)
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
- const { rows, appliedWhere, appliedLimitOffset } = scanResult
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 dataSource = tables[plan.table]
139
- if (dataSource === undefined) {
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 = dataSource.numRows
145
- if (dataSource.numRows === undefined) {
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 } = dataSource.scan({ signal })
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
@@ -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[]} columnNames
206
+ * @param {string[]} columns
207
207
  * @returns {AsyncRow}
208
208
  */
209
- function createNullRow(columnNames) {
209
+ function createNullRow(columns) {
210
210
  /** @type {AsyncCells} */
211
211
  const cells = {}
212
- for (const col of columnNames) {
212
+ for (const col of columns) {
213
213
  cells[col] = () => Promise.resolve(null)
214
214
  }
215
- return { columns: columnNames, cells }
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
- cells[`${rightTable}.${key}`] = cell
248
- } else {
249
- cells[key] = cell
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)
@@ -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 './spatial.js'
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: Object.keys(row.cells),
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 = await Promise.all(node.args.map(arg => evaluateExpr({ node: arg, row, rowIndex, rows, context })))
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
  }
@@ -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 === '-') {
@@ -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 all tables (ambiguous)
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
- columns.add(expr.name)
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 './spatial.geometry.js'
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 simpleGeomEqual(a, b) {
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
- export function lineEqual(a, b) {
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
- export function polygonEqual(a, b) {
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
- export function ringsEqual(ring1, ring2) {
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
  */