squirreling 0.15.2 → 0.16.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.
@@ -1,5 +1,5 @@
1
1
  import { executeStatement } from '../execute/execute.js'
2
- import { isPlainObject, keyify, sqlEquals, stringify } from '../execute/utils.js'
2
+ import { isPlainObject, keyify, sqlEquals } from '../execute/utils.js'
3
3
  import { yieldToEventLoop } from '../execute/yield.js'
4
4
  import { ArgValueError, ExecutionError } from '../validation/executionErrors.js'
5
5
  import { isAggregateFunc, isMathFunc, isRegexpFunc, isSpatialFunc, isStringFunc } from '../validation/functions.js'
@@ -7,9 +7,10 @@ import { UnknownFunctionError } from '../validation/parseErrors.js'
7
7
  import { ColumnNotFoundError } from '../validation/tables.js'
8
8
  import { derivedAlias } from './alias.js'
9
9
  import { applyBinaryOp } from './binary.js'
10
- import { applyIntervalToDate, dateDiff, dateTrunc, extractField, toDate } from './date.js'
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
14
  import { evaluateSpatialFunc } from '../spatial/spatial.js'
14
15
  import { evaluateStringFunc } from './strings.js'
15
16
 
@@ -74,6 +75,13 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
74
75
  if (qualified in row.cells) {
75
76
  return row.cells[qualified]()
76
77
  }
78
+ if (context.scope?.includes(node.prefix) && node.name in row.cells) {
79
+ return row.cells[node.name]()
80
+ }
81
+ if (context.outerRow && context.outerAliases?.has(node.prefix)) {
82
+ if (qualified in context.outerRow.cells) return context.outerRow.cells[qualified]()
83
+ if (node.name in context.outerRow.cells) return context.outerRow.cells[node.name]()
84
+ }
77
85
  const prefix = node.prefix + '.'
78
86
  const prefixedColumns = row.columns.filter(col => col.startsWith(prefix))
79
87
  if (prefixedColumns.length === 1) {
@@ -93,10 +101,6 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
93
101
  return value[node.name]
94
102
  }
95
103
  }
96
- // Check outer row for correlated subquery references
97
- if (context.outerRow && context.outerAliases?.has(node.prefix) && node.name in context.outerRow.cells) {
98
- return context.outerRow.cells[node.name]()
99
- }
100
104
  // Standalone `FROM UNNEST(...) AS alias` row has a single bare column;
101
105
  // `alias.field` should struct-access that column's element.
102
106
  if (context.scope?.includes(node.prefix) && row.columns.length === 1) {
@@ -167,7 +171,9 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
167
171
  if (val == null) return null
168
172
  return -val
169
173
  }
170
- if (node.op === 'NOT') return !val
174
+ // NOT UNKNOWN is UNKNOWN: JS ! would flip null to true and return rows
175
+ // SQL excludes, e.g. WHERE NOT (nullable < 300)
176
+ if (node.op === 'NOT') return val == null ? null : !val
171
177
  if (node.op === 'IS NULL') return val == null
172
178
  if (node.op === 'IS NOT NULL') return val != null
173
179
  }
@@ -186,9 +192,11 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
186
192
 
187
193
  const left = await evaluateExpr({ node: node.left, row, rowIndex, rows, context })
188
194
 
189
- // Short-circuit evaluation for AND and OR
190
- if (node.op === 'AND' && !left) return false
191
- if (node.op === 'OR' && left) return true
195
+ // Short-circuit evaluation for AND and OR. A null left cannot
196
+ // short-circuit AND: NULL AND FALSE is false but NULL AND TRUE is null,
197
+ // so the right side decides
198
+ if (node.op === 'AND' && left != null && !left) return false
199
+ if (node.op === 'OR' && left != null && Boolean(left)) return true
192
200
 
193
201
  const right = await evaluateExpr({ node: node.right, row, rowIndex, rows, context })
