Skip to content

Commit 50e7828

Browse files
committed
Add minimal JSON path
1 parent dae6e3e commit 50e7828

File tree

6 files changed

+274
-4
lines changed

6 files changed

+274
-4
lines changed

json-parse-stream.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1+
// deno-lint-ignore-file no-explicit-any
12
import { JSONParser } from 'https://ghuc.cc/qwtel/jsonparse/index.js';
3+
import { normalize, match } from './json-path.ts'
24

3-
export class JSONParseStream<T = any> extends TransformStream<string|BufferSource, T> {
4-
constructor(/* TODO */) {
5+
export class JSONParseStream<T = any> extends TransformStream<string | BufferSource, T> {
6+
constructor(jsonPath = '$.*') {
57
let parser!: JSONParser;
8+
const matchPath = normalize(jsonPath)
69
super({
710
start: (controller) => {
811
parser = new JSONParser();
912
parser.onValue = (value: T) => {
10-
controller.enqueue(value);
13+
const path = [...parser.stack.map(_ => _.key), parser.key]; // TODO: modify parser to provide key efficiently
14+
path[0] ||= '$';
15+
16+
const nPath = normalize(path.join('.')); // FIXME: avoid string concatenation/joining
17+
if (match(matchPath, nPath)) {
18+
controller.enqueue(value);
19+
}
1120
};
1221
},
1322
transform: (chunk) => {

json-path.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// deno-lint-ignore-file no-explicit-any
2+
3+
// Modernized version of Stefan Goessner's original JSON Path implementation.
4+
// Copyright (c) 2007 Stefan Goessner (goessner.net)
5+
// Licensed under the MIT (MIT-LICENSE.txt) licence.
6+
7+
export function* trace<T = any>(expr: string, val: unknown, path: string): IterableIterator<[string, T]> {
8+
if (expr) {
9+
const [loc, ...rest] = expr.split(";");
10+
const x = rest.join(";");
11+
12+
if (val !== null && typeof val === 'object' && loc in val) {
13+
yield* trace(x, (<any>val)[loc], path + ";" + loc);
14+
}
15+
else if (loc === "*") {
16+
for (const [m, _l, v, p] of walk(loc, val, path)) {
17+
yield* trace(m + ";" + x, v, p)
18+
}
19+
}
20+
else if (loc === "..") {
21+
yield* trace(x, val, path);
22+
for (const [m, _l, v, p] of walk(loc, val, path)) {
23+
if (typeof (<any>v)[m] === "object")
24+
yield* trace("..;" + x, (<any>v)[m], p + ";" + m);
25+
}
26+
}
27+
else if (/,/.test(loc)) { // [name1,name2,...]
28+
for (let s = loc.split(/'?,'?/), i = 0, n = s.length; i < n; i++)
29+
yield* trace(s[i] + ";" + x, val, path);
30+
}
31+
else if (/^(-?[0-9]*):(-?[0-9]*):?([0-9]*)$/.test(loc)) { // [start:end:step] slice syntax
32+
yield* slice(loc, x, val, path);
33+
}
34+
// eval is bad, not doing this anymore
35+
// else if (/^\(.*?\)$/.test(loc)) { // [(expr)]
36+
// trace(eval(loc, val, path.substr(path.lastIndexOf(";") + 1)) + ";" + x, val, path);
37+
// } else if (/^\?\(.*?\)$/.test(loc)) { // [?(expr)]
38+
// walk(loc, x, val, path, (m, l, x, v, p) => { if (eval(l.replace(/^\?\((.*?)\)$/, "$1"), v[m], m)) trace(m + ";" + x, v, p); });
39+
// }
40+
}
41+
else yield [path, val as T]
42+
}
43+
44+
function* slice<T>(loc: string, expr: string, val: unknown, path: string): IterableIterator<[string, T]> {
45+
if (val instanceof Array) {
46+
const len = val.length;
47+
let start = 0, end = len, step = 1;
48+
loc.replace(/^(-?[0-9]*):(-?[0-9]*):?(-?[0-9]*)$/g, (_$0, $1, $2, $3) => {
49+
start = parseInt($1 || start);
50+
end = parseInt($2 || end);
51+
step = parseInt($3 || step);
52+
return ''
53+
});
54+
start = (start < 0) ? Math.max(0, start + len) : Math.min(len, start);
55+
end = (end < 0) ? Math.max(0, end + len) : Math.min(len, end);
56+
for (let i = start; i < end; i += step)
57+
yield* trace(i + ";" + expr, val, path);
58+
}
59+
}
60+
61+
function* walk(loc: string, val: unknown, path: string) {
62+
if (val instanceof Array) {
63+
for (let i = 0, n = val.length; i < n; i++)
64+
if (i in val)
65+
yield [i, loc, val, path] as const
66+
}
67+
else if (typeof val === "object") {
68+
for (const m in val)
69+
if (val.hasOwnProperty(m))
70+
yield [m, loc, val, path] as const
71+
}
72+
}
73+
74+
export function normalize(expr: string) {
75+
const subX: string[] = [];
76+
if (!expr.startsWith('$')) expr = '$' + expr
77+
return expr
78+
.replace(/[\['](\??\(.*?\))[\]']/g, (_$0, $1) => { return "[#" + (subX.push($1) - 1) + "]"; })
79+
.replace(/'?\.'?|\['?/g, ";")
80+
.replace(/;;;|;;/g, ";..;")
81+
.replace(/;$|'?\]|'$/g, "")
82+
.replace(/#([0-9]+)/g, (_$0, $1) => { return subX[$1]; });
83+
}
84+
85+
// FIXME: avoid repeated split/join/regex.test
86+
export function match(expr: string, path: string): boolean {
87+
if (expr && path) {
88+
const [loc, ...restLoc] = expr.split(";");
89+
const [val, ...restVal] = path.split(";");
90+
const exprRest = restLoc.join(";");
91+
const pathRest = restVal.join(';')
92+
93+
if (loc === val) {
94+
return match(exprRest, pathRest)
95+
}
96+
else if (loc === "*") {
97+
return match(exprRest, pathRest)
98+
}
99+
else if (loc === "..") {
100+
return match(exprRest, path) || match("..;" + exprRest, pathRest);
101+
}
102+
else if (/,/.test(loc)) { // [name1,name2,...]
103+
if (loc.split(/'?,'?/).some(v => v === val)) return match(exprRest, pathRest)
104+
else return false
105+
}
106+
else if (/^(-?[0-9]*):(-?[0-9]*):?([0-9]*)$/.test(loc)) { // [start:end:step] slice syntax
107+
let start = 0, end = Number.MAX_SAFE_INTEGER, step = 1;
108+
loc.replace(/^(-?[0-9]*):(-?[0-9]*):?(-?[0-9]*)$/g, (_$0, $1, $2, $3) => {
109+
start = parseInt($1 || start);
110+
end = parseInt($2 || end);
111+
step = parseInt($3 || step);
112+
return ''
113+
});
114+
const idx = Number(val)
115+
if (start < 0 || end < 0 || step < 0)
116+
throw TypeError('Negative numbers not supported. Can\'t know length ahead of time when stream parsing');
117+
if (idx >= start && idx < end && start + idx % step === 0) return match(exprRest, pathRest)
118+
else return false
119+
}
120+
}
121+
else if (!expr && !path) return true
122+
return false;
123+
}

json-stringify.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ type Primitive = undefined | boolean | number | string | bigint | symbol;
77

88
export type ToJSON = { toJSON: (key?: any) => string }
99

10-
const isIterable = <T>(x: unknown): x is Iterable<T> =>
10+
const _isIterable = <T>(x: unknown): x is Iterable<T> =>
1111
x != null && typeof x === 'object' && Symbol.iterator in x
1212

1313
const isAsyncIterable = <T>(x: unknown): x is AsyncIterable<T> =>

test/json-parse-stream.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// deno-lint-ignore-file no-explicit-any no-unused-vars require-await
2+
import 'https://gist.githubusercontent.com/qwtel/b14f0f81e3a96189f7771f83ee113f64/raw/TestRequest.ts'
3+
import {
4+
assert,
5+
assertExists,
6+
assertEquals,
7+
assertStrictEquals,
8+
assertStringIncludes,
9+
assertThrows,
10+
assertRejects,
11+
assertArrayIncludes,
12+
} from 'https://deno.land/std@0.133.0/testing/asserts.ts'
13+
const { test } = Deno;
14+
15+
import { JSONParseStream } from '../json-parse-stream.ts'
16+
17+
async function consume(stream: ReadableStream) {
18+
const reader = stream.getReader();
19+
while (!(await reader.read()).done) { /* NOOP */ }
20+
}
21+
22+
async function collect<T = any>(stream: ReadableStream) {
23+
const chunks = []
24+
const reader = stream.getReader();
25+
let result: ReadableStreamReadResult<T>
26+
while (!(result = await reader.read()).done) chunks.push(result.value)
27+
return chunks;
28+
}
29+
30+
test('exists', () =>{
31+
assertExists(JSONParseStream)
32+
})
33+
34+
test('ctor', () => {
35+
const x = new JSONParseStream()
36+
assertExists(x)
37+
assertExists(x.readable)
38+
assertExists(x.writable)
39+
})
40+
41+
test('simple', async () => {
42+
const res = await collect(new Response(JSON.stringify([{ a: 1 }, { b: 2 }, { c: 3 }])).body!
43+
.pipeThrough(new JSONParseStream()))
44+
assertEquals(res, [{ a: 1 }, { b: 2 }, { c: 3 }])
45+
})
46+
47+
test('simple reader read', async () => {
48+
const reader = new Response(JSON.stringify([{ a: 1 }, { b: 2 }, { c: 3 }])).body!
49+
.pipeThrough(new JSONParseStream())
50+
.getReader()
51+
assertEquals((await reader.read()).value, { a: 1 })
52+
assertEquals((await reader.read()).value, { b: 2 })
53+
assertEquals((await reader.read()).value, { c: 3 })
54+
assertEquals((await reader.read()).done, true)
55+
})
56+
57+
test('read all', async () => {
58+
const stream = new Response(JSON.stringify([{ a: 1 }, { b: 2 }, { c: 3 }])).body!
59+
.pipeThrough(new JSONParseStream('$..*'))
60+
const reader = stream.getReader()
61+
assertEquals((await reader.read()).value, 1)
62+
assertEquals((await reader.read()).value, { a: 1 })
63+
assertEquals((await reader.read()).value, 2)
64+
assertEquals((await reader.read()).value, { b: 2 })
65+
assertEquals((await reader.read()).value, 3)
66+
assertEquals((await reader.read()).value, { c: 3 })
67+
assertEquals((await reader.read()).done, true)
68+
})

test/json-path.match.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// deno-lint-ignore-file no-unused-vars
2+
import 'https://gist.githubusercontent.com/qwtel/b14f0f81e3a96189f7771f83ee113f64/raw/TestRequest.ts'
3+
import {
4+
assert,
5+
assertExists,
6+
assertEquals,
7+
assertStrictEquals,
8+
assertStringIncludes,
9+
assertThrows,
10+
assertRejects,
11+
assertArrayIncludes,
12+
} from 'https://deno.land/std@0.133.0/testing/asserts.ts'
13+
const { test } = Deno;
14+
15+
import { normalize, match as _match } from '../json-path.ts'
16+
17+
function match(...args: Parameters<typeof _match>): boolean {
18+
return _match(normalize(args[0]), normalize(args[1]))
19+
}
20+
21+
// console.log([...trace(normalize('$..*').replace(/^\$;/, ""), { a: 3, b: 4, c: 5, d: [1, 2, 3], e: { f: { g: { h: 5 } } } }, '$')])
22+
// consume(new Response(JSON.stringify({ a: 3, b: 4, c: 5, d: [1, 2, 3], e: { f: { g: { h: 5 } } } })).body!.pipeThrough(new JSONParseStream('$..*')))
23+
24+
test('exists', () =>{
25+
assertExists(_match)
26+
})
27+
28+
test('*', () =>{
29+
assert(match('.*', '.a'))
30+
assert(match('.*', '.b'))
31+
assert(!match('.*', '.a.b'))
32+
})
33+
34+
test('..', () =>{
35+
assert(match('..*', '.store.price'));
36+
assert(match('..*', '.store.a.price'));
37+
assert(match('..*', '.store.a.b.price'));
38+
assert(match('..*', '.store.a.price.b'));
39+
assert(match('..*', '.store.foo'));
40+
assert(match('..*', '.store'));
41+
})
42+
43+
44+
test('.. with follow-up', () =>{
45+
assert(match('.store..price', '.store.price'));
46+
assert(match('.store..price', '.store.a.price'));
47+
assert(match('.store..price', '.store.a.b.price'));
48+
assert(!match('.store..price', '.store.a.price.b'));
49+
assert(!match('.store..price', '.store.foo'));
50+
assert(!match('.store..price', '.store'));
51+
})
52+
53+
test('selection', () => {
54+
assert(match('$..foo[a,b]', '$.x.foo.a'));
55+
assert(match('$..foo[a,b]', '$.x.foo.b'));
56+
assert(!match('$..foo[a,b]', '$.x.foo.c'));
57+
})
58+
59+
test('selection num', () => {
60+
assert(match('$..book[0,1]', '$.book[0]'));
61+
assert(match('$..book[0,1]', '$.book[0]'));
62+
assert(!match('$..book[0,1]', '$.book[2]'));
63+
})
64+
65+
test('range', () => {
66+
assert(match('$..book[0:2]', '$.book[0]'));
67+
assert(match('$..book[0:2]', '$.book[0]'));
68+
assert(!match('$..book[0:2]', '$.book[2]'));
69+
})

test/json-stringify.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// deno-lint-ignore-file no-unused-vars no-explicit-any
12
import 'https://gist.githubusercontent.com/qwtel/b14f0f81e3a96189f7771f83ee113f64/raw/TestRequest.ts'
23
import {
34
assert,

0 commit comments

Comments
 (0)