Skip to content

Commit 5745c09

Browse files
committed
Fix serialization of keys containing single quote or backslash
1 parent 65525c3 commit 5745c09

File tree

5 files changed

+125
-20
lines changed

5 files changed

+125
-20
lines changed

src/path/segments.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@ import { isArray, isObject, isString } from "../types";
22
import { JSONPathEnvironment } from "./environment";
33
import { JSONPathRecursionLimitError } from "./errors";
44
import { JSONPathNode } from "./node";
5-
import { JSONPathSelector } from "./selectors";
5+
import { JSONPathSelector, NameSelector } from "./selectors";
66
import { Token } from "./token";
77

8+
/**
9+
* An identifier that is allowed in both JS and JSONPath.
10+
* JSONPath identifiers are generally much more permissive than JS ones, but
11+
* they don't allow the character "$", so we take the intersection of the two
12+
* when deciding whether to use dot shorthand for canonical serialization of
13+
* simple names.
14+
*/
15+
const SHORTHAND_COMPATIBLE_IDENTIFIER = /^[\p{ID_Start}_]\p{ID_Continue}*$/u;
16+
817
/** Base class for all JSONPath segments. Both shorthand and bracketed. */
918
export abstract class JSONPathSegment {
1019
constructor(
@@ -52,7 +61,20 @@ export class ChildSegment extends JSONPathSegment {
5261
}
5362

5463
public toString(): string {
55-
return `[${this.selectors.map((s) => s.toString()).join(", ")}]`;
64+
const { selectors } = this;
65+
const [selector] = selectors;
66+
67+
if (selectors.length === 1 && selector instanceof NameSelector) {
68+
const inner = selector.toString().slice(1, -1);
69+
70+
if (SHORTHAND_COMPATIBLE_IDENTIFIER.test(inner)) {
71+
return `.${inner}`;
72+
}
73+
74+
return `[${selector}]`;
75+
}
76+
77+
return `[${selectors.map((s) => s.toString()).join(", ")}]`;
5678
}
5779
}
5880

src/path/selectors.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,15 @@ export class NameSelector extends JSONPathSelector {
7171
}
7272

7373
public toString(): string {
74-
return `'${this.name}'`;
74+
const jsonified = JSON.stringify(this.name);
75+
76+
const inner = jsonified.slice(1, -1);
77+
78+
if (inner.includes("'") && !inner.includes('"')) {
79+
return jsonified;
80+
}
81+
82+
return `'${inner.replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
7583
}
7684
}
7785

tests/path/escaping.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { compile } from "../../src/path";
2+
3+
const cases = [
4+
{ key: "a", serialized: "$.a" },
5+
6+
{ key: 1, serialized: "$[1]" },
7+
{ key: "1", serialized: `$['1']` },
8+
{ key: "a1", serialized: "$.a1" },
9+
{ key: "1a", serialized: `$['1a']` },
10+
{ key: "a1a", serialized: "$.a1a" },
11+
{ key: "1a1", serialized: `$['1a1']` },
12+
13+
{ key: "$", serialized: `$['$']` },
14+
{ key: "$a", serialized: `$['$a']` },
15+
{ key: "a$", serialized: `$['a$']` },
16+
{ key: "a$a", serialized: `$['a$a']` },
17+
{ key: "$a$", serialized: `$['$a$']` },
18+
19+
{ key: "_", serialized: "$._" },
20+
{ key: "_a", serialized: "$._a" },
21+
{ key: "a_", serialized: "$.a_" },
22+
{ key: "a_a", serialized: "$.a_a" },
23+
{ key: "_a_", serialized: "$._a_" },
24+
25+
{ key: " ", serialized: `$[' ']` },
26+
{ key: " a", serialized: `$[' a']` },
27+
{ key: "a ", serialized: `$['a ']` },
28+
{ key: "a a", serialized: `$['a a']` },
29+
{ key: " a ", serialized: `$[' a ']` },
30+
31+
{ key: "\\", serialized: String.raw`$['\\']` },
32+
{ key: '"', serialized: String.raw`$['"']` },
33+
{ key: `'`, serialized: `$["'"]` },
34+
{ key: '\\"', serialized: String.raw`$['\\"']` },
35+
{ key: `\\'`, serialized: String.raw`$["\\'"]` },
36+
37+
{ key: `'"`, serialized: String.raw`$['\'"']` },
38+
{ key: `"'`, serialized: String.raw`$['"\'']` },
39+
40+
{ key: "*", serialized: `$['*']` },
41+
{ key: "@", serialized: `$['@']` },
42+
{ key: "[]", serialized: `$['[]']` },
43+
{ key: "💩", serialized: `$['💩']` },
44+
45+
{ key: "文字", serialized: `$.文字` },
46+
47+
{ key: "\u200c", serialized: `$['\u200c']` },
48+
{ key: "\u200ca", serialized: `$['\u200ca']` },
49+
{ key: "a\u200c", serialized: "$.a\u200c" },
50+
{ key: "a\u200ca", serialized: "$.a\u200ca" },
51+
{ key: "\u200ca\u200c", serialized: `$['\u200ca\u200c']` },
52+
53+
{ key: "null", serialized: "$.null" },
54+
{ key: "Infinity", serialized: "$.Infinity" },
55+
{ key: "__proto__", serialized: "$.__proto__" },
56+
];
57+
58+
const obj = Object.fromEntries(cases.map(({ key }) => [key, key]));
59+
const arr = Array.from({ length: 100 }, (_, i) => i);
60+
61+
describe("escaping of keys", () => {
62+
test.each(cases)("$key -> $serialized", ({ key, serialized }) => {
63+
const input = `$${JSON.stringify([key])}`;
64+
const jpq = compile(input);
65+
const path = jpq.toString();
66+
const roundTripped = compile(path);
67+
68+
expect(path).toBe(serialized);
69+
expect(roundTripped.toString()).toBe(path);
70+
71+
const target = typeof key === "number" ? arr : obj;
72+
expect(jpq.query(target).values()[0]).toBe(key);
73+
expect(roundTripped.query(target).values()[0]).toBe(key);
74+
});
75+
});

tests/path/normalized_path.test.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@ const testCases: Case[] = [
1212
{ description: "empty", path: "", want: "", error: true },
1313
{ description: "just root", path: "$", want: "$" },
1414
{ description: "root dot", path: "$.", want: "", error: true },
15-
{ description: "shorthand name", path: "$.foo.bar", want: "$['foo']['bar']" },
15+
{ description: "shorthand name", path: "$.foo.bar", want: "$.foo.bar" },
1616
{
1717
description: "bracketed name, single quotes",
1818
path: "$['foo']['bar']",
19-
want: "$['foo']['bar']",
19+
want: "$.foo.bar",
2020
},
2121
{
2222
description: "bracketed name, double quotes",
2323
path: "$['foo']['bar']",
24-
want: "$['foo']['bar']",
24+
want: "$.foo.bar",
2525
},
2626
{
2727
description: "dot bracketed",
@@ -62,17 +62,17 @@ const testCases: Case[] = [
6262
{
6363
description: "wild shorthand",
6464
path: "$.foo.*",
65-
want: "$['foo'][*]",
65+
want: "$.foo[*]",
6666
},
6767
{
6868
description: "wild",
6969
path: "$.foo[*]",
70-
want: "$['foo'][*]",
70+
want: "$.foo[*]",
7171
},
7272
{
7373
description: "descend",
7474
path: "$.foo..[0]",
75-
want: "$['foo']..[0]",
75+
want: "$.foo..[0]",
7676
},
7777
{
7878
description: "bald descend",
@@ -83,37 +83,37 @@ const testCases: Case[] = [
8383
{
8484
description: "multiple selectors",
8585
path: "$.foo[1, 2:5, *, 'bar']",
86-
want: "$['foo'][1, 2:5:1, *, 'bar']",
86+
want: "$.foo[1, 2:5:1, *, 'bar']",
8787
},
8888
{
8989
description: "filter, relative query",
9090
path: "$[?@.foo]",
91-
want: "$[?@['foo']]",
91+
want: "$[?@.foo]",
9292
},
9393
{
9494
description: "filter, parenthesized",
9595
path: "$[?(@.foo)]",
96-
want: "$[?@['foo']]",
96+
want: "$[?@.foo]",
9797
},
9898
{
9999
description: "filter, logical and",
100100
path: "$[?@.foo && @.bar]",
101-
want: "$[?(@['foo'] && @['bar'])]",
101+
want: "$[?(@.foo && @.bar)]",
102102
},
103103
{
104104
description: "filter, logical or",
105105
path: "$[?@.foo || @.bar]",
106-
want: "$[?(@['foo'] || @['bar'])]",
106+
want: "$[?(@.foo || @.bar)]",
107107
},
108108
{
109109
description: "filter, logical not",
110110
path: "$[?!@.foo]",
111-
want: "$[?!@['foo']]",
111+
want: "$[?!@.foo]",
112112
},
113113
{
114114
description: "filter, comparison",
115115
path: "$[?@.foo == @.bar]",
116-
want: "$[?@['foo'] == @['bar']]",
116+
want: "$[?@.foo == @.bar]",
117117
},
118118
];
119119

tests/path/parse.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,22 @@ const TEST_CASES: TestCase[] = [
1515
{
1616
description: "not binds more tightly than and",
1717
path: "$[?!@.a && !@.b]",
18-
want: "$[?(!@['a'] && !@['b'])]",
18+
want: "$[?(!@.a && !@.b)]",
1919
},
2020
{
2121
description: "not binds more tightly than or",
2222
path: "$[?!@.a || !@.b]",
23-
want: "$[?(!@['a'] || !@['b'])]",
23+
want: "$[?(!@.a || !@.b)]",
2424
},
2525
{
2626
description: "control precedence with parens",
2727
path: "$[?!(@.a && !@.b)]",
28-
want: "$[?!(@['a'] && !@['b'])]",
28+
want: "$[?!(@.a && !@.b)]",
2929
},
3030
{
3131
description: "non-singular query in logical expression",
3232
path: "$[?@.* && @.b]",
33-
want: "$[?(@[*] && @['b'])]",
33+
want: "$[?(@[*] && @.b)]",
3434
},
3535
];
3636

0 commit comments

Comments
 (0)