194
202
  return applyBinaryOp(node.op, left, right)
@@ -665,60 +673,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
665
673
  }
666
674
 
667
675
  if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
668
- let jsonArg = args[0]
669
- const pathArg = args[1]
670
- if (jsonArg == null || pathArg == null) return null
671
-
672
- // Parse JSON if string, otherwise use directly
673
- if (typeof jsonArg === 'string') {
674
- try {
675
- jsonArg = JSON.parse(jsonArg)
676
- } catch {
677
- throw new ArgValueError({
678
- ...node,
679
- message: 'invalid JSON string',
680
- hint: 'First argument must be valid JSON.',
681
- rowIndex,
682
- })
683
- }
684
- }
685
- if (typeof jsonArg !== 'object' || jsonArg instanceof Date) {
686
- throw new ArgValueError({
687
- ...node,
688
- message: `first argument must be JSON string or object, got ${typeof jsonArg}`,
689
- rowIndex,
690
- })
691
- }
692
-
693
- // Parse path ("$.foo.bar[0].baz" or "foo.bar[0]")
694
- const path = String(pathArg)
695
- const normalizedPath = path.startsWith('$') ? path.slice(1) : path
696
-
697
- // Navigate the path
698
- let current = jsonArg
699
- const segments = normalizedPath.match(/\.?([^.[]+)|\[(\d+)\]/g) || []
700
- for (const segment of segments) {
701
- if (current == null) return null
702
- if (segment.startsWith('[')) {
703
- // Array index access
704
- const index = parseInt(segment.slice(1, -1), 10)
705
- if (!Array.isArray(current)) return null
706
- current = current[index]
707
- } else {
708
- // Property access
709
- const key = segment.startsWith('.') ? segment.slice(1) : segment
710
- if (typeof current !== 'object' || Array.isArray(current)) return null
711
- current = current[key]
712
- }
713
- }
714
-
715
- if (current == null) return null
716
- // JSON_EXTRACT_STRING returns text: unquoted scalars, JSON text for objects and arrays
717
- if (funcName === 'JSON_EXTRACT_STRING') {
718
- if (typeof current === 'object') return JSON.stringify(current)
719
- return String(current)
720
- }
721
- return current
676
+ return evaluateJsonExtract({ funcName, node, args, rowIndex })
722
677
  }
723
678
 
724
679
  // Check user-defined functions (case-insensitive lookup)
@@ -735,60 +690,31 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
735
690
 
736
691
  if (node.type === 'cast') {
737
692
  const val = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
738
- if (val == null) return null
739
- const { toType } = node
740
- if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
741
- if (typeof val === 'object') return stringify(val)
742
- return String(val)
743
- }
744
- // Can only cast primitives (and Dates) to other primitive types
745
- if (typeof val === 'object' && !(val instanceof Date)) {
746
- if (node.tryCast) return null
747
- throw new ExecutionError({ message: `Cannot CAST object to ${toType}`, rowIndex, ...node })
748
- }
749
- if (toType === 'INTEGER' || toType === 'INT') {
750
- const num = Number(val)
751
- if (isNaN(num)) return null
752
- return Math.trunc(num)
753
- }
754
- if (toType === 'BIGINT') {
755
- if (typeof val === 'bigint') return val
756
- const num = Number(val)
757
- // NaN and Infinity have no bigint representation
758
- if (!isFinite(num)) return null
759
- return BigInt(Math.trunc(num))
760
- }
761
- if (toType === 'FLOAT' || toType === 'REAL' || toType === 'DOUBLE') {
762
- const num = Number(val)
763
- if (isNaN(num)) return null
764
- return num
765
- }
766
- if (toType === 'BOOLEAN' || toType === 'BOOL') {
767
- return Boolean(val)
768
- }
769
- if (toType === 'TIMESTAMP') {
770
- if (typeof val === 'number' || typeof val === 'bigint') {
771
- const date = new Date(Number(val))
772
- if (isNaN(date.getTime())) return null
773
- return date
774
- }
775
- return toDate(val)
776
- }
693
+ return applyCast(node, val, rowIndex)
777
694
  }
