Skip to content

Commit 15c81a7

Browse files
committed
Support pretty and canonical paths for JSONPathQuery and JSONPathNode
1 parent a235b22 commit 15c81a7

File tree

14 files changed

+412
-104
lines changed

14 files changed

+412
-104
lines changed

src/path/expression.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { FunctionExpressionType } from "./functions/function";
44
import { JSONPathNodeList } from "./node";
55
import { JSONPathQuery } from "./path";
66
import { Token } from "./token";
7-
import { FilterContext, Nothing } from "./types";
7+
import { FilterContext, Nothing, SerializationOptions } from "./types";
88
import { isNumber, isString } from "../types";
99

1010
/**
@@ -22,7 +22,7 @@ export abstract class FilterExpression {
2222
/**
2323
* Return a string representation of the expression.
2424
*/
25-
public abstract toString(): string;
25+
public abstract toString(options?: SerializationOptions): string;
2626
}
2727

2828
/**
@@ -112,8 +112,8 @@ export class PrefixExpression extends FilterExpression {
112112
);
113113
}
114114

115-
public toString(): string {
116-
return `${this.operator}${this.right.toString()}`;
115+
public toString(options?: SerializationOptions): string {
116+
return `${this.operator}${this.right.toString(options)}`;
117117
}
118118
}
119119

@@ -158,13 +158,13 @@ export class InfixExpression extends FilterExpression {
158158
return compare(left, this.operator, right);
159159
}
160160

161-
public toString(): string {
161+
public toString(options?: SerializationOptions): string {
162162
if (this.logical) {
163-
return `(${this.left.toString()} ${
163+
return `(${this.left.toString(options)} ${
164164
this.operator
165-
} ${this.right.toString()})`;
165+
} ${this.right.toString(options)})`;
166166
}
167-
return `${this.left.toString()} ${this.operator} ${this.right.toString()}`;
167+
return `${this.left.toString(options)} ${this.operator} ${this.right.toString(options)}`;
168168
}
169169
}
170170

@@ -182,8 +182,8 @@ export class LogicalExpression extends FilterExpression {
182182
return isTruthy(value);
183183
}
184184

185-
public toString(): string {
186-
return this.expression.toString();
185+
public toString(options?: SerializationOptions): string {
186+
return this.expression.toString(options);
187187
}
188188
}
189189

@@ -208,8 +208,8 @@ export class RelativeQuery extends FilterQuery {
208208
: this.path.query(context.currentValue);
209209
}
210210

211-
public toString(): string {
212-
return `@${this.path.toString().slice(1)}`;
211+
public toString(options?: SerializationOptions): string {
212+
return `@${this.path.toString(options).slice(1)}`;
213213
}
214214
}
215215

@@ -220,8 +220,8 @@ export class RootQuery extends FilterQuery {
220220
: this.path.query(context.rootValue);
221221
}
222222

223-
public toString(): string {
224-
return this.path.toString();
223+
public toString(options?: SerializationOptions): string {
224+
return this.path.toString(options);
225225
}
226226
}
227227

@@ -254,8 +254,8 @@ export class FunctionExtension extends FilterExpression {
254254
return func.call(...args);
255255
}
256256

257-
public toString(): string {
258-
return `${this.name}(${this.args.map((e) => e.toString()).join(", ")})`;
257+
public toString(options?: SerializationOptions): string {
258+
return `${this.name}(${this.args.map((e) => e.toString(options)).join(", ")})`;
259259
}
260260

261261
private unpack_node_list(arg: JSONPathNodeList): unknown {

src/path/extra/selectors.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ import { JSONPathEnvironment } from "../environment";
33
import { LogicalExpression } from "../expression";
44
import { JSONPathNode } from "../node";
55
import { JSONPathSelector } from "../selectors";
6+
import { toCanonical, toQuoted } from "../serialize";
67
import { Token } from "../token";
7-
import { FilterContext, KEY_MARK, hasStringKey } from "../types";
8+
import {
9+
type FilterContext,
10+
type SerializationOptions,
11+
KEY_MARK,
12+
defaultSerializationOptions,
13+
hasStringKey,
14+
} from "../types";
815

916
export class KeySelector extends JSONPathSelector {
1017
constructor(
@@ -44,8 +51,10 @@ export class KeySelector extends JSONPathSelector {
4451
}
4552
}
4653

47-
public toString(): string {
48-
return `~'${this.key.replaceAll("'", "\\'")}'`;
54+
public toString(options?: SerializationOptions): string {
55+
const { form } = { ...defaultSerializationOptions, ...options };
56+
const serialize = form === "canonical" ? toCanonical : toQuoted;
57+
return `~${serialize(this.key)}`;
4958
}
5059
}
5160

@@ -150,7 +159,7 @@ export class KeysFilterSelector extends JSONPathSelector {
150159
}
151160
}
152161

153-
public toString(): string {
154-
return `~?${this.expression.toString()}`;
162+
public toString(options?: SerializationOptions): string {
163+
return `~?${this.expression.toString(options)}`;
155164
}
156165
}

src/path/node.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { JSONPointer } from "../pointer";
22
import { JSONValue, isString } from "../types";
3-
import { KEY_MARK } from "./types";
3+
import { toCanonical, toQuoted, toShorthand } from "./serialize";
4+
import {
5+
type SerializationOptions,
6+
defaultSerializationOptions,
7+
KEY_MARK,
8+
} from "./types";
49

510
/**
611
* The pair of a JSON value and its location found in the target JSON value.
@@ -17,12 +22,28 @@ export class JSONPathNode {
1722
readonly root: JSONValue,
1823
) {}
1924

25+
/**
26+
* @deprecated Use {@link getPath} with `options.form` set to `canonical` instead.
27+
*/
2028
public get path(): string {
29+
return this.getPath({ form: "canonical" });
30+
}
31+
32+
/**
33+
* Get the path to this node in the target JSON value.
34+
*
35+
* Given that the path refers to the singular current node, the returned path
36+
* will always be a normalized path if `options.form` is set to `canonical`,
37+
* following section 2.7 of RFC 9535.
38+
*/
39+
public getPath(options?: SerializationOptions): string {
40+
const opts = { ...defaultSerializationOptions, ...options };
41+
2142
return (
2243
// eslint-disable-next-line prefer-template
2344
"$" +
2445
this.location
25-
.map((s) => (isString(s) ? this.decode_name_location(s) : `[${s}]`))
46+
.map((s) => (isString(s) ? this.decodeNameLocation(s, opts) : `[${s}]`))
2647
.join("")
2748
);
2849
}
@@ -37,10 +58,20 @@ export class JSONPathNode {
3758
return new JSONPointer(JSONPointer.encode(this.location.map(String)));
3859
}
3960

40-
private decode_name_location(name: string): string {
41-
return name.startsWith(KEY_MARK)
42-
? `[~'${name.slice(1).replaceAll("'", "\\'")}']`
43-
: `['${name.replaceAll("'", "\\'")}']`;
61+
private decodeNameLocation(
62+
name: string,
63+
options: SerializationOptions,
64+
): string {
65+
const serialize = options.form === "canonical" ? toCanonical : toQuoted;
66+
const hasKeyMark = name.startsWith(KEY_MARK);
67+
if (hasKeyMark) name = name.slice(1);
68+
const shorthand = toShorthand(name);
69+
70+
if (hasKeyMark) {
71+
return shorthand == null ? `[~${serialize(name)}]` : `.~${shorthand}`;
72+
}
73+
74+
return shorthand == null ? `[${serialize(name)}]` : `.${shorthand}`;
4475
}
4576
}
4677

@@ -99,8 +130,8 @@ export class JSONPathNodeList {
99130
* A normalized path contains only property name and index selectors, and
100131
* always uses bracketed segments, never shorthand selectors.
101132
*/
102-
public paths(): string[] {
103-
return this.nodes.map((node) => node.path);
133+
public paths(options?: SerializationOptions): string[] {
134+
return this.nodes.map((node) => node.getPath(options));
104135
}
105136

106137
/**

src/path/path.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { JSONPathNode, JSONPathNodeList } from "./node";
33
import { IndexSelector, NameSelector } from "./selectors";
44
import { JSONValue } from "../types";
55
import { JSONPathSegment, DescendantSegment } from "./segments";
6+
import { SerializationOptions } from "./types";
67

78
/**
89
*
@@ -62,10 +63,11 @@ export class JSONPathQuery {
6263
}
6364

6465
/**
65-
*
66+
* Return a string representation of this query.
6667
*/
67-
public toString(): string {
68-
return `$${this.segments.map((s) => s.toString()).join("")}`;
68+
public toString(options?: SerializationOptions): string {
69+
// return this.prettyPath();
70+
return `$${this.segments.map((s) => s.toString(options)).join("")}`;
6971
}
7072

7173
public singularQuery(): boolean {

src/path/segments.ts

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,10 @@ import { JSONPathRecursionLimitError } from "./errors";
44
import { JSONPathNode } from "./node";
55
import { JSONPathSelector, NameSelector } from "./selectors";
66
import { Token } from "./token";
7-
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;
7+
import {
8+
type SerializationOptions,
9+
defaultSerializationOptions,
10+
} from "./types";
1611

1712
/** Base class for all JSONPath segments. Both shorthand and bracketed. */
1813
export abstract class JSONPathSegment {
@@ -35,9 +30,9 @@ export abstract class JSONPathSegment {
3530
): Generator<JSONPathNode>;
3631

3732
/**
38-
* Return a canonical string representation of this segment.
33+
* Return a string representation of this segment.
3934
*/
40-
public abstract toString(): string;
35+
public abstract toString(options?: SerializationOptions): string;
4136
}
4237

4338
/** The child selection segment. */
@@ -60,21 +55,19 @@ export class ChildSegment extends JSONPathSegment {
6055
}
6156
}
6257

63-
public toString(): string {
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-
}
58+
public toString(options?: SerializationOptions): string {
59+
const { form } = { ...defaultSerializationOptions, ...options };
7360

74-
return `[${selector}]`;
61+
if (
62+
form === "pretty" &&
63+
this.selectors.length === 1 &&
64+
this.selectors[0] instanceof NameSelector
65+
) {
66+
const shorthand = this.selectors[0].shorthand();
67+
if (shorthand != null) return `.${shorthand}`;
7568
}
7669

77-
return `[${selectors.map((s) => s.toString()).join(", ")}]`;
70+
return `[${this.selectors.map((s) => s.toString(options)).join(", ")}]`;
7871
}
7972
}
8073

@@ -110,8 +103,8 @@ export class DescendantSegment extends JSONPathSegment {
110103
}
111104
}
112105

113-
public toString(): string {
114-
return `..[${this.selectors.map((s) => s.toString()).join(", ")}]`;
106+
public toString(options?: SerializationOptions): string {
107+
return `..[${this.selectors.map((s) => s.toString(options)).join(", ")}]`;
115108
}
116109

117110
private *visit(

src/path/selectors.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import { JSONPathIndexError } from "./errors";
33
import { LogicalExpression } from "./expression";
44
import { JSONPathNode } from "./node";
55
import { Token } from "./token";
6-
import { FilterContext, hasStringKey } from "./types";
6+
import {
7+
type FilterContext,
8+
type SerializationOptions,
9+
defaultSerializationOptions,
10+
hasStringKey,
11+
} from "./types";
712
import { isArray, isObject, isString, JSONValue } from "../types";
13+
import { toCanonical, toQuoted, toShorthand } from "./serialize";
814

915
/**
1016
* Base class for all JSONPath segments and selectors.
@@ -31,7 +37,7 @@ export abstract class JSONPathSelector {
3137
/**
3238
* Return a canonical string representation of this selector.
3339
*/
34-
public abstract toString(): string;
40+
public abstract toString(options?: SerializationOptions): string;
3541
}
3642

3743
/**
@@ -70,16 +76,13 @@ export class NameSelector extends JSONPathSelector {
7076
}
7177
}
7278

73-
public toString(): string {
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-
}
79+
public toString(options?: SerializationOptions): string {
80+
const { form } = { ...defaultSerializationOptions, ...options };
81+
return form === "canonical" ? toCanonical(this.name) : toQuoted(this.name);
82+
}
8183

82-
return `'${inner.replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
84+
public shorthand(): string | null {
85+
return toShorthand(this.name);
8386
}
8487
}
8588

@@ -416,7 +419,7 @@ export class FilterSelector extends JSONPathSelector {
416419
}
417420
}
418421

419-
public toString(): string {
420-
return `?${this.expression.toString()}`;
422+
public toString(options?: SerializationOptions): string {
423+
return `?${this.expression.toString(options)}`;
421424
}
422425
}

src/path/serialize.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* An identifier that is allowed in both JS and JSONPath.
3+
* JSONPath identifiers are generally much more permissive than JS ones, but
4+
* they don't allow the character "$", so we take the intersection of the two
5+
* when deciding whether to use dot shorthand for canonical serialization of
6+
* simple names.
7+
*/
8+
const SHORTHAND_COMPATIBLE_IDENTIFIER = /^[\p{ID_Start}_]\p{ID_Continue}*$/u;
9+
10+
/** Usable in a quoted path. */
11+
export function toQuoted(name: string): string {
12+
return name.includes("'") && !name.includes('"')
13+
? JSON.stringify(name)
14+
: toCanonical(name);
15+
}
16+
17+
/** Usable in a normalized path. */
18+
export function toCanonical(name: string): string {
19+
return `'${JSON.stringify(name).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
20+
}
21+
22+
/** Usable in a shorthand path. */
23+
export function toShorthand(name: string): string | null {
24+
return SHORTHAND_COMPATIBLE_IDENTIFIER.test(name) ? name : null;
25+
}

0 commit comments

Comments
 (0)