diff --git a/CHANGELOG.md b/CHANGELOG.md index d463b3c..af27819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # JSON P3 Change Log +# Version 1.1.0 (unreleased) + +**Features** + +- Added `nondeterministic` to `JSONPathEnvironmentOptions` and environment variables to control nondeterminism and the location of `cts.json` when testing for compliance. See the [README](https://github.com/jg-rp/json-p3/blob/main/README.md) for a description of these environment variables. + # Version 1.0.0 [RFC 9535](https://datatracker.ietf.org/doc/html/rfc9535) has been published, replacing the [draft IETF JSONPath base](https://datatracker.ietf.org/doc/html/draft-ietf-jsonpath-base-21). diff --git a/README.md b/README.md index b3b2668..b54be72 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,15 @@ JSON P3 has zero runtime dependencies. | `json-p3-iife.js` | A bundle formatted as an Immediately Invoked Function Expression. | | `json-p3-iife.min.js` | A minified bundle formatted as an Immediately Invoked Function Expression. | +## Compliance Environment Variables + +These environment variables control the location of the compliance test suite under test and if nondeterministic object iteration is enabled for those tests. + +| Environment Variable | Description | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `JSONP3_CTS_PATH` | The path to `cts.json` used by `compliance.test.ts`. Defaults to `tests/path/cts/cts.json`. | +| `JSONP3_CTS_NONDETERMINISTIC` | When set to `true`, enables nondeterministic iteration of JSON objects for `compliance.test.ts`. Defaults to `false`. | + ## Contributing Please see [Contributing to JSON P3](https://github.com/jg-rp/json-p3/blob/main/CONTRIBUTING.md) diff --git a/package-lock.json b/package-lock.json index 43e51fc..6443601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "json-p3", - "version": "0.3.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "json-p3", - "version": "0.3.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@babel/cli": "^7.23.4", diff --git a/performance/index.js b/performance/index.js index bce9486..71488f6 100644 --- a/performance/index.js +++ b/performance/index.js @@ -30,4 +30,4 @@ function perf(repeat) { return (stop - start) / 1e3; } -console.log(perf(1000)); +console.log(perf(10000)); diff --git a/src/path/environment.ts b/src/path/environment.ts index 4136825..4a55955 100644 --- a/src/path/environment.ts +++ b/src/path/environment.ts @@ -51,6 +51,11 @@ export type JSONPathEnvironmentOptions = { * can visit before a `JSONPathRecursionLimitError` is thrown. */ maxRecursionDepth?: number; + + /** + * If `true`, enable nondeterministic ordering when iterating JSON object data. + */ + nondeterministic?: boolean; }; /** @@ -88,6 +93,11 @@ export class JSONPathEnvironment { */ readonly maxRecursionDepth: number; + /** + * If `true`, enable nondeterministic ordering when iterating JSON object data. + */ + readonly nondeterministic: boolean; + /** * A map of function names to objects implementing the {@link FilterFunction} * interface. You are free to set or delete custom filter functions directly. @@ -104,6 +114,7 @@ export class JSONPathEnvironment { this.maxIntIndex = options.maxIntIndex ?? Math.pow(2, 53) - 1; this.minIntIndex = options.maxIntIndex ?? -Math.pow(2, 53) - 1; this.maxRecursionDepth = options.maxRecursionDepth ?? 50; + this.nondeterministic = options.nondeterministic ?? false; this.parser = new Parser(this); this.setupFilterFunctions(); } @@ -262,4 +273,31 @@ export class JSONPathEnvironment { return args; } + + /** + * Return an array of key/values of the enumerable properties in _obj_. + * + * If you want to introduce some nondeterminism to iterating JSON-like + * objects, do it here. The wildcard selector, descendent segment and + * filter selector all use `this.environment.entries`. + * + * @param obj - A JSON-like object. + */ + public entries(obj: { + [key: string]: JSONValue; + }): Array<[string, JSONValue]> { + function shuffle(entries: Array<[string, JSONValue]>) { + for (let i = entries.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [entries[i], entries[j]] = [entries[j], entries[i]]; + } + return entries; + } + + if (this.nondeterministic) { + return shuffle(Object.entries(obj)); + } + + return Object.entries(obj); + } } diff --git a/src/path/selectors.ts b/src/path/selectors.ts index b26bd06..338e9ef 100644 --- a/src/path/selectors.ts +++ b/src/path/selectors.ts @@ -278,7 +278,7 @@ export class WildcardSelector extends JSONPathSelector { ); } } else if (isObject(node.value)) { - for (const [key, value] of Object.entries(node.value)) { + for (const [key, value] of this.environment.entries(node.value)) { rv.push( new JSONPathNode(value, node.location.concat(key), node.root), ); @@ -300,7 +300,7 @@ export class WildcardSelector extends JSONPathSelector { ); } } else if (isObject(node.value)) { - for (const [key, value] of Object.entries(node.value)) { + for (const [key, value] of this.environment.entries(node.value)) { yield new JSONPathNode(value, node.location.concat(key), node.root); } } @@ -377,7 +377,9 @@ export class RecursiveDescentSegment extends JSONPathSelector { } } } else if (isObject(currentNode.value)) { - for (const [key, value] of Object.entries(currentNode.value)) { + for (const [key, value] of this.environment.entries( + currentNode.value, + )) { const __node = new JSONPathNode( value, currentNode.location.concat(key), @@ -421,7 +423,7 @@ export class RecursiveDescentSegment extends JSONPathSelector { } } } else if (isObject(node.value)) { - for (const [key, value] of Object.entries(node.value)) { + for (const [key, value] of this.environment.entries(node.value)) { const _node = new JSONPathNode( value, node.location.concat(key), @@ -467,7 +469,7 @@ export class FilterSelector extends JSONPathSelector { } } } else if (isObject(node.value)) { - for (const [key, value] of Object.entries(node.value)) { + for (const [key, value] of this.environment.entries(node.value)) { const filterContext: FilterContext = { environment: this.environment, currentValue: value, @@ -502,7 +504,7 @@ export class FilterSelector extends JSONPathSelector { } } } else if (isObject(node.value)) { - for (const [key, value] of Object.entries(node.value)) { + for (const [key, value] of this.environment.entries(node.value)) { const filterContext: FilterContext = { environment: this.environment, currentValue: value, diff --git a/tests/path/compliance.test.ts b/tests/path/compliance.test.ts new file mode 100644 index 0000000..07cb304 --- /dev/null +++ b/tests/path/compliance.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "fs"; + +import { JSONPathEnvironment } from "../../src/path/environment"; +import { JSONPathError } from "../../src/path/errors"; +import { JSONValue } from "../../src/types"; + +type Case = { + name: string; + selector: string; + document?: JSONValue; + result?: JSONValue[]; + results?: JSONValue[][]; + invalid_selector?: boolean; +}; + +const cts = JSON.parse( + readFileSync(process.env.JSONP3_CTS_PATH || "tests/path/cts/cts.json", { + encoding: "utf8", + }), +); + +const env = new JSONPathEnvironment({ + nondeterministic: process.env.JSONP3_CTS_NONDETERMINISTIC === "true", +}); + +const testSuiteName = env.nondeterministic + ? "compliance test suite (nondeterministic)" + : "compliance test suite"; + +describe(testSuiteName, () => { + test.each(cts.tests)( + "$name", + ({ selector, document, result, results, invalid_selector }: Case) => { + if (invalid_selector) { + expect(() => env.compile(selector)).toThrow(JSONPathError); + } else if (document) { + if (result) { + const rv = env.query(selector, document).values(); + expect(rv).toStrictEqual(result); + } else if (results) { + const rv = env.query(selector, document).values(); + expect(results).toContainEqual(rv); + } + } + }, + ); +}); diff --git a/tests/path/cts b/tests/path/cts index 446336c..c1c6b88 160000 --- a/tests/path/cts +++ b/tests/path/cts @@ -1 +1 @@ -Subproject commit 446336cd6651586f416a3b546c70bdd0fa2022c0 +Subproject commit c1c6b88b25a7895b227383e22891a5f52fb70508 diff --git a/tests/path/lazy.test.ts b/tests/path/lazy.test.ts new file mode 100644 index 0000000..dc36222 --- /dev/null +++ b/tests/path/lazy.test.ts @@ -0,0 +1,37 @@ +import { JSONPathNodeList } from "../../src/path"; +import { JSONPathEnvironment } from "../../src/path/environment"; +import { JSONPathError } from "../../src/path/errors"; +import { JSONValue } from "../../src/types"; + +import cts from "./cts/cts.json"; + +type Case = { + name: string; + selector: string; + document?: JSONValue; + result?: JSONValue[]; + results?: JSONValue[][]; + invalid_selector?: boolean; +}; + +describe("lazy resolution", () => { + test.each(cts.tests)( + "$name", + ({ selector, document, result, results, invalid_selector }: Case) => { + const env = new JSONPathEnvironment(); + if (invalid_selector) { + expect(() => env.compile(selector)).toThrow(JSONPathError); + } else if (document) { + if (result) { + const it = env.lazyQuery(selector, document); + const rv = new JSONPathNodeList(Array.from(it)).values(); + expect(rv).toStrictEqual(result); + } else if (results) { + const it = env.lazyQuery(selector, document); + const rv = new JSONPathNodeList(Array.from(it)).values(); + expect(results).toContainEqual(rv); + } + } + }, + ); +}); diff --git a/tests/path/query.test.ts b/tests/path/query.test.ts deleted file mode 100644 index 1ea7b84..0000000 --- a/tests/path/query.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { JSONPathNodeList } from "../../src/path"; -import { JSONPathEnvironment } from "../../src/path/environment"; -import { JSONPathError } from "../../src/path/errors"; -import { JSONValue } from "../../src/types"; - -import cts from "./cts/cts.json"; - -type Case = { - name: string; - selector: string; - document?: JSONValue; - result?: JSONValue[]; - invalid_selector?: boolean; -}; - -describe("compliance test suite", () => { - test.each(cts.tests)( - "$name", - ({ selector, document, result, invalid_selector }: Case) => { - const env = new JSONPathEnvironment(); - if (invalid_selector) { - expect(() => env.compile(selector)).toThrow(JSONPathError); - } else if (document && result) { - const rv = env.query(selector, document).values(); - expect(rv).toStrictEqual(result); - } - }, - ); -}); - -describe("lazy resolution", () => { - test.each(cts.tests)( - "$name", - ({ selector, document, result, invalid_selector }: Case) => { - const env = new JSONPathEnvironment(); - if (invalid_selector) { - expect(() => env.compile(selector)).toThrow(JSONPathError); - } else if (document && result) { - const it = env.lazyQuery(selector, document); - const rv = new JSONPathNodeList(Array.from(it)).values(); - expect(rv).toStrictEqual(result); - } - }, - ); -}); diff --git a/tsconfig.json b/tsconfig.json index 125ba0f..44ba1dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "strict": true, "target": "es2022", "noImplicitAny": true, - "types": ["jest"], + "types": ["jest", "node"], "incremental": true, "importHelpers": true, "isolatedModules": true,