778
695
 
779
- // IN and NOT IN with value lists
696
+ // IN and NOT IN with value lists. IN is a chain of OR'd equalities, so it
697
+ // inherits their three-valued logic: a null on either side of any equality
698
+ // makes a non-match UNKNOWN rather than false, and NOT above it must not
699
+ // turn that into true
780
700
  if (node.type === 'in valuelist') {
781
701
  const exprVal = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
702
+ let sawNull = exprVal == null
782
703
  for (const valueNode of node.values) {
783
704
  const val = await evaluateExpr({ node: valueNode, row, rowIndex, rows, context })
784
- if (sqlEquals(exprVal, val)) return true
705
+ if (val == null) {
706
+ sawNull = true
707
+ } else if (exprVal != null && sqlEquals(exprVal, val)) {
708
+ return true
709
+ }
785
710
  }
786
- return false
711
+ return sawNull ? null : false
787
712
  }
788
- // IN with subqueries
713
+ // IN with subqueries, same three-valued logic as the value-list form
789
714
  if (node.type === 'in') {
790
715
  const exprVal = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
791
716
  const subResult = executeStatement({ query: node.subquery, context })
717
+ let sawNull = false
792
718
  let innerCount = 0
793
719
  for await (const resRow of subResult.rows()) {
794
720
  if (++innerCount % YIELD_INTERVAL === 0) {
@@ -796,9 +722,13 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
796
722
  context.signal?.throwIfAborted()
797
723
  }
798
724
  const value = await resRow.cells[resRow.columns[0]]()
799
- if (sqlEquals(exprVal, value)) return true
725
+ if (exprVal == null || value == null) {
726
+ sawNull = true
727
+ } else if (exprVal != null && sqlEquals(exprVal, value)) {
728
+ return true
729
+ }
800
730
  }
801
- return false
731
+ return sawNull ? null : false
802
732
  }
803
733
 
804
734
  // EXISTS and NOT EXISTS with subqueries
@@ -0,0 +1,105 @@
1
+ import { stringify } from '../execute/utils.js'
2
+ import { ArgValueError, ExecutionError } from '../validation/executionErrors.js'
3
+ import { toDate } from './date.js'
4
+
5
+ /**
6
+ * @import { CastNode, FunctionNode, SqlPrimitive } from '../types.js'
7
+ */
8
+
9
+ /**
10
+ * Applies a scalar CAST without requiring a row evaluator.
11
+ *
12
+ * @param {CastNode} node
13
+ * @param {SqlPrimitive} value
14
+ * @param {number} [rowIndex]
15
+ * @returns {SqlPrimitive}
16
+ */
17
+ export function applyCast(node, value, rowIndex) {
18
+ if (value == null) return null
19
+ const { toType } = node
20
+ if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
21
+ return typeof value === 'object' ? stringify(value) : String(value)
22
+ }
23
+ if (typeof value === 'object' && !(value instanceof Date)) {
24
+ if (node.tryCast) return null
25
+ throw new ExecutionError({ message: `Cannot CAST object to ${toType}`, rowIndex, ...node })
26
+ }
27
+ if (toType === 'INTEGER' || toType === 'INT') {
28
+ const number = Number(value)
29
+ return isNaN(number) ? null : Math.trunc(number)
30
+ }
31
+ if (toType === 'BIGINT') {
32
+ if (typeof value === 'bigint') return value
33
+ const number = Number(value)
34
+ return isFinite(number) ? BigInt(Math.trunc(number)) : null
35
+ }
36
+ if (toType === 'FLOAT' || toType === 'REAL' || toType === 'DOUBLE') {
37
+ const number = Number(value)
38
+ return isNaN(number) ? null : number
39
+ }
40
+ if (toType === 'BOOLEAN' || toType === 'BOOL') return Boolean(value)
41
+ if (typeof value === 'number' || typeof value === 'bigint') {
42
+ const date = new Date(Number(value))
43
+ return isNaN(date.getTime()) ? null : date
44
+ }
45
+ return toDate(value)
46
+ }
47
+
48
+ /**
49
+ * Applies the JSON extraction functions shared by row and batch evaluation.
50
+ *
51
+ * @param {object} options
52
+ * @param {string} options.funcName
53
+ * @param {FunctionNode} options.node
54
+ * @param {SqlPrimitive[]} options.args
55
+ * @param {number} [options.rowIndex]
56
+ * @returns {SqlPrimitive}
57
+ */
58
+ export function evaluateJsonExtract({ funcName, node, args, rowIndex }) {
59
+ let json = args[0]
60
+ const pathArg = args[1]
61
+ if (json == null || pathArg == null) return null
62
+
63
+ if (typeof json === 'string') {
64
+ try {
65
+ json = JSON.parse(json)
66
+ } catch {
67
+ throw new ArgValueError({
68
+ ...node,
69
+ message: 'invalid JSON string',
70
+ hint: 'First argument must be valid JSON.',
71
+ rowIndex,
72
+ })
73
+ }
74
+ }
75
+ if (typeof json !== 'object' || json instanceof Date) {
76
+ throw new ArgValueError({
77
+ ...node,
78
+ message: `first argument must be JSON string or object, got ${typeof json}`,
79
+ rowIndex,
80
+ })
81
+ }
82
+
83
+ const path = String(pathArg)
84
+ const normalized = path.startsWith('$') ? path.slice(1) : path
85
+ /** @type {any} */
86
+ let current = json
87
+ const segments = normalized.match(/\.?([^.[]+)|\[(\d+)\]/g) || []
88
+ for (const segment of segments) {
89
+ if (current == null) return null
90
+ if (segment.startsWith('[')) {
91
+ if (!Array.isArray(current)) return null
92
+ current = current[parseInt(segment.slice(1, -1), 10)]
93
+ } else {
94
+ if (typeof current !== 'object' || Array.isArray(current)) return null
95
+ const key = segment.startsWith('.') ? segment.slice(1) : segment
96
+ current = current[key]
97
+ }
98
+ }
99
+
100
+ if (current == null) return null
101
+ if (funcName === 'JSON_EXTRACT_STRING') {
102
+ return typeof current === 'object' ? JSON.stringify(current) : String(current)
103
+ }
104
+ return current
105
+ }
package/src/index.d.ts CHANGED
@@ -1,19 +1,38 @@
1
- import type { AsyncDataSource, AsyncRow, ExecuteContext, ExecuteSqlOptions, ExprNode, ParseSqlOptions, PlanSqlOptions, QueryPlan, QueryResults, SqlPrimitive, Statement, Token } from './types.js'
1
+ import type { AsyncBatch, AsyncDataSource, AsyncRow, ColumnResult, ColumnVector, ExecuteContext, ExecuteSqlOptions, ExprNode, ParseSqlOptions, PlanSqlOptions, QueryPlan, QueryResults, ReadBatchColumnOptions, RowsToBatchesOptions, RowSelection, SqlPrimitive, Statement, Token } from './types.js'
2
2
  export type {
3
+ AsyncBatch,
3
4
  AsyncCells,
4
5
  AsyncDataSource,
5
6
  AsyncRow,
7
+ BatchColumn,
8
+ ColumnDemand,
9
+ ColumnReadRequest,
10
+ ColumnResult,
11
+ ColumnVector,
6
12
  ExecuteContext,
7
13
  ExecuteSqlOptions,
8
14
  ExprNode,
15
+ Field,
16
+ NumericArray,
9
17
  ParseSqlOptions,
10
18
  PlanSqlOptions,
19
+ PreparedScan,
20
+ PrepareScan,
11
21
  QueryPlan,
12
22
  QueryResults,
23
+ ReadBatchColumnOptions,
24
+ ReadColumn,
25
+ RelationSchema,
26
+ RowsToBatchesOptions,
27
+ RowSelection,
13
28
  ScanOptions,
29
+ ScanProperties,
30
+ ScanRequest,
31
+ ScanResidual,
14
32
  ScanResults,
15
33
  SelectStatement,
16
34
  SetOperationStatement,
35
+ SqlType,
17
36
  SqlPrimitive,
18
37
  Statement,
19
38
  Token,
@@ -96,6 +115,30 @@ export function asyncRow(row: Record<string, SqlPrimitive>, columns: string[]):
96
115
 
97
116
  export function cachedDataSource(source: AsyncDataSource): AsyncDataSource
98
117
 
118
+ export function selectedRowCount(selection: RowSelection): number
119
+
120
+ export function composeSelections(outer: RowSelection, inner: RowSelection): RowSelection
121
+
122
+ export function valueAt(vector: ColumnVector, index: number): SqlPrimitive
123
+
124
+ export function selectVector(vector: ColumnVector, selection: RowSelection): ColumnVector
125
+
126
+ export function readBatchColumn(options: ReadBatchColumnOptions): ColumnResult
127
+
128
+ export function selectBatch(batch: AsyncBatch, selection: RowSelection): AsyncBatch
129
+
130
+ export function rowsToBatches(
131
+ rows: AsyncIterable<AsyncRow>,
132
+ columns: string[],
133
+ options?: RowsToBatchesOptions,
134
+ ): AsyncIterable<AsyncBatch>
135
+
136
+ export function batchesToRows(
137
+ batches: AsyncIterable<AsyncBatch>,
138
+ columns: string[],
139
+ signal?: AbortSignal,
140
+ ): AsyncIterable<AsyncRow>
141
+
99
142
  /**
100
143
  * Generates a default alias for a derived column expression.
101
144
  * Useful for generating column names pre-execution.
package/src/index.js CHANGED
@@ -5,4 +5,13 @@ export { planSql } from './plan/plan.js'
5
5
  export { tokenizeSql } from './parse/tokenize.js'
6
6
  export { collect } from './execute/utils.js'
7
7
  export { asyncRow, cachedDataSource } from './backend/dataSource.js'
8
+ export {
9
+ composeSelections,
10
+ readBatchColumn,
11
+ selectBatch,
12
+ selectedRowCount,
13
+ selectVector,
14
+ valueAt,
15
+ } from './backend/batch.js'
16
+ export { batchesToRows, rowsToBatches } from './backend/batchAdapters.js'
8
17
  export { derivedAlias } from './expression/alias.js'
@@ -0,0 +1,28 @@
1
+ import type { ColumnReadRequest, ColumnResult, ColumnVector, SqlPrimitive } from './types.js'
2
+
3
+ export interface CompiledBatchExpression {
4
+ evaluate(context: ColumnReadRequest): ColumnResult
5
+ }
6
+
7
+ export type ValueKernel = (
8
+ vectors: ColumnVector[],
9
+ rowIndex: number,
10
+ streamRowIndex: number,
11
+ ) => SqlPrimitive
12
+
13
+ export interface CompileState {
14
+ dependencies: number[]
15
+ dependencyPositions: Map<number, number>
16
+ columns: readonly string[]
17
+ }
18
+
19
+ export interface BatchAggregateInputs {
20
+ keys: CompiledBatchExpression[]
21
+ filters: (CompiledBatchExpression | undefined)[]
22
+ args: (CompiledBatchExpression | undefined)[]
23
+ }
24
+
25
+ export type BatchProjection =
26
+ | { type: 'column', columnIndex: number }
27
+ | { type: 'constant', value: SqlPrimitive }
28
+ | { type: 'expression', expression: CompiledBatchExpression }