squirreling 0.9.3 → 0.9.5

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
@@ -147,7 +147,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
147
147
  - String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`
148
148
  - Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
149
149
  - Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
150
- - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
150
+ - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
151
151
  - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
152
152
  - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
153
153
  - Regex: `REGEXP_SUBSTR`, `REGEXP_REPLACE`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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.0",
40
+ "@types/node": "25.3.3",
41
41
  "@vitest/coverage-v8": "4.0.18",
42
42
  "eslint": "9.39.2",
43
43
  "eslint-plugin-jsdoc": "62.7.1",
@@ -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
  /**
@@ -271,6 +271,14 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
271
271
  return val1 == val2 ? null : val1
272
272
  }
273
273
 
274
+ if (funcName === 'DATE_TRUNC') {
275
+ return dateTrunc(args[0], args[1])
276
+ }
277
+
278
+ if (funcName === 'EXTRACT' || funcName === 'DATE_PART') {
279
+ return extractField(args[0], args[1])
280
+ }
281
+
274
282
  if (funcName === 'CURRENT_DATE') {
275
283
  return new Date().toISOString().split('T')[0]
276
284
  }
@@ -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 === '-') {
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @import { SimpleGeometry } from './geometry.js'
3
+ */
4
+
5
+ import { EPSILON, EPSILON_SQ, distSq } from './primitives.js'
6
+
7
+ /**
8
+ * @param {SimpleGeometry} a
9
+ * @param {SimpleGeometry} b
10
+ * @returns {boolean}
11
+ */
12
+ export function geometryEqual(a, b) {
13
+ if (a.type === 'Point' && b.type === 'Point') {
14
+ return distSq(a.coordinates, b.coordinates) < EPSILON_SQ
15
+ } else if (a.type === 'LineString' && b.type === 'LineString') {
16
+ return lineEqual(a.coordinates, b.coordinates)
17
+ } else if (a.type === 'Polygon' && b.type === 'Polygon') {
18
+ return polygonEqual(a.coordinates, b.coordinates)
19
+ }
20
+ return false
21
+ }
22
+
23
+ /**
24
+ * @param {number[][]} a
25
+ * @param {number[][]} b
26
+ * @returns {boolean}
27
+ */
28
+ function lineEqual(a, b) {
29
+ if (a.length !== b.length) return false
30
+ // Forward
31
+ let forward = true
32
+ for (let i = 0; i < a.length; i++) {
33
+ if (Math.abs(a[i][0] - b[i][0]) > EPSILON || Math.abs(a[i][1] - b[i][1]) > EPSILON) {
34
+ forward = false
35
+ break
36
+ }
37
+ }
38
+ if (forward) return true
39
+ // Reverse
40
+ for (let i = 0; i < a.length; i++) {
41
+ if (Math.abs(a[i][0] - b[a.length - 1 - i][0]) > EPSILON || Math.abs(a[i][1] - b[a.length - 1 - i][1]) > EPSILON) {
42
+ return false
43
+ }
44
+ }
45
+ return true
46
+ }
47
+
48
+ /**
49
+ * @param {number[][][]} a
50
+ * @param {number[][][]} b
51
+ * @returns {boolean}
52
+ */
53
+ function polygonEqual(a, b) {
54
+ if (a.length !== b.length) return false
55
+ for (let i = 0; i < a.length; i++) {
56
+ if (!ringsEqual(a[i], b[i])) return false
57
+ }
58
+ return true
59
+ }
60
+
61
+ /**
62
+ * Test if two rings are equal (same vertices, possibly different starting point).
63
+ *
64
+ * @param {number[][]} ring1
65
+ * @param {number[][]} ring2
66
+ * @returns {boolean}
67
+ */
68
+ function ringsEqual(ring1, ring2) {
69
+ if (ring1.length !== ring2.length) return false
70
+ // Try every rotation
71
+ const n = ring1.length - 1 // closed ring, last = first
72
+ for (let offset = 0; offset < n; offset++) {
73
+ let match = true
74
+ for (let i = 0; i < n; i++) {
75
+ const j = (i + offset) % n
76
+ if (Math.abs(ring1[i][0] - ring2[j][0]) > EPSILON ||
77
+ Math.abs(ring1[i][1] - ring2[j][1]) > EPSILON) {
78
+ match = false
79
+ break
80
+ }
81
+ }
82
+ if (match) return true
83
+ }
84
+ // Try reverse direction
85
+ for (let offset = 0; offset < n; offset++) {
86
+ let match = true
87
+ for (let i = 0; i < n; i++) {
88
+ const j = (n - i + offset) % n
89
+ if (Math.abs(ring1[i][0] - ring2[j][0]) > EPSILON ||
90
+ Math.abs(ring1[i][1] - ring2[j][1]) > EPSILON) {
91
+ match = false
92
+ break
93
+ }
94
+ }
95
+ if (match) return true
96
+ }
97
+ return false
98
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Geometry types based on the GeoJSON specification (RFC 7946)
3
+ */
4
+ export type Geometry =
5
+ | Point
6
+ | MultiPoint
7
+ | LineString
8
+ | MultiLineString
9
+ | Polygon
10
+ | MultiPolygon
11
+ | GeometryCollection
12
+
13
+ /**
14
+ * Simple geometries that are not collections.
15
+ */
16
+ export type SimpleGeometry = Point | LineString | Polygon
17
+
18
+ export interface BBox {
19
+ minX: number
20
+ minY: number
21
+ maxX: number
22
+ maxY: number
23
+ }
24
+
25
+ /**
26
+ * Boundary relationship between two geometries.
27
+ */
28
+ export type Relation = 'OUTSIDE' | 'BOUNDARY' | 'INSIDE'
29
+
30
+ /**
31
+ * Position is an array of at least two numbers.
32
+ * The order should be [longitude, latitude] with optional properties (eg- altitude).
33
+ */
34
+ export type Position = number[]
35
+
36
+ export interface Point {
37
+ type: 'Point'
38
+ coordinates: Position
39
+ }
40
+
41
+ export interface MultiPoint {
42
+ type: 'MultiPoint'
43
+ coordinates: Position[]
44
+ }
45
+
46
+ export interface LineString {
47
+ type: 'LineString'
48
+ coordinates: Position[]
49
+ }
50
+
51
+ /**
52
+ * Each element is one LineString.
53
+ */
54
+ export interface MultiLineString {
55
+ type: 'MultiLineString'
56
+ coordinates: Position[][]
57
+ }
58
+
59
+ /**
60
+ * Each element is a linear ring.
61
+ */
62
+ export interface Polygon {
63
+ type: 'Polygon'
64
+ coordinates: Position[][]
65
+ }
66
+
67
+ /**
68
+ * Each element is one Polygon.
69
+ */
70
+ export interface MultiPolygon {
71
+ type: 'MultiPolygon'
72
+ coordinates: Position[][][]
73
+ }
74
+
75
+ export interface GeometryCollection {
76
+ type: 'GeometryCollection'
77
+ geometries: Geometry[]
78
+ }