squirreling 0.9.2 → 0.9.4
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 +2 -0
- package/package.json +3 -3
- package/src/backend/dataSource.js +2 -2
- package/src/expression/evaluate.js +32 -1
- package/src/expression/geometry.d.ts +71 -0
- package/src/expression/spatial.equality.js +98 -0
- package/src/expression/spatial.geometry.js +620 -0
- package/src/expression/spatial.js +365 -0
- package/src/expression/wkt.js +222 -0
- package/src/types.d.ts +16 -0
- package/src/validation.js +39 -2
- package/src/validationErrors.js +16 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { geomToWkt, parseWkt } from './wkt.js'
|
|
2
|
+
import { distSq, pointInPolygon, pointLineRelation, pointToSegmentDistSq, simpleIntersects, simplePairContainment, simplePairRelation } from './spatial.geometry.js'
|
|
3
|
+
import { simpleGeomEqual } from './spatial.equality.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @import { SpatialFunc, SqlPrimitive } from '../types.js'
|
|
7
|
+
* @import { Geometry, Point, SimpleGeometry } from './geometry.js'
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Evaluate a spatial predicate function.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} options
|
|
14
|
+
* @param {SpatialFunc} options.funcName
|
|
15
|
+
* @param {SqlPrimitive[]} options.args
|
|
16
|
+
* @returns {SqlPrimitive}
|
|
17
|
+
*/
|
|
18
|
+
export function evaluateSpatialFunc({ funcName, args }) {
|
|
19
|
+
// Singleton functions
|
|
20
|
+
if (funcName === 'ST_GEOMFROMTEXT') {
|
|
21
|
+
if (args[0] == null) return null
|
|
22
|
+
return parseWkt(String(args[0]))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (funcName === 'ST_MAKEENVELOPE') {
|
|
26
|
+
if (args[0] == null || args[1] == null || args[2] == null || args[3] == null) return null
|
|
27
|
+
const xmin = Number(args[0])
|
|
28
|
+
const ymin = Number(args[1])
|
|
29
|
+
const xmax = Number(args[2])
|
|
30
|
+
const ymax = Number(args[3])
|
|
31
|
+
return {
|
|
32
|
+
type: 'Polygon',
|
|
33
|
+
coordinates: [[[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax], [xmin, ymin]]],
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const geomA = toGeometry(args[0])
|
|
38
|
+
if (funcName === 'ST_ASTEXT') {
|
|
39
|
+
if (geomA == null) return null
|
|
40
|
+
return geomToWkt(geomA)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Predicate functions (require two geometries)
|
|
44
|
+
const geomB = toGeometry(args[1])
|
|
45
|
+
if (geomA == null || geomB == null) return null
|
|
46
|
+
const a = decompose(geomA)
|
|
47
|
+
const b = decompose(geomB)
|
|
48
|
+
|
|
49
|
+
switch (funcName) {
|
|
50
|
+
case 'ST_INTERSECTS': return simpleIntersects(a, b)
|
|
51
|
+
case 'ST_CONTAINS': return stContains(a, b)
|
|
52
|
+
case 'ST_CONTAINSPROPERLY': return stContainsProperly(a, b)
|
|
53
|
+
case 'ST_WITHIN': return stContains(b, a) // inverse of contains
|
|
54
|
+
case 'ST_OVERLAPS': return stOverlaps(a, b)
|
|
55
|
+
case 'ST_TOUCHES': return stTouches(a, b)
|
|
56
|
+
case 'ST_EQUALS': return stEquals(a, b)
|
|
57
|
+
case 'ST_CROSSES': return stCrosses(a, b)
|
|
58
|
+
case 'ST_COVERS': return stContains(a, b) // TODO: handle boundary
|
|
59
|
+
case 'ST_COVEREDBY': return stContains(b, a) // inverse of covers
|
|
60
|
+
case 'ST_DWITHIN': {
|
|
61
|
+
if (args[2] == null) return null
|
|
62
|
+
const dist = Number(args[2])
|
|
63
|
+
return stDWithin(a, b, dist)
|
|
64
|
+
}
|
|
65
|
+
default: return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Normalize a geometry value. Accepts GeoJSON objects.
|
|
71
|
+
* Returns null if the value is not a valid geometry.
|
|
72
|
+
*
|
|
73
|
+
* @param {SqlPrimitive} val
|
|
74
|
+
* @returns {Geometry | null}
|
|
75
|
+
*/
|
|
76
|
+
function toGeometry(val) {
|
|
77
|
+
if (typeof val === 'object' && val != null && 'type' in val) {
|
|
78
|
+
if (val.type === 'GeometryCollection' && Array.isArray(val.geometries)) {
|
|
79
|
+
// eslint-disable-next-line no-extra-parens
|
|
80
|
+
return /** @type {Geometry} */ (val)
|
|
81
|
+
}
|
|
82
|
+
const geometryTypes = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon']
|
|
83
|
+
if (geometryTypes.includes(val.type) && Array.isArray(val.coordinates)) {
|
|
84
|
+
// eslint-disable-next-line no-extra-parens
|
|
85
|
+
return /** @type {Geometry} */ (val)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Minimum distance between geometries
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get all line segments from a geometry.
|
|
97
|
+
*
|
|
98
|
+
* @param {SimpleGeometry[]} geoms
|
|
99
|
+
* @returns {{ segments: Array<[number[], number[]]>, points: number[][] }}
|
|
100
|
+
*/
|
|
101
|
+
function getSegments(geoms) {
|
|
102
|
+
/** @type {Array<[number[], number[]]>} */
|
|
103
|
+
const segments = []
|
|
104
|
+
/** @type {number[][]} */
|
|
105
|
+
const points = []
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {number[][]} coords
|
|
109
|
+
*/
|
|
110
|
+
function addLine(coords) {
|
|
111
|
+
for (let i = 0; i < coords.length - 1; i++) {
|
|
112
|
+
segments.push([coords[i], coords[i + 1]])
|
|
113
|
+
}
|
|
114
|
+
points.push(...coords)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const geom of geoms) {
|
|
118
|
+
if (geom.type === 'Point') points.push(geom.coordinates)
|
|
119
|
+
else if (geom.type === 'LineString') addLine(geom.coordinates)
|
|
120
|
+
else if (geom.type === 'Polygon') geom.coordinates.forEach(addLine)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { segments, points }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Test whether two geometries are within a given distance of each other.
|
|
128
|
+
* Intersecting geometries have distance 0. For non-intersecting geometries,
|
|
129
|
+
* the minimum distance is always at an endpoint, so point-to-segment suffices.
|
|
130
|
+
*
|
|
131
|
+
* @param {SimpleGeometry[]} a
|
|
132
|
+
* @param {SimpleGeometry[]} b
|
|
133
|
+
* @param {number} distance
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
function stDWithin(a, b, distance) {
|
|
137
|
+
if (distance < 0) return false
|
|
138
|
+
if (simpleIntersects(a, b)) return true
|
|
139
|
+
|
|
140
|
+
const distanceSq = distance * distance
|
|
141
|
+
const { points: ptsA, segments: segsA } = getSegments(a)
|
|
142
|
+
const { points: ptsB, segments: segsB } = getSegments(b)
|
|
143
|
+
|
|
144
|
+
// Point-to-point
|
|
145
|
+
for (const pa of ptsA) {
|
|
146
|
+
for (const pb of ptsB) {
|
|
147
|
+
if (distSq(pa, pb) <= distanceSq) return true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Point-to-segment
|
|
152
|
+
for (const pt of ptsA) {
|
|
153
|
+
for (const [b1, b2] of segsB) {
|
|
154
|
+
if (pointToSegmentDistSq(pt, b1, b2) <= distanceSq) return true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const pt of ptsB) {
|
|
158
|
+
for (const [a1, a2] of segsA) {
|
|
159
|
+
if (pointToSegmentDistSq(pt, a1, a2) <= distanceSq) return true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// Spatial predicate dispatch - decompose to primitive type pairs
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Decompose Multi* and GeometryCollection into simple geometries.
|
|
172
|
+
*
|
|
173
|
+
* @param {Geometry} geom
|
|
174
|
+
* @returns {SimpleGeometry[]}
|
|
175
|
+
*/
|
|
176
|
+
function decompose(geom) {
|
|
177
|
+
switch (geom.type) {
|
|
178
|
+
case 'MultiPoint':
|
|
179
|
+
return geom.coordinates.map(c => ({ type: 'Point', coordinates: c }))
|
|
180
|
+
case 'MultiLineString':
|
|
181
|
+
return geom.coordinates.map(c => ({ type: 'LineString', coordinates: c }))
|
|
182
|
+
case 'MultiPolygon':
|
|
183
|
+
return geom.coordinates.map(c => ({ type: 'Polygon', coordinates: c }))
|
|
184
|
+
case 'GeometryCollection':
|
|
185
|
+
return geom.geometries.flatMap(decompose)
|
|
186
|
+
default:
|
|
187
|
+
return [geom]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ============================================================================
|
|
192
|
+
// ST_Contains
|
|
193
|
+
// ============================================================================
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {SimpleGeometry[]} a
|
|
197
|
+
* @param {SimpleGeometry[]} b
|
|
198
|
+
* @returns {boolean}
|
|
199
|
+
*/
|
|
200
|
+
function stContains(a, b) {
|
|
201
|
+
// Every part of b must be inside some part of a
|
|
202
|
+
return b.every(pb => a.some(pa => simplePairContainment(pa, pb) !== 'OUTSIDE'))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// ST_ContainsProperly
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @param {SimpleGeometry[]} a
|
|
211
|
+
* @param {SimpleGeometry[]} b
|
|
212
|
+
* @returns {boolean}
|
|
213
|
+
*/
|
|
214
|
+
function stContainsProperly(a, b) {
|
|
215
|
+
// Every part of b must be strictly inside some part of a
|
|
216
|
+
return b.every(pb => a.some(pa => simplePairContainment(pa, pb) === 'INSIDE'))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// ST_Touches
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param {SimpleGeometry[]} a
|
|
225
|
+
* @param {SimpleGeometry[]} b
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
function stTouches(a, b) {
|
|
229
|
+
let intersects = false
|
|
230
|
+
for (const pa of a) {
|
|
231
|
+
for (const pb of b) {
|
|
232
|
+
const rel = simplePairRelation(pa, pb)
|
|
233
|
+
if (rel === 'INSIDE') return false
|
|
234
|
+
if (rel === 'BOUNDARY') intersects = true
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return intersects
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// ST_Overlaps
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {SimpleGeometry[]} a
|
|
246
|
+
* @param {SimpleGeometry[]} b
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
function stOverlaps(a, b) {
|
|
250
|
+
// Overlaps requires same dimension, and that each geometry has some part
|
|
251
|
+
// inside the other and some part outside
|
|
252
|
+
const dimA = geometryDimension(a)
|
|
253
|
+
const dimB = geometryDimension(b)
|
|
254
|
+
if (dimA !== dimB) return false
|
|
255
|
+
if (!simpleIntersects(a, b)) return false
|
|
256
|
+
if (stEquals(a, b)) return false
|
|
257
|
+
// Must not be containment
|
|
258
|
+
if (stContains(a, b) || stContains(b, a)) return false
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {SimpleGeometry[]} parts
|
|
264
|
+
* @returns {number}
|
|
265
|
+
*/
|
|
266
|
+
function geometryDimension(parts) {
|
|
267
|
+
let max = 0
|
|
268
|
+
for (const geom of parts) {
|
|
269
|
+
switch (geom.type) {
|
|
270
|
+
case 'Point':
|
|
271
|
+
break
|
|
272
|
+
case 'LineString':
|
|
273
|
+
if (max < 1) max = 1
|
|
274
|
+
break
|
|
275
|
+
case 'Polygon':
|
|
276
|
+
return 2
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return max
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ============================================================================
|
|
283
|
+
// ST_Equals
|
|
284
|
+
// ============================================================================
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {SimpleGeometry[]} a
|
|
288
|
+
* @param {SimpleGeometry[]} b
|
|
289
|
+
* @returns {boolean}
|
|
290
|
+
*/
|
|
291
|
+
function stEquals(a, b) {
|
|
292
|
+
if (a.length !== b.length) return false
|
|
293
|
+
|
|
294
|
+
// For each simple geometry in a, find a matching one in b
|
|
295
|
+
const used = new Set()
|
|
296
|
+
for (const pa of a) {
|
|
297
|
+
let found = false
|
|
298
|
+
for (let i = 0; i < b.length; i++) {
|
|
299
|
+
if (used.has(i)) continue
|
|
300
|
+
if (simpleGeomEqual(pa, b[i])) {
|
|
301
|
+
used.add(i)
|
|
302
|
+
found = true
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!found) return false
|
|
307
|
+
}
|
|
308
|
+
return true
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// ST_Crosses
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* @param {SimpleGeometry[]} a
|
|
317
|
+
* @param {SimpleGeometry[]} b
|
|
318
|
+
* @returns {boolean}
|
|
319
|
+
*/
|
|
320
|
+
function stCrosses(a, b) {
|
|
321
|
+
// Crosses: interiors intersect, and the intersection has lower dimension
|
|
322
|
+
// than the maximum of the two geometries' dimensions
|
|
323
|
+
const dimA = geometryDimension(a)
|
|
324
|
+
const dimB = geometryDimension(b)
|
|
325
|
+
|
|
326
|
+
if (!simpleIntersects(a, b)) return false
|
|
327
|
+
|
|
328
|
+
// Point/Point or Polygon/Polygon cannot cross
|
|
329
|
+
if (dimA === dimB && dimA !== 1) return false
|
|
330
|
+
|
|
331
|
+
// Line/Line: they cross if they intersect at a point (not overlap)
|
|
332
|
+
if (dimA === 1 && dimB === 1) {
|
|
333
|
+
// They cross if they intersect but neither contains the other
|
|
334
|
+
// and the intersection is a set of points (not line segments)
|
|
335
|
+
return !stContains(a, b) && !stContains(b, a) && !stTouches(a, b)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Point/Line, Point/Polygon: point "in interior"
|
|
339
|
+
if (dimA === 0 && dimB >= 1) {
|
|
340
|
+
for (const pa of a) {
|
|
341
|
+
// eslint-disable-next-line no-extra-parens
|
|
342
|
+
const point = /** @type {Point} */ (pa)
|
|
343
|
+
for (const pb of b) {
|
|
344
|
+
if (pb.type === 'LineString') {
|
|
345
|
+
if (pointLineRelation(point.coordinates, pb.coordinates) === 'INSIDE') return true
|
|
346
|
+
}
|
|
347
|
+
if (pb.type === 'Polygon') {
|
|
348
|
+
if (pointInPolygon(point.coordinates, pb.coordinates) === 'INSIDE') return true
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return false
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Line/Polygon: line crosses polygon if part of line is inside and part is outside
|
|
356
|
+
if (dimA === 1 && dimB === 2) {
|
|
357
|
+
return !stContains(b, a)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Symmetric cases
|
|
361
|
+
if (dimA >= 1 && dimB === 0) return stCrosses(b, a)
|
|
362
|
+
if (dimA === 2 && dimB === 1) return stCrosses(b, a)
|
|
363
|
+
|
|
364
|
+
return false
|
|
365
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Geometry } from './geometry.js'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a WKT string into a GeoJSON geometry.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} wkt
|
|
9
|
+
* @returns {Geometry | null}
|
|
10
|
+
*/
|
|
11
|
+
export function parseWkt(wkt) {
|
|
12
|
+
const s = wkt.trim()
|
|
13
|
+
const upper = s.toUpperCase()
|
|
14
|
+
|
|
15
|
+
if (upper.startsWith('POINT')) {
|
|
16
|
+
const coords = parseWktCoordinate(s.slice(5).trim())
|
|
17
|
+
if (!coords) return null
|
|
18
|
+
return { type: 'Point', coordinates: coords }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (upper.startsWith('MULTIPOINT')) {
|
|
22
|
+
const inner = extractParens(s.slice(10).trim())
|
|
23
|
+
if (inner == null) return null
|
|
24
|
+
const coords = parseWktCoordinateList(inner)
|
|
25
|
+
if (!coords) return null
|
|
26
|
+
return { type: 'MultiPoint', coordinates: coords }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (upper.startsWith('MULTILINESTRING')) {
|
|
30
|
+
const inner = extractParens(s.slice(15).trim())
|
|
31
|
+
if (inner == null) return null
|
|
32
|
+
const rings = parseWktRingList(inner)
|
|
33
|
+
if (!rings) return null
|
|
34
|
+
return { type: 'MultiLineString', coordinates: rings }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (upper.startsWith('MULTIPOLYGON')) {
|
|
38
|
+
const inner = extractParens(s.slice(12).trim())
|
|
39
|
+
if (inner == null) return null
|
|
40
|
+
const polys = parseWktPolygonList(inner)
|
|
41
|
+
if (!polys) return null
|
|
42
|
+
return { type: 'MultiPolygon', coordinates: polys }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (upper.startsWith('LINESTRING')) {
|
|
46
|
+
const inner = extractParens(s.slice(10).trim())
|
|
47
|
+
if (inner == null) return null
|
|
48
|
+
const coords = parseWktCoordinateList(inner)
|
|
49
|
+
if (!coords) return null
|
|
50
|
+
return { type: 'LineString', coordinates: coords }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (upper.startsWith('POLYGON')) {
|
|
54
|
+
const inner = extractParens(s.slice(7).trim())
|
|
55
|
+
if (inner == null) return null
|
|
56
|
+
const rings = parseWktRingList(inner)
|
|
57
|
+
if (!rings) return null
|
|
58
|
+
return { type: 'Polygon', coordinates: rings }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convert a GeoJSON geometry to WKT.
|
|
66
|
+
*
|
|
67
|
+
* @param {Geometry} geom
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function geomToWkt(geom) {
|
|
71
|
+
switch (geom.type) {
|
|
72
|
+
case 'Point':
|
|
73
|
+
return `POINT (${coordToWkt(geom.coordinates)})`
|
|
74
|
+
case 'MultiPoint':
|
|
75
|
+
return `MULTIPOINT (${geom.coordinates.map(c => `(${coordToWkt(c)})`).join(', ')})`
|
|
76
|
+
case 'LineString':
|
|
77
|
+
return `LINESTRING (${coordListToWkt(geom.coordinates)})`
|
|
78
|
+
case 'MultiLineString':
|
|
79
|
+
return `MULTILINESTRING (${geom.coordinates.map(l => `(${coordListToWkt(l)})`).join(', ')})`
|
|
80
|
+
case 'Polygon':
|
|
81
|
+
return `POLYGON (${geom.coordinates.map(r => `(${coordListToWkt(r)})`).join(', ')})`
|
|
82
|
+
case 'MultiPolygon':
|
|
83
|
+
return `MULTIPOLYGON (${geom.coordinates.map(p => `(${p.map(r => `(${coordListToWkt(r)})`).join(', ')})`).join(', ')})`
|
|
84
|
+
case 'GeometryCollection':
|
|
85
|
+
return `GEOMETRYCOLLECTION (${(geom.geometries || []).map(g => geomToWkt(g)).join(', ')})`
|
|
86
|
+
default:
|
|
87
|
+
return ''
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract content inside outer parentheses.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} s
|
|
95
|
+
* @returns {string | null}
|
|
96
|
+
*/
|
|
97
|
+
function extractParens(s) {
|
|
98
|
+
const trimmed = s.trim()
|
|
99
|
+
if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) return null
|
|
100
|
+
return trimmed.slice(1, -1).trim()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse a single coordinate like "(1 2)" or "1 2".
|
|
105
|
+
*
|
|
106
|
+
* @param {string} s
|
|
107
|
+
* @returns {number[] | null}
|
|
108
|
+
*/
|
|
109
|
+
function parseWktCoordinate(s) {
|
|
110
|
+
const inner = s.trim().replace(/^\(/, '').replace(/\)$/, '').trim()
|
|
111
|
+
const parts = inner.split(/\s+/)
|
|
112
|
+
if (parts.length < 2) return null
|
|
113
|
+
const x = Number(parts[0])
|
|
114
|
+
const y = Number(parts[1])
|
|
115
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
|
116
|
+
return [x, y]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a comma-separated list of coordinates like "1 2, 3 4, 5 6".
|
|
121
|
+
*
|
|
122
|
+
* @param {string} s
|
|
123
|
+
* @returns {number[][] | null}
|
|
124
|
+
*/
|
|
125
|
+
function parseWktCoordinateList(s) {
|
|
126
|
+
const parts = s.split(',')
|
|
127
|
+
/** @type {number[][]} */
|
|
128
|
+
const coords = []
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
const trimmed = part.trim().replace(/^\(/, '').replace(/\)$/, '').trim()
|
|
131
|
+
const nums = trimmed.split(/\s+/)
|
|
132
|
+
if (nums.length < 2) return null
|
|
133
|
+
const x = Number(nums[0])
|
|
134
|
+
const y = Number(nums[1])
|
|
135
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
|
136
|
+
coords.push([x, y])
|
|
137
|
+
}
|
|
138
|
+
return coords.length ? coords : null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parse a list of rings like "(1 2, 3 4), (5 6, 7 8)".
|
|
143
|
+
*
|
|
144
|
+
* @param {string} s
|
|
145
|
+
* @returns {number[][][] | null}
|
|
146
|
+
*/
|
|
147
|
+
function parseWktRingList(s) {
|
|
148
|
+
/** @type {number[][][]} */
|
|
149
|
+
const rings = []
|
|
150
|
+
const ringStrs = splitTopLevel(s)
|
|
151
|
+
for (const ringStr of ringStrs) {
|
|
152
|
+
const inner = extractParens(ringStr.trim())
|
|
153
|
+
if (inner == null) return null
|
|
154
|
+
const coords = parseWktCoordinateList(inner)
|
|
155
|
+
if (!coords) return null
|
|
156
|
+
rings.push(coords)
|
|
157
|
+
}
|
|
158
|
+
return rings.length ? rings : null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse a list of polygons like "((ring1), (ring2)), ((ring3))".
|
|
163
|
+
*
|
|
164
|
+
* @param {string} s
|
|
165
|
+
* @returns {number[][][][] | null}
|
|
166
|
+
*/
|
|
167
|
+
function parseWktPolygonList(s) {
|
|
168
|
+
/** @type {number[][][][]} */
|
|
169
|
+
const polys = []
|
|
170
|
+
const polyStrs = splitTopLevel(s)
|
|
171
|
+
for (const polyStr of polyStrs) {
|
|
172
|
+
const inner = extractParens(polyStr.trim())
|
|
173
|
+
if (inner == null) return null
|
|
174
|
+
const rings = parseWktRingList(inner)
|
|
175
|
+
if (!rings) return null
|
|
176
|
+
polys.push(rings)
|
|
177
|
+
}
|
|
178
|
+
return polys.length ? polys : null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Split a string by commas at the top-level (not inside parentheses).
|
|
183
|
+
*
|
|
184
|
+
* @param {string} s
|
|
185
|
+
* @returns {string[]}
|
|
186
|
+
*/
|
|
187
|
+
function splitTopLevel(s) {
|
|
188
|
+
/** @type {string[]} */
|
|
189
|
+
const parts = []
|
|
190
|
+
let depth = 0
|
|
191
|
+
let start = 0
|
|
192
|
+
for (let i = 0; i < s.length; i++) {
|
|
193
|
+
if (s[i] === '(') depth++
|
|
194
|
+
else if (s[i] === ')') depth--
|
|
195
|
+
else if (s[i] === ',' && depth === 0) {
|
|
196
|
+
parts.push(s.slice(start, i))
|
|
197
|
+
start = i + 1
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
parts.push(s.slice(start))
|
|
201
|
+
return parts
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Format a single coordinate to WKT.
|
|
206
|
+
*
|
|
207
|
+
* @param {number[]} coord
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
function coordToWkt(coord) {
|
|
211
|
+
return `${coord[0]} ${coord[1]}`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Format a coordinate list to WKT.
|
|
216
|
+
*
|
|
217
|
+
* @param {number[][]} coords
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
function coordListToWkt(coords) {
|
|
221
|
+
return coords.map(coordToWkt).join(', ')
|
|
222
|
+
}
|
package/src/types.d.ts
CHANGED
|
@@ -276,6 +276,22 @@ export type StringFunc =
|
|
|
276
276
|
| 'RIGHT'
|
|
277
277
|
| 'INSTR'
|
|
278
278
|
|
|
279
|
+
export type SpatialFunc =
|
|
280
|
+
| 'ST_INTERSECTS'
|
|
281
|
+
| 'ST_CONTAINS'
|
|
282
|
+
| 'ST_CONTAINSPROPERLY'
|
|
283
|
+
| 'ST_WITHIN'
|
|
284
|
+
| 'ST_OVERLAPS'
|
|
285
|
+
| 'ST_TOUCHES'
|
|
286
|
+
| 'ST_EQUALS'
|
|
287
|
+
| 'ST_CROSSES'
|
|
288
|
+
| 'ST_COVERS'
|
|
289
|
+
| 'ST_COVEREDBY'
|
|
290
|
+
| 'ST_DWITHIN'
|
|
291
|
+
| 'ST_GEOMFROMTEXT'
|
|
292
|
+
| 'ST_MAKEENVELOPE'
|
|
293
|
+
| 'ST_ASTEXT'
|
|
294
|
+
|
|
279
295
|
export interface StarColumn {
|
|
280
296
|
kind: 'star'
|
|
281
297
|
table?: string
|
package/src/validation.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ParseError } from './parseErrors.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @import { AggregateFunc, BinaryOp, ExprNode, FunctionNode, IntervalUnit, MathFunc, StringFunc, UserDefinedFunction } from './types.js'
|
|
4
|
+
* @import { AggregateFunc, BinaryOp, ExprNode, FunctionNode, IntervalUnit, MathFunc, SpatialFunc, StringFunc, UserDefinedFunction } from './types.js'
|
|
5
5
|
* @param {string} name
|
|
6
6
|
* @returns {name is AggregateFunc}
|
|
7
7
|
*/
|
|
@@ -79,6 +79,19 @@ export function isRegexpFunc(name) {
|
|
|
79
79
|
return ['REGEXP_SUBSTR', 'REGEXP_REPLACE'].includes(name)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} name
|
|
84
|
+
* @returns {name is SpatialFunc}
|
|
85
|
+
*/
|
|
86
|
+
export function isSpatialFunc(name) {
|
|
87
|
+
return [
|
|
88
|
+
'ST_INTERSECTS', 'ST_CONTAINS', 'ST_CONTAINSPROPERLY', 'ST_WITHIN',
|
|
89
|
+
'ST_OVERLAPS', 'ST_TOUCHES', 'ST_EQUALS', 'ST_CROSSES',
|
|
90
|
+
'ST_COVERS', 'ST_COVEREDBY', 'ST_DWITHIN',
|
|
91
|
+
'ST_GEOMFROMTEXT', 'ST_MAKEENVELOPE', 'ST_ASTEXT',
|
|
92
|
+
].includes(name)
|
|
93
|
+
}
|
|
94
|
+
|
|
82
95
|
/**
|
|
83
96
|
* @param {string} name
|
|
84
97
|
* @returns {name is MathFunc}
|
|
@@ -178,6 +191,12 @@ export const FUNCTION_ARG_COUNTS = {
|
|
|
178
191
|
JSON_OBJECT: { min: 0 },
|
|
179
192
|
JSON_ARRAYAGG: { min: 1, max: 1 },
|
|
180
193
|
|
|
194
|
+
// Array functions
|
|
195
|
+
ARRAY_LENGTH: { min: 1, max: 1 },
|
|
196
|
+
ARRAY_POSITION: { min: 2, max: 2 },
|
|
197
|
+
ARRAY_SORT: { min: 1, max: 1 },
|
|
198
|
+
CARDINALITY: { min: 1, max: 1 },
|
|
199
|
+
|
|
181
200
|
// Conditional functions
|
|
182
201
|
COALESCE: { min: 1 },
|
|
183
202
|
NULLIF: { min: 2, max: 2 },
|
|
@@ -190,6 +209,22 @@ export const FUNCTION_ARG_COUNTS = {
|
|
|
190
209
|
MAX: { min: 1, max: 1 },
|
|
191
210
|
STDDEV_SAMP: { min: 1, max: 1 },
|
|
192
211
|
STDDEV_POP: { min: 1, max: 1 },
|
|
212
|
+
|
|
213
|
+
// Spatial predicate functions
|
|
214
|
+
ST_INTERSECTS: { min: 2, max: 2 },
|
|
215
|
+
ST_CONTAINS: { min: 2, max: 2 },
|
|
216
|
+
ST_CONTAINSPROPERLY: { min: 2, max: 2 },
|
|
217
|
+
ST_WITHIN: { min: 2, max: 2 },
|
|
218
|
+
ST_OVERLAPS: { min: 2, max: 2 },
|
|
219
|
+
ST_TOUCHES: { min: 2, max: 2 },
|
|
220
|
+
ST_EQUALS: { min: 2, max: 2 },
|
|
221
|
+
ST_CROSSES: { min: 2, max: 2 },
|
|
222
|
+
ST_COVERS: { min: 2, max: 2 },
|
|
223
|
+
ST_COVEREDBY: { min: 2, max: 2 },
|
|
224
|
+
ST_DWITHIN: { min: 3, max: 3 },
|
|
225
|
+
ST_GEOMFROMTEXT: { min: 1, max: 1 },
|
|
226
|
+
ST_MAKEENVELOPE: { min: 4, max: 4 },
|
|
227
|
+
ST_ASTEXT: { min: 1, max: 1 },
|
|
193
228
|
}
|
|
194
229
|
|
|
195
230
|
/**
|
|
@@ -249,7 +284,8 @@ export function isKnownFunction(funcName, functions) {
|
|
|
249
284
|
isAggregateFunc(funcName) ||
|
|
250
285
|
isMathFunc(funcName) ||
|
|
251
286
|
isStringFunc(funcName) ||
|
|
252
|
-
isRegexpFunc(funcName)
|
|
287
|
+
isRegexpFunc(funcName) ||
|
|
288
|
+
isSpatialFunc(funcName)
|
|
253
289
|
) {
|
|
254
290
|
return true
|
|
255
291
|
}
|
|
@@ -258,6 +294,7 @@ export function isKnownFunction(funcName, functions) {
|
|
|
258
294
|
if ([
|
|
259
295
|
'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP',
|
|
260
296
|
'JSON_VALUE', 'JSON_QUERY', 'JSON_OBJECT',
|
|
297
|
+
'ARRAY_LENGTH', 'ARRAY_POSITION', 'ARRAY_SORT', 'CARDINALITY',
|
|
261
298
|
'COALESCE', 'NULLIF', 'CAST',
|
|
262
299
|
].includes(funcName)) {
|
|
263
300
|
return true
|
package/src/validationErrors.js
CHANGED
|
@@ -68,6 +68,22 @@ export const FUNCTION_SIGNATURES = {
|
|
|
68
68
|
MAX: 'expression',
|
|
69
69
|
STDDEV_SAMP: 'expression',
|
|
70
70
|
STDDEV_POP: 'expression',
|
|
71
|
+
|
|
72
|
+
// Spatial predicate functions
|
|
73
|
+
ST_INTERSECTS: 'geometry, geometry',
|
|
74
|
+
ST_CONTAINS: 'geometry, geometry',
|
|
75
|
+
ST_CONTAINSPROPERLY: 'geometry, geometry',
|
|
76
|
+
ST_WITHIN: 'geometry, geometry',
|
|
77
|
+
ST_OVERLAPS: 'geometry, geometry',
|
|
78
|
+
ST_TOUCHES: 'geometry, geometry',
|
|
79
|
+
ST_EQUALS: 'geometry, geometry',
|
|
80
|
+
ST_CROSSES: 'geometry, geometry',
|
|
81
|
+
ST_COVERS: 'geometry, geometry',
|
|
82
|
+
ST_COVEREDBY: 'geometry, geometry',
|
|
83
|
+
ST_DWITHIN: 'geometry, geometry, distance',
|
|
84
|
+
ST_GEOMFROMTEXT: 'wkt',
|
|
85
|
+
ST_MAKEENVELOPE: 'xmin, ymin, xmax, ymax',
|
|
86
|
+
ST_ASTEXT: 'geometry',
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
/**
|