@jarenjs/json 0.34.2 → 0.43.1
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/ARCHITECTURE.md +1 -1
- package/README.md +26 -3
- package/dist/types/canonical.d.ts +16 -0
- package/dist/types/query/operators.d.ts +33 -5
- package/docs/QUERY-FORMAT.md +92 -19
- package/package.json +2 -2
- package/src/canonical.js +29 -0
- package/src/query/normalize.js +15 -0
- package/src/query/operators.js +149 -16
package/ARCHITECTURE.md
CHANGED
|
@@ -163,7 +163,7 @@ Grouping, `$distinct` and the `$orderby` machinery need a total, deterministic e
|
|
|
163
163
|
|
|
164
164
|
### The operator registry
|
|
165
165
|
|
|
166
|
-
All
|
|
166
|
+
All 98 §8 operators live in one table in `operators.js` — the query-language analogue of `path.js`'s `FUNCTIONS` table. A test derives the count from the registry and asserts it against QUERY-FORMAT §8 *and* against every committed document that states a number — this file, the README twice, `site.md` and `@jarenjs/linq`'s architecture — so an operator cannot be added without moving all of them in the same change:
|
|
167
167
|
|
|
168
168
|
```javascript
|
|
169
169
|
'$substring': {
|
package/README.md
CHANGED
|
@@ -386,9 +386,9 @@ queryJson({
|
|
|
386
386
|
}, [1, 2, 3, 4, 5]); // [2, 3, 4] — a 3-point moving average
|
|
387
387
|
```
|
|
388
388
|
|
|
389
|
-
The operator library (
|
|
389
|
+
The operator library (98 operators: comparisons, IEEE-double arithmetic, logic, strings with I-Regexp `$match`/`$search`/`$replace`, aggregates, sequence tools like `$distinct`/`$subsequence`/`$range`, type predicates and casts, `$coalesce`, the RFC 3339 date family, and the spatial family) is cataloged in [QUERY-FORMAT.md §8](./docs/QUERY-FORMAT.md#8-operators).
|
|
390
390
|
|
|
391
|
-
**Extending the vocabulary (host opt-in).** The
|
|
391
|
+
**Extending the vocabulary (host opt-in).** The 98 are closed, but a host can add more the way `@jarenjs/validate` gains formats from `@jarenjs/formats`: `createJsltRegistry().use(mathPack).use(financePack)` composes packs of pure `@jarenjs/core` functions into a compiler, so `{ "$sqrt": "$.variance" }` and `{ "$npv": ["$.rate", "$.cashflows[*]"] }` work in a stylesheet, a bare query, and `@jarenjs/linq` — while a document compiled *without* the registry still rejects them. Aggregators fold a `seq` operand to an array before the pure call. See [JSLT-FORMAT.md §13](./docs/JSLT-FORMAT.md#13-registered-operators-host-opt-in-non-normative).
|
|
392
392
|
|
|
393
393
|
**Dates are RFC 3339 strings** ([§8.13](./docs/QUERY-FORMAT.md#813-dates-and-times)): `$is-date`/`$is-time`/`$is-datetime`/`$is-duration` test the lexical forms, `$year`…`$seconds` and `$offset` read components *lexically, in the value's own offset* (so "group by month" means what you expect), `$week`/`$week-year`/`$quarter`/`$weekday` add the derived calendar fields, and `$epoch`/`$datetime` convert to and from epoch milliseconds — the one place a value is shifted to UTC, and therefore the way to compare instants across offsets. There is deliberately no `current-dateTime`: a compiled query is cached by document identity and saved as a rule, so it must answer the same for the same input forever.
|
|
394
394
|
|
|
@@ -417,7 +417,30 @@ queryJson({
|
|
|
417
417
|
}, data); // a spatial filter and a spatial sort, in the language's own clauses
|
|
418
418
|
```
|
|
419
419
|
|
|
420
|
-
|
|
420
|
+
**Geography goes in and out as the text the world already speaks.** `$geo-parse` reads Well-Known Text — what PostGIS, SpatiaLite, GEOS, JTS and every `ST_AsText` emit — and `$geo-text` writes it back, so a WKT column becomes measurable in one expression and a result leaves as something a spatial database reads. `$geohash-bounds` turns a cell string back into the `Polygon` it covers, and `$geo-simplify` reduces a value **for storage and transport**: the same structure and properties with fewer positions, rings still closed, so it can be kept or sent as-is.
|
|
421
|
+
|
|
422
|
+
Getting a CSV of coordinates in needs **no code and no operator** — it is a stylesheet. `parseCsv(text, { headers: true, typed: true })` then one rule:
|
|
423
|
+
|
|
424
|
+
```js
|
|
425
|
+
compileJsltStylesheet([{
|
|
426
|
+
match: '$',
|
|
427
|
+
body: {
|
|
428
|
+
type: 'FeatureCollection',
|
|
429
|
+
features: [{
|
|
430
|
+
$for: { r: '$[*]' },
|
|
431
|
+
$return: {
|
|
432
|
+
type: 'Feature',
|
|
433
|
+
geometry: { type: 'Point', coordinates: ['$r.lon', '$r.lat'] },
|
|
434
|
+
properties: { name: '$r.name', pop: '$r.pop' },
|
|
435
|
+
},
|
|
436
|
+
}],
|
|
437
|
+
},
|
|
438
|
+
}]);
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
The one trap: `coordinates` and `features` take the **array constructor** (the brackets, §3.4), never `$seq` — a two-item *sequence* in member position is `JQ2001`, because a sequence is not an array.
|
|
442
|
+
|
|
443
|
+
A geohash is a string, so **bucketing and tiling** need no operator: `$groupby` over `$substring` groups by cell and an index over the hash is a spatial index. **Proximity is different**, and a prefix test is not it — two points ten metres apart can differ in the *first* character of their cell, so a single prefix misses a neighbour at every cell boundary. `$geohash-neighbours` gives the nine-cell probe a correct proximity query needs; narrow it with `$distance` when an exact radius matters. The whole recipe, end to end, is in [HOWTO](../../docs/HOWTO.md#getting-geographic-data-in-and-out).
|
|
421
444
|
|
|
422
445
|
### External parameters
|
|
423
446
|
|
|
@@ -35,3 +35,19 @@ export declare class JsonCanonicalizeError extends TypeError {
|
|
|
35
35
|
* canonicalizeJson(1e21); // '1e+21'
|
|
36
36
|
*/
|
|
37
37
|
export declare function canonicalizeJson(value: any): string;
|
|
38
|
+
/**
|
|
39
|
+
* The lowercase hex SHA-256 over the RFC 8785 canonical UTF-8 bytes of a
|
|
40
|
+
* JSON value — the content identity two independent processes agree on
|
|
41
|
+
* (a document revision, an idempotency request hash), which is why it is
|
|
42
|
+
* SHA-256 over the canonical text and never a 32-bit fingerprint that
|
|
43
|
+
* collides. Asynchronous because it rides the platform's
|
|
44
|
+
* `globalThis.crypto.subtle` (Node ≥ 20, Bun, browsers, workers); the
|
|
45
|
+
* canonicalization itself is synchronous and its refusals
|
|
46
|
+
* (`JsonCanonicalizeError`) surface as the rejection.
|
|
47
|
+
*
|
|
48
|
+
* @param {any} value - The JSON value to identify
|
|
49
|
+
* @returns {Promise<string>} 64 lowercase hex characters
|
|
50
|
+
* @example
|
|
51
|
+
* await canonicalSha256({ b: 1, a: 2 }) === await canonicalSha256({ a: 2, b: 1 }); // true
|
|
52
|
+
*/
|
|
53
|
+
export declare function canonicalSha256(value: any): Promise<string>;
|
|
@@ -495,22 +495,22 @@ export declare const OPERATORS: Readonly<{
|
|
|
495
495
|
};
|
|
496
496
|
$bbox: {
|
|
497
497
|
params: string;
|
|
498
|
-
result: typeof
|
|
498
|
+
result: typeof RESULT_OPT;
|
|
499
499
|
compile: (gets: any, args: any) => (f: any) => any;
|
|
500
500
|
};
|
|
501
501
|
$area: {
|
|
502
502
|
params: string;
|
|
503
|
-
result: typeof
|
|
503
|
+
result: typeof RESULT_OPT;
|
|
504
504
|
compile: (gets: any, args: any) => (f: any) => any;
|
|
505
505
|
};
|
|
506
506
|
$length: {
|
|
507
507
|
params: string;
|
|
508
|
-
result: typeof
|
|
508
|
+
result: typeof RESULT_OPT;
|
|
509
509
|
compile: (gets: any, args: any) => (f: any) => any;
|
|
510
510
|
};
|
|
511
511
|
$centroid: {
|
|
512
512
|
params: string;
|
|
513
|
-
result: typeof
|
|
513
|
+
result: typeof RESULT_OPT;
|
|
514
514
|
compile: (gets: any, args: any) => (f: any) => any;
|
|
515
515
|
};
|
|
516
516
|
$distance: {
|
|
@@ -542,9 +542,37 @@ export declare const OPERATORS: Readonly<{
|
|
|
542
542
|
kinds: readonly string[];
|
|
543
543
|
min: 1;
|
|
544
544
|
}>;
|
|
545
|
-
result: typeof
|
|
545
|
+
result: typeof RESULT_OPT;
|
|
546
546
|
compile: (gets: any, args: any) => (f: any) => string | typeof EMPTY;
|
|
547
547
|
};
|
|
548
|
+
'$geo-parse': {
|
|
549
|
+
params: string;
|
|
550
|
+
result: typeof RESULT_OPT;
|
|
551
|
+
compile: (gets: any, args: any) => (f: any) => any;
|
|
552
|
+
};
|
|
553
|
+
'$geo-text': {
|
|
554
|
+
params: string;
|
|
555
|
+
result: typeof RESULT_OPT;
|
|
556
|
+
compile: (gets: any, args: any) => (f: any) => any;
|
|
557
|
+
};
|
|
558
|
+
'$geohash-bounds': {
|
|
559
|
+
params: string;
|
|
560
|
+
result: typeof RESULT_OPT;
|
|
561
|
+
compile: (gets: any, args: any) => (f: any) => any;
|
|
562
|
+
};
|
|
563
|
+
'$geohash-neighbours': {
|
|
564
|
+
params: string;
|
|
565
|
+
result: typeof RESULT_MANY;
|
|
566
|
+
compile: (gets: any, args: any) => (f: any) => any;
|
|
567
|
+
};
|
|
568
|
+
'$geo-simplify': {
|
|
569
|
+
params: Readonly<{
|
|
570
|
+
kinds: readonly string[];
|
|
571
|
+
min: 2;
|
|
572
|
+
}>;
|
|
573
|
+
result: typeof resultEmptyPropagates;
|
|
574
|
+
compile: (gets: any, args: any) => (f: any) => any;
|
|
575
|
+
};
|
|
548
576
|
'$date-add': {
|
|
549
577
|
params: Readonly<{
|
|
550
578
|
kinds: readonly string[];
|
package/docs/QUERY-FORMAT.md
CHANGED
|
@@ -211,7 +211,7 @@ evaluation, use `$const` (§3.5.1); to bind one without iteration, use `$let`
|
|
|
211
211
|
|
|
212
212
|
#### 3.5.1 `$const` — quote
|
|
213
213
|
|
|
214
|
-
```
|
|
214
|
+
```jsonc
|
|
215
215
|
{ "$const": v }
|
|
216
216
|
```
|
|
217
217
|
|
|
@@ -227,7 +227,7 @@ constructs `{ "template": { "$for": "kept verbatim", "price": null }, "label": "
|
|
|
227
227
|
|
|
228
228
|
#### 3.5.2 `$map` — general map constructor
|
|
229
229
|
|
|
230
|
-
```
|
|
230
|
+
```jsonc
|
|
231
231
|
{ "$map": [[keyExpr, valueExpr], ...] }
|
|
232
232
|
```
|
|
233
233
|
|
|
@@ -270,7 +270,7 @@ A query document is either:
|
|
|
270
270
|
bare scalar; or
|
|
271
271
|
2. the **version envelope** phrase:
|
|
272
272
|
|
|
273
|
-
```
|
|
273
|
+
```jsonc
|
|
274
274
|
{ "$query": "0.1", "$expr": <expression> }
|
|
275
275
|
```
|
|
276
276
|
|
|
@@ -409,7 +409,7 @@ clause (§6.9), in which case it evaluates to the final accumulator and
|
|
|
409
409
|
|
|
410
410
|
### 6.2 `$for` — iteration bindings
|
|
411
411
|
|
|
412
|
-
```
|
|
412
|
+
```jsonc
|
|
413
413
|
"$for": { name: source, ... }
|
|
414
414
|
```
|
|
415
415
|
|
|
@@ -438,7 +438,7 @@ may reference variables bound earlier in the same `$for` object.
|
|
|
438
438
|
**Extended binding form** — a source written as an object with an `$in`
|
|
439
439
|
member iterates `$in` like a plain source, with options:
|
|
440
440
|
|
|
441
|
-
```
|
|
441
|
+
```jsonc
|
|
442
442
|
{ "$in": expr, "$at": "posName" }
|
|
443
443
|
```
|
|
444
444
|
|
|
@@ -456,7 +456,7 @@ the `$window` family — are §6.10.
|
|
|
456
456
|
|
|
457
457
|
### 6.3 `$let` — sequence bindings
|
|
458
458
|
|
|
459
|
-
```
|
|
459
|
+
```jsonc
|
|
460
460
|
"$let": { name: expr, ... }
|
|
461
461
|
```
|
|
462
462
|
|
|
@@ -474,7 +474,7 @@ ordinary shadowing and is allowed.
|
|
|
474
474
|
|
|
475
475
|
### 6.4 `$where` — tuple filter
|
|
476
476
|
|
|
477
|
-
```
|
|
477
|
+
```jsonc
|
|
478
478
|
"$where": expr
|
|
479
479
|
```
|
|
480
480
|
|
|
@@ -484,7 +484,7 @@ true. Cross-variable predicates (joins) belong here, not in path filters
|
|
|
484
484
|
|
|
485
485
|
### 6.5 `$groupby` — grouping
|
|
486
486
|
|
|
487
|
-
```
|
|
487
|
+
```jsonc
|
|
488
488
|
"$groupby": { name: keyExpr, ... }
|
|
489
489
|
```
|
|
490
490
|
|
|
@@ -522,7 +522,7 @@ that genre's books.
|
|
|
522
522
|
|
|
523
523
|
### 6.6 `$orderby` — ordering
|
|
524
524
|
|
|
525
|
-
```
|
|
525
|
+
```jsonc
|
|
526
526
|
"$orderby": keySpec
|
|
527
527
|
"$orderby": [keySpec, ...]
|
|
528
528
|
```
|
|
@@ -530,7 +530,7 @@ that genre's books.
|
|
|
530
530
|
A *keySpec* is either an expression (shorthand for ascending, empty-least) or
|
|
531
531
|
the explicit form
|
|
532
532
|
|
|
533
|
-
```
|
|
533
|
+
```jsonc
|
|
534
534
|
{ "$key": expr, "$dir": "asc" | "desc", "$empty": "least" | "greatest",
|
|
535
535
|
"$collation": "name" }
|
|
536
536
|
```
|
|
@@ -573,7 +573,7 @@ saved rule declares the collations it needs.
|
|
|
573
573
|
|
|
574
574
|
### 6.7 `$count` — tuple numbering
|
|
575
575
|
|
|
576
|
-
```
|
|
576
|
+
```jsonc
|
|
577
577
|
"$count": "name"
|
|
578
578
|
```
|
|
579
579
|
|
|
@@ -589,7 +589,7 @@ one key.
|
|
|
589
589
|
|
|
590
590
|
### 6.8 `$as` — schema assertions on bindings
|
|
591
591
|
|
|
592
|
-
```
|
|
592
|
+
```jsonc
|
|
593
593
|
"$as": { name: schema, ... }
|
|
594
594
|
```
|
|
595
595
|
|
|
@@ -625,7 +625,7 @@ instead.
|
|
|
625
625
|
|
|
626
626
|
### 6.9 `$fold` — the accumulator clause
|
|
627
627
|
|
|
628
|
-
```
|
|
628
|
+
```jsonc
|
|
629
629
|
"$fold": { name: initExpr }
|
|
630
630
|
```
|
|
631
631
|
|
|
@@ -682,7 +682,7 @@ Two further options of the extended `$for` binding form (§6.2).
|
|
|
682
682
|
would yield no tuple at all, the clause emits exactly **one** tuple with the
|
|
683
683
|
variable bound to the **empty sequence**, so the enclosing tuple survives.
|
|
684
684
|
|
|
685
|
-
```
|
|
685
|
+
```jsonc
|
|
686
686
|
{ "$in": expr, "$allowing-empty": true }
|
|
687
687
|
```
|
|
688
688
|
|
|
@@ -711,7 +711,7 @@ because an empty member value omits the member (§3.4).
|
|
|
711
711
|
**Windows** iterate consecutive *runs* of the item stream instead of single
|
|
712
712
|
items:
|
|
713
713
|
|
|
714
|
-
```
|
|
714
|
+
```jsonc
|
|
715
715
|
{ "$in": expr, "$window": "tumbling" | "sliding", "$size": n, "$step": m, "$at": "w" }
|
|
716
716
|
```
|
|
717
717
|
|
|
@@ -745,7 +745,7 @@ two if the stream does not divide evenly.
|
|
|
745
745
|
|
|
746
746
|
## 7. Quantifier phrases
|
|
747
747
|
|
|
748
|
-
```
|
|
748
|
+
```jsonc
|
|
749
749
|
{ "$some": { name: expr, ... }, "$satisfies": expr }
|
|
750
750
|
{ "$every": { name: expr, ... }, "$satisfies": expr }
|
|
751
751
|
```
|
|
@@ -1339,10 +1339,83 @@ intersection is overlay work and is deliberately absent.
|
|
|
1339
1339
|
| `$within` | `[a, b]` → is `a`'s representative position inside `b`'s surface? Only a polygon has an inside, so a line or point as `b` is `false` |
|
|
1340
1340
|
| `$bbox-intersects` | `[a, b]` → do the two bounding boxes overlap? Touching edges count |
|
|
1341
1341
|
| `$geohash` | `[value]` or `[value, precision]` → the base-32 cell string; precision is 1-12, default 9 |
|
|
1342
|
+
| `$geo-parse` | a Well-Known Text string → the geometry it denotes; text that is not well-formed WKT → empty. A non-string operand is `JQ2001` |
|
|
1343
|
+
| `$geo-text` | any value → its Well-Known Text string; a value with no WKT spelling → empty |
|
|
1344
|
+
| `$geohash-bounds` | a cell string → the `Polygon` covering that cell; a string outside the base-32 alphabet → empty |
|
|
1345
|
+
| `$geohash-neighbours` | a cell string → the cell and its neighbours as a sequence of up to nine strings, in reading order (north-west first, the cell itself in the middle); cells past a pole do not exist and are absent |
|
|
1346
|
+
| `$geo-simplify` | `[value, tolerance]` → the same value with vertices dropped. The tolerance is in **degrees**, not metres |
|
|
1347
|
+
|
|
1348
|
+
**Conversion is how geography gets in and out.** Well-Known Text is what
|
|
1349
|
+
PostGIS, SpatiaLite, GEOS, JTS and every `ST_AsText` emit, so `$geo-parse` and
|
|
1350
|
+
`$geo-text` are the doors: a WKT column becomes a value the rest of §8.14
|
|
1351
|
+
measures, and any value goes back out as the text the database reads.
|
|
1352
|
+
`$geo-parse` answers a **geometry**, never a `Feature` — WKT carries no
|
|
1353
|
+
properties, and inventing an empty `properties` member would be a value the
|
|
1354
|
+
source never had.
|
|
1355
|
+
|
|
1356
|
+
`$geo-simplify` is reduction **for storage and transport**: a 50 MB
|
|
1357
|
+
`FeatureCollection` a caller wants to keep, send or store smaller comes back as
|
|
1358
|
+
valid GeoJSON with the same structure and fewer positions. A renderer's
|
|
1359
|
+
simplification is a different job and is not this one — a chart simplifies into
|
|
1360
|
+
its own drawing space and hands back a picture, not a document. A ring stays
|
|
1361
|
+
closed and a line keeps both endpoints, so a value that was valid before is
|
|
1362
|
+
valid after. The tolerance is a planar vertex-dropping threshold in the
|
|
1363
|
+
coordinate's own units, which for GeoJSON is degrees; calling it metres would
|
|
1364
|
+
be exactly the planar-for-geodesic confusion the rule above exists to prevent.
|
|
1365
|
+
A negative or non-numeric tolerance is `JQ2001`.
|
|
1366
|
+
|
|
1367
|
+
**A non-finite coordinate has no measurement.** `NaN` and `Infinity` are not
|
|
1368
|
+
JSON numbers, but a value that reached the engine through a computation can
|
|
1369
|
+
carry one, and every underlying formula launders it into something plausible —
|
|
1370
|
+
a great-circle distance from `NaN` comes back as the antipodal distance, and a
|
|
1371
|
+
`NaN` area compares false against zero and reports `0`. So a measurement over a
|
|
1372
|
+
value carrying a non-finite coordinate answers **empty** (`$bbox`, `$area`,
|
|
1373
|
+
`$length`, `$centroid`, `$distance`, `$geohash`), and a predicate answers
|
|
1374
|
+
**false** (`$within`, `$bbox-intersects`) — each the same answer it already
|
|
1375
|
+
gives for a missing operand. A value with no positions at all is a different
|
|
1376
|
+
thing and still measures: `$area` of an empty `FeatureCollection` is `0`.
|
|
1377
|
+
|
|
1378
|
+
Because of that rule, **every measurement and every conversion in this section
|
|
1379
|
+
is optional-valued**: `$bbox`, `$area`, `$length`, `$centroid`, `$distance`,
|
|
1380
|
+
`$geohash`, `$geo-parse`, `$geo-text` and `$geohash-bounds` can answer the
|
|
1381
|
+
empty sequence for an operand that is present — a non-finite coordinate, text
|
|
1382
|
+
that is not well-formed WKT, a cell outside the base-32 alphabet. Only the two
|
|
1383
|
+
predicates answer exactly one item, because `false` is their missing-operand
|
|
1384
|
+
value. This matters wherever such an expression sits in a position that expects
|
|
1385
|
+
one item: `[{"$bbox": expr}]` builds an empty array rather than a one-item one,
|
|
1386
|
+
and `{"m": {"$bbox": expr}}` omits the member entirely.
|
|
1387
|
+
|
|
1388
|
+
**Boxes do not cross the antimeridian.** RFC 7946 §3.1.9 tells producers to cut
|
|
1389
|
+
geometries at ±180° rather than let them span it, and this format follows that
|
|
1390
|
+
rather than re-joining what a producer split: `$bbox` of an uncut geometry whose
|
|
1391
|
+
positions sit either side of the line returns a box spanning the globe the
|
|
1392
|
+
*wrong* way — `[-179, 0, 179, 0]` for two points four degrees apart. Cut the
|
|
1393
|
+
geometry at the antimeridian, as the RFC asks, and every box, containment test
|
|
1394
|
+
and index probe is right. There is no flag for this: a box that silently meant
|
|
1395
|
+
"the short way round" for some values and "the long way" for others is worse
|
|
1396
|
+
than one rule.
|
|
1397
|
+
|
|
1398
|
+
Note what needs **no** operator — and one that does. A geohash is a string, so
|
|
1399
|
+
**bucketing and tiling** need nothing new: `$groupby` over `$substring` groups
|
|
1400
|
+
by cell, ordering by the hash orders by locality, and an index over it is a
|
|
1401
|
+
spatial index. **Proximity is different, and `$starts-with` on a prefix is not
|
|
1402
|
+
it.** Two points ten metres apart can differ in the *first* character of their
|
|
1403
|
+
cell, so a single-prefix test misses a neighbour at every cell boundary — which
|
|
1404
|
+
is to say, everywhere a customer actually looks. A correct proximity probe
|
|
1405
|
+
tests the neighbourhood: `$geohash-neighbours` of the query point's cell, then
|
|
1406
|
+
a membership test against those cells, then the exact `$distance` on what
|
|
1407
|
+
survives.
|
|
1408
|
+
|
|
1409
|
+
```json
|
|
1410
|
+
{ "$let": { "cells": { "$geohash-neighbours": { "$geohash": ["$.here", 6] } } },
|
|
1411
|
+
"$return": { "$for": { "c": "$.places[*]" },
|
|
1412
|
+
"$where": { "$exists": { "$index-of": ["$cells", { "$geohash": ["$c.at", 6] }] } },
|
|
1413
|
+
"$return": "$c.name" } }
|
|
1414
|
+
```
|
|
1342
1415
|
|
|
1343
|
-
|
|
1344
|
-
`$
|
|
1345
|
-
|
|
1416
|
+
— the nine-cell probe, in the language's own clauses. Narrow it with
|
|
1417
|
+
`$distance` when an exact radius matters; the cells are the cheap filter, not
|
|
1418
|
+
the answer.
|
|
1346
1419
|
|
|
1347
1420
|
```json
|
|
1348
1421
|
{ "$for": { "c": "$.cities[*]" },
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/json",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.43.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -90,6 +90,6 @@
|
|
|
90
90
|
"prepack": "npm run build:types"
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
|
-
"@jarenjs/core": "^0.
|
|
93
|
+
"@jarenjs/core": "^0.43.1"
|
|
94
94
|
}
|
|
95
95
|
}
|
package/src/canonical.js
CHANGED
|
@@ -167,4 +167,33 @@ export function canonicalizeJson(value) {
|
|
|
167
167
|
return serializeValue(value, '', new Set());
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
const HEX = '0123456789abcdef';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The lowercase hex SHA-256 over the RFC 8785 canonical UTF-8 bytes of a
|
|
174
|
+
* JSON value — the content identity two independent processes agree on
|
|
175
|
+
* (a document revision, an idempotency request hash), which is why it is
|
|
176
|
+
* SHA-256 over the canonical text and never a 32-bit fingerprint that
|
|
177
|
+
* collides. Asynchronous because it rides the platform's
|
|
178
|
+
* `globalThis.crypto.subtle` (Node ≥ 20, Bun, browsers, workers); the
|
|
179
|
+
* canonicalization itself is synchronous and its refusals
|
|
180
|
+
* (`JsonCanonicalizeError`) surface as the rejection.
|
|
181
|
+
*
|
|
182
|
+
* @param {any} value - The JSON value to identify
|
|
183
|
+
* @returns {Promise<string>} 64 lowercase hex characters
|
|
184
|
+
* @example
|
|
185
|
+
* await canonicalSha256({ b: 1, a: 2 }) === await canonicalSha256({ a: 2, b: 1 }); // true
|
|
186
|
+
*/
|
|
187
|
+
export async function canonicalSha256(value) {
|
|
188
|
+
const text = canonicalizeJson(value);
|
|
189
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
190
|
+
const bytes = new Uint8Array(digest);
|
|
191
|
+
let out = '';
|
|
192
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
193
|
+
const b = bytes[i];
|
|
194
|
+
out += HEX[b >> 4] + HEX[b & 15];
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
170
199
|
//#endregion
|
package/src/query/normalize.js
CHANGED
|
@@ -402,6 +402,21 @@ const OPERATOR_ALIASES = {
|
|
|
402
402
|
// conditionals ($coalesce is a real operator; the guesses are here)
|
|
403
403
|
$case: "$if", $cond: "$if", $switch: "$if", $ternary: "$if",
|
|
404
404
|
$ifnull: "$default", $ifempty: "$default", $nvl: "$default",
|
|
405
|
+
// spatial: the PostGIS/Turf spellings, and the American plural
|
|
406
|
+
$wkt: "$geo-parse to read one, $geo-text to write one",
|
|
407
|
+
"$parse-wkt": "$geo-parse", "$from-wkt": "$geo-parse", $st_geomfromtext: "$geo-parse",
|
|
408
|
+
"$to-wkt": "$geo-text", $st_astext: "$geo-text", "$geo-stringify": "$geo-text",
|
|
409
|
+
"$geohash-decode": "$geohash-bounds", "$geohash-bbox": "$geohash-bounds",
|
|
410
|
+
"$geohash-neighbors": "$geohash-neighbours", "$geohash-adjacent": "$geohash-neighbours",
|
|
411
|
+
$simplify: "$geo-simplify", "$douglas-peucker": "$geo-simplify",
|
|
412
|
+
$intersects: "$bbox-intersects (boxes only — real overlay is deliberately absent)",
|
|
413
|
+
$buffer: null, $union: null, $difference: null,
|
|
414
|
+
// the one absence that needs its reason, not a pointer: a projected
|
|
415
|
+
// position is the same [x, y] array as a geographic one, so an
|
|
416
|
+
// operator making one could not stop it reaching $distance
|
|
417
|
+
$project: "the renderer — the language cannot make a projected coordinate at all, so a measurement can never land on one; measurement here is geodesic",
|
|
418
|
+
"$geo-project": "the renderer, as for '$project'",
|
|
419
|
+
$srid: null, $transform: null,
|
|
405
420
|
};
|
|
406
421
|
|
|
407
422
|
// JQ0002 for an unknown $-key, with a "did you mean" suggestion. A
|
package/src/query/operators.js
CHANGED
|
@@ -60,12 +60,18 @@ import {
|
|
|
60
60
|
isPosition,
|
|
61
61
|
bboxOf,
|
|
62
62
|
bboxIntersects,
|
|
63
|
+
bboxPolygon,
|
|
63
64
|
geometryArea,
|
|
64
65
|
geometryLength,
|
|
65
66
|
centroidOf,
|
|
66
67
|
containsPosition,
|
|
67
68
|
geoDistance,
|
|
68
69
|
geohashEncode,
|
|
70
|
+
geohashBounds,
|
|
71
|
+
geohashNeighbours,
|
|
72
|
+
wktToGeoJson,
|
|
73
|
+
geoJsonToWkt,
|
|
74
|
+
simplifyGeometry,
|
|
69
75
|
} from '@jarenjs/core/geo';
|
|
70
76
|
import { JsonQueryCompileError, JsonQueryRuntimeError } from './errors.js';
|
|
71
77
|
import {
|
|
@@ -748,7 +754,17 @@ function dateTruncEntry(truncate) {
|
|
|
748
754
|
// What is deliberately absent is real geometry-to-geometry intersection.
|
|
749
755
|
// `$bbox-intersects` says exactly what it tests, because an operator
|
|
750
756
|
// named `$intersects` that only compared bounding boxes would be a lie
|
|
751
|
-
// the first time two L-shapes shared a box and nothing else.
|
|
757
|
+
// the first time two L-shapes shared a box and nothing else. So is any
|
|
758
|
+
// operator producing a PROJECTED coordinate: a projected position is
|
|
759
|
+
// the same `[x, y]` array as a geographic one, so a language that could
|
|
760
|
+
// make one could not stop it reaching `$distance`. Leaving it out makes
|
|
761
|
+
// "never measure on a projected coordinate" structural instead of
|
|
762
|
+
// advisory; `projectMercator` stays a kernel export for renderers.
|
|
763
|
+
//
|
|
764
|
+
// The conversion members carry geography in and out of the forms the
|
|
765
|
+
// rest of the world uses — Well-Known Text, geohash cells — and reduce
|
|
766
|
+
// a value for storage. Each is a call into the kernel and nothing else:
|
|
767
|
+
// the grammar, the cell arithmetic and Douglas-Peucker each exist once.
|
|
752
768
|
|
|
753
769
|
// A spatial operand, rejected uniformly: the empty sequence propagates
|
|
754
770
|
// at the call site, so this only ever sees a real item.
|
|
@@ -761,11 +777,53 @@ function geoArg(v, docPath) {
|
|
|
761
777
|
return v;
|
|
762
778
|
}
|
|
763
779
|
|
|
764
|
-
//
|
|
765
|
-
|
|
780
|
+
// A cell string operand — the geohash family and `$geo-parse` take text
|
|
781
|
+
// where the rest of the family takes a value.
|
|
782
|
+
function geoTextArg(v, what, docPath) {
|
|
783
|
+
if (typeof v !== 'string') {
|
|
784
|
+
throw runtimeError('JQ2001',
|
|
785
|
+
`expected ${what}, got ${describeItem(v)}`, docPath);
|
|
786
|
+
}
|
|
787
|
+
return v;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// The family's non-finite rule, in one place. A coordinate that is not a
|
|
791
|
+
// finite number has no measurement, and every kernel function that
|
|
792
|
+
// touches one launders it into something plausible: `haversineDistance`
|
|
793
|
+
// answers the antipodal distance, a NaN area compares false against zero
|
|
794
|
+
// and reports 0. A representative-position operator checks the one
|
|
795
|
+
// position it uses; an aggregate measurement has to know that EVERY
|
|
796
|
+
// position is finite, and `centroidOf` is the kernel walk that already
|
|
797
|
+
// answers it — NaN propagates through the mean, and a null mean is "no
|
|
798
|
+
// positions at all", which still measures (as zero) rather than being
|
|
799
|
+
// refused. No second finiteness walk exists.
|
|
800
|
+
function finitePosition(at) {
|
|
801
|
+
return at !== null && Number.isFinite(at[0]) && Number.isFinite(at[1]) ? at : null;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function geoFinite(value) {
|
|
805
|
+
const at = centroidOf(value);
|
|
806
|
+
return at === null || finitePosition(at) !== null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// the representative position §8.14 measures a value by: a bare position
|
|
810
|
+
// is itself, anything else is its centroid
|
|
811
|
+
function representative(value) {
|
|
812
|
+
return finitePosition(isPosition(value) ? value : centroidOf(value));
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// A unary spatial measurement: empty propagates, anything else is
|
|
816
|
+
// JQ2001 — and the answer is OPTIONAL rather than exactly-one, because
|
|
817
|
+
// `null` from the kernel IS the empty sequence and every one of these
|
|
818
|
+
// can answer it for an operand that is present (a non-finite
|
|
819
|
+
// coordinate, text that is not well-formed, a cell outside the
|
|
820
|
+
// alphabet). Declaring exactly-one would let the internal empty marker
|
|
821
|
+
// escape into an array or object constructor, which is neither an item
|
|
822
|
+
// nor a JSON value.
|
|
823
|
+
function geoUnaryEntry(measure, check = geoArg) {
|
|
766
824
|
return {
|
|
767
825
|
params: UNARY,
|
|
768
|
-
result:
|
|
826
|
+
result: RESULT_OPT,
|
|
769
827
|
compile: (gets, args) => {
|
|
770
828
|
const get = gets[0];
|
|
771
829
|
const docPath = args[0].docPath;
|
|
@@ -773,7 +831,7 @@ function geoUnaryEntry(measure, resultCard = resultEmptyPropagates) {
|
|
|
773
831
|
const v = get(f);
|
|
774
832
|
if (v === EMPTY)
|
|
775
833
|
return EMPTY;
|
|
776
|
-
const out = measure(
|
|
834
|
+
const out = measure(check(v, docPath));
|
|
777
835
|
return out === null ? EMPTY : out;
|
|
778
836
|
};
|
|
779
837
|
},
|
|
@@ -1516,9 +1574,9 @@ export const OPERATORS = Object.freeze({
|
|
|
1516
1574
|
//#region section 8.14 - spatial
|
|
1517
1575
|
|
|
1518
1576
|
'$bbox': geoUnaryEntry(bboxOf),
|
|
1519
|
-
'$area': geoUnaryEntry(geometryArea
|
|
1520
|
-
'$length': geoUnaryEntry(geometryLength
|
|
1521
|
-
'$centroid': geoUnaryEntry(centroidOf),
|
|
1577
|
+
'$area': geoUnaryEntry((v) => (geoFinite(v) ? geometryArea(v) : null)),
|
|
1578
|
+
'$length': geoUnaryEntry((v) => (geoFinite(v) ? geometryLength(v) : null)),
|
|
1579
|
+
'$centroid': geoUnaryEntry((v) => finitePosition(centroidOf(v))),
|
|
1522
1580
|
|
|
1523
1581
|
'$distance': { // metres between two values' representative positions
|
|
1524
1582
|
params: ARGS_2,
|
|
@@ -1533,7 +1591,11 @@ export const OPERATORS = Object.freeze({
|
|
|
1533
1591
|
const b = bGet(f);
|
|
1534
1592
|
if (a === EMPTY || b === EMPTY)
|
|
1535
1593
|
return EMPTY;
|
|
1536
|
-
const
|
|
1594
|
+
const pa = representative(geoArg(a, aPath));
|
|
1595
|
+
const pb = representative(geoArg(b, bPath));
|
|
1596
|
+
if (pa === null || pb === null)
|
|
1597
|
+
return EMPTY;
|
|
1598
|
+
const out = geoDistance(pa, pb);
|
|
1537
1599
|
return out === null ? EMPTY : out;
|
|
1538
1600
|
};
|
|
1539
1601
|
},
|
|
@@ -1552,13 +1614,16 @@ export const OPERATORS = Object.freeze({
|
|
|
1552
1614
|
const area = areaGet(f);
|
|
1553
1615
|
if (point === EMPTY || area === EMPTY)
|
|
1554
1616
|
return false; // nothing is inside nothing
|
|
1555
|
-
const p = geoArg(point, pointPath);
|
|
1556
1617
|
// a bare position is itself; anything else is represented by its
|
|
1557
1618
|
// centroid, the same rule $distance uses
|
|
1558
|
-
const at =
|
|
1559
|
-
|
|
1619
|
+
const at = representative(geoArg(point, pointPath));
|
|
1620
|
+
const surface = geoArg(area, areaPath);
|
|
1621
|
+
// a predicate answers its missing-operand value, not empty: the
|
|
1622
|
+
// surface has to be bounded too, or a NaN vertex makes an
|
|
1623
|
+
// even-odd crossing count report containment that is not there
|
|
1624
|
+
if (at === null || !geoFinite(surface))
|
|
1560
1625
|
return false;
|
|
1561
|
-
return containsPosition(
|
|
1626
|
+
return containsPosition(surface, at[0], at[1]);
|
|
1562
1627
|
};
|
|
1563
1628
|
},
|
|
1564
1629
|
},
|
|
@@ -1585,7 +1650,9 @@ export const OPERATORS = Object.freeze({
|
|
|
1585
1650
|
|
|
1586
1651
|
'$geohash': { // a position as a base-32 cell string
|
|
1587
1652
|
params: ARGS_1_2,
|
|
1588
|
-
|
|
1653
|
+
// OPTIONAL for the same reason the unary measurements are: a value
|
|
1654
|
+
// with no bounded position answers the empty sequence
|
|
1655
|
+
result: RESULT_OPT,
|
|
1589
1656
|
compile: (gets, args) => {
|
|
1590
1657
|
const get = gets[0];
|
|
1591
1658
|
const docPath = args[0].docPath;
|
|
@@ -1595,8 +1662,7 @@ export const OPERATORS = Object.freeze({
|
|
|
1595
1662
|
const v = get(f);
|
|
1596
1663
|
if (v === EMPTY)
|
|
1597
1664
|
return EMPTY;
|
|
1598
|
-
const
|
|
1599
|
-
const at = isPosition(value) ? value : centroidOf(value);
|
|
1665
|
+
const at = representative(geoArg(v, docPath));
|
|
1600
1666
|
if (at === null)
|
|
1601
1667
|
return EMPTY;
|
|
1602
1668
|
let precision = 9;
|
|
@@ -1613,6 +1679,73 @@ export const OPERATORS = Object.freeze({
|
|
|
1613
1679
|
},
|
|
1614
1680
|
},
|
|
1615
1681
|
|
|
1682
|
+
// -- conversion: geography in and out of the forms the world uses --
|
|
1683
|
+
|
|
1684
|
+
// Well-Known Text is what PostGIS, SpatiaLite, GEOS, JTS and every
|
|
1685
|
+
// `ST_AsText` emit, so without these a document can only carry such a
|
|
1686
|
+
// string through untouched. Text that is not well-formed WKT is empty
|
|
1687
|
+
// rather than an error, like every other "nothing to answer" here.
|
|
1688
|
+
'$geo-parse': geoUnaryEntry(wktToGeoJson,
|
|
1689
|
+
(v, docPath) => geoTextArg(v, 'a Well-Known Text string', docPath)),
|
|
1690
|
+
|
|
1691
|
+
// A value with no WKT spelling — a non-finite coordinate — is empty,
|
|
1692
|
+
// never written approximately.
|
|
1693
|
+
'$geo-text': geoUnaryEntry(geoJsonToWkt),
|
|
1694
|
+
|
|
1695
|
+
'$geohash-bounds': geoUnaryEntry(
|
|
1696
|
+
(hash) => bboxPolygon(geohashBounds(hash)),
|
|
1697
|
+
(v, docPath) => geoTextArg(v, 'a geohash cell string', docPath)),
|
|
1698
|
+
|
|
1699
|
+
// The neighbourhood, not the cell: two points metres apart can sit in
|
|
1700
|
+
// different cells, so a proximity probe tests the nine cells and a
|
|
1701
|
+
// single prefix is bucketing. The cells come in reading order — north
|
|
1702
|
+
// -west first, the cell itself in the middle; cells past a pole do
|
|
1703
|
+
// not exist and are absent, so the sequence can be shorter.
|
|
1704
|
+
'$geohash-neighbours': {
|
|
1705
|
+
params: UNARY,
|
|
1706
|
+
result: RESULT_MANY,
|
|
1707
|
+
compile: (gets, args) => {
|
|
1708
|
+
const get = gets[0];
|
|
1709
|
+
const docPath = args[0].docPath;
|
|
1710
|
+
return (f) => {
|
|
1711
|
+
const v = get(f);
|
|
1712
|
+
if (v === EMPTY)
|
|
1713
|
+
return EMPTY;
|
|
1714
|
+
return seqOf(geohashNeighbours(geoTextArg(v, 'a geohash cell string', docPath)));
|
|
1715
|
+
};
|
|
1716
|
+
},
|
|
1717
|
+
},
|
|
1718
|
+
|
|
1719
|
+
// Reduction for STORAGE and TRANSPORT: the same value with vertices
|
|
1720
|
+
// dropped, still valid GeoJSON a caller can keep or send. The chart
|
|
1721
|
+
// layer simplifies too, but only into its own drawing space, so
|
|
1722
|
+
// nothing there hands a document back.
|
|
1723
|
+
'$geo-simplify': {
|
|
1724
|
+
params: ARGS_2,
|
|
1725
|
+
result: resultEmptyPropagates,
|
|
1726
|
+
compile: (gets, args) => {
|
|
1727
|
+
const valueGet = gets[0];
|
|
1728
|
+
const valuePath = args[0].docPath;
|
|
1729
|
+
const toleranceGet = gets[1];
|
|
1730
|
+
const tolerancePath = args[1].docPath;
|
|
1731
|
+
return (f) => {
|
|
1732
|
+
const v = valueGet(f);
|
|
1733
|
+
const tolerance = toleranceGet(f);
|
|
1734
|
+
if (v === EMPTY || tolerance === EMPTY)
|
|
1735
|
+
return EMPTY;
|
|
1736
|
+
// degrees, not metres: a planar vertex-dropping threshold. A
|
|
1737
|
+
// degree of longitude is not a fixed distance, so naming it a
|
|
1738
|
+
// distance is the confusion this family exists to prevent.
|
|
1739
|
+
if (typeof tolerance !== 'number' || !Number.isFinite(tolerance) || tolerance < 0) {
|
|
1740
|
+
throw runtimeError('JQ2001',
|
|
1741
|
+
`a simplification tolerance is a non-negative number of degrees, got ${describeItem(tolerance)}`,
|
|
1742
|
+
tolerancePath);
|
|
1743
|
+
}
|
|
1744
|
+
return simplifyGeometry(geoArg(v, valuePath), tolerance);
|
|
1745
|
+
};
|
|
1746
|
+
},
|
|
1747
|
+
},
|
|
1748
|
+
|
|
1616
1749
|
//#endregion
|
|
1617
1750
|
|
|
1618
1751
|
//#region section 8.13 - dates and times, continued
|