diff --git a/README.md b/README.md index e7f945c..ff3d23d 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,8 @@ 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" }], }); // [ @@ -52,6 +51,17 @@ const pathResult = query.paths({ // { 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 diff --git a/src/jsonpath_js.ts b/src/jsonpath_js.ts index dbefbd8..c5fe9be 100644 --- a/src/jsonpath_js.ts +++ b/src/jsonpath_js.ts @@ -2,20 +2,40 @@ 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 @@ -23,14 +43,47 @@ export class JSONPathJS { .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 }; }); } diff --git a/src/parser.ts b/src/parser.ts index a2c4ada..ac9f694 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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); } diff --git a/src/types/node.ts b/src/types/node.ts index 025c744..a5c2f0e 100644 --- a/src/types/node.ts +++ b/src/types/node.ts @@ -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 }; } @@ -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 { diff --git a/tests/path_segments.test.ts b/tests/path_segments.test.ts new file mode 100644 index 0000000..97bc32b --- /dev/null +++ b/tests/path_segments.test.ts @@ -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([]); +});