Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,26 @@ const result = query.find({
// [ 'John Doe', 'Jane Doe' ]
console.log(result);


const pathResult = query.paths({
users: [{ name: "John Doe" }, { name: "Jane Doe" }],
users: [{ name: "John Doe" }, { name: "Jane Doe" }],
});

// [
// { value: "John Doe", path: "$['users'][0]['name']" },
// { value: "Jane Doe", path: "$['users'][1]['name']" },
// ];
console.log(pathResult);

const pathSegmentsResult = query.pathSegments({
users: [{ name: "John Doe" }, { name: "Jane Doe" }],
});

// NOTE: The root node $ is not included in the segments
// [
// { value: "John Doe", segments: ["users", 0, "name"] },
// { value: "Jane Doe", segments: ["users", 1, "name"] },
// ];
console.log(pathSegmentsResult);
```

## Contributing
Expand Down
55 changes: 54 additions & 1 deletion src/jsonpath_js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,88 @@ import type { JsonpathQuery } from "./grammar/ast";
import { parse } from "./grammar/jsonpath_js";
import { run } from "./parser";
import type { Json } from "./types/json";
import { escapeMemberName } from "./utils/escapeMemberName";

type PathResult = {
value: Json;
path: string;
};

type PathSegmentsResult = {
value: Json;
segments: (string | number)[];
};

/**
* A JSONPath query engine for executing JSONPath queries against JSON data.
* Fully implements the RFC 9535 JSONPath specification.
*/
export class JSONPathJS {
rootNode: JsonpathQuery;

/**
* Creates a new JSONPath query instance.
* @param query - The JSONPath query string to parse
* @throws Throws an error if the query string is invalid
*/
constructor(private query: string) {
const parseResult = parse(query);
this.rootNode = parseResult;
}

/**
* Executes the JSONPath query and returns only the matching values.
* @param json - The JSON data to query against
* @returns An array of matching values
*/
find(json: Json): Json {
const resultNodeList = run(json, this.rootNode);
return resultNodeList
.filter((json) => json !== undefined)
.map((json) => json.value);
}

#convertPathSegmentToString(segment: string | number): string {
if (typeof segment === "string") {
if (segment === "$") {
return "$";
}
return `['${escapeMemberName(segment)}']`;
}
return `[${segment}]`;
}

/**
* Executes the JSONPath query and returns both matching values and their JSONPath strings.
* @param json - The JSON data to query against
* @returns An array of objects containing the matching value and its JSONPath string
*/
paths(json: Json): PathResult[] {
return this.pathSegments(json).map((result) => ({
value: result.value,
path:
"$" +
result.segments
.map((segment) => this.#convertPathSegmentToString(segment))
.join(""),
}));
}

/**
* Executes the JSONPath query and returns both matching values and their path segments as arrays.
* Path segments are returned as arrays containing strings (for object keys) and numbers (for array indices).
* The root segment $ is not included in path segments.
* @param json - The JSON data to query against
* @returns An array of objects containing the matching value and its path segments as an array
*/
pathSegments(json: Json): PathSegmentsResult[] {
const resultNodeList = run(json, this.rootNode);
return resultNodeList
.filter((json) => json !== undefined)
.map((json) => {
return {
value: json.value,
path: json.path,
segments: json.path.slice(1), // Remove the root '$' segment from the path
};
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import type { Json } from "./types/json";
import { createNode, type Node, type NodeList } from "./types/node";

export function run(json: Json, query: JsonpathQuery): NodeList {
const rootNode: Node = createNode(json, "$");
const rootNode: Node = createNode(json, ["$"]);
return applyRoot(query, rootNode);
}
13 changes: 5 additions & 8 deletions src/types/node.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { escapeMemberName } from "../utils/escapeMemberName";
import type { Json } from "./json";

const nodeType: unique symbol = Symbol("NodeType");

export type Node = { [nodeType]: unknown; value: Json; path: string };
export type PathSegment = string | number;
export type Node = { [nodeType]: unknown; value: Json; path: PathSegment[] };
export type NodeList = Node[];

export function createNode(json: Json, path: string): Node {
export function createNode(json: Json, path: PathSegment[]): Node {
return { [nodeType]: undefined, value: json, path };
}

Expand All @@ -15,14 +15,11 @@ export function addMemberPath(
newValue: Json,
memberName: string,
): Node {
return createNode(
newValue,
`${base.path}['${escapeMemberName(memberName)}']`,
);
return createNode(newValue, [...base.path, memberName]);
}

export function addIndexPath(base: Node, newValue: Json, index: number): Node {
return createNode(newValue, `${base.path}[${index}]`);
return createNode(newValue, [...base.path, index]);
}

export function isNode(node: unknown): node is Node {
Expand Down
37 changes: 37 additions & 0 deletions tests/path_segments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, it } from "vitest";
import { JSONPathJS } from "../src";

const book1 = {
category: "reference",
author: "Nigel Rees",
title: "Sayings of the Century",
price: 8.95,
};

const book2 = {
category: "fiction",
author: "Evelyn Waugh",
title: "Sword of Honour",
price: 12.99,
};

const json = {
store: {
book: [book1, book2],
},
};

it("should return path segments as arrays of strings and numbers", () => {
const path = new JSONPathJS("$.store.book[*].author");
const pathSegmentsList = path.pathSegments(json).map((path) => path.segments);

expect(pathSegmentsList[0]).toEqual(["store", "book", 0, "author"]);
expect(pathSegmentsList[1]).toEqual(["store", "book", 1, "author"]);
});

it("should return empty segments for root segment", () => {
const path = new JSONPathJS("$");
const pathSegmentsList = path.pathSegments(json).map((path) => path.segments);

expect(pathSegmentsList[0]).toEqual([]);
});