diff --git a/README.md b/README.md index 6ff5176..a364b1f 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,15 @@ Supported values (case insensitive): - `yes`/`y`/`true`/`1`/`on` - `no`/`n`/`false`/`0`/`off` +The variable is read when `schema-utils` is loaded, so set it before starting the process: + +```console +SKIP_VALIDATION=y webpack +``` + +Use `enableValidation()`/`disableValidation()` to change it while the process is running - they +take effect immediately and apply to every copy of `schema-utils` in the process. + ## Contributing Please take a moment to read our contributing guidelines if you haven't yet done so. diff --git a/declarations/validate.d.ts b/declarations/validate.d.ts index 081f2fe..8b5077d 100644 --- a/declarations/validate.d.ts +++ b/declarations/validate.d.ts @@ -52,6 +52,33 @@ export type ValidationErrorConfiguration = { */ postFormatter?: PostFormatter | undefined; }; +/** + * A node of the prefix tree used by `filterErrors` to look up already reported errors by their + * instance path. + */ +export type ErrorPathNode = { + /** + * positions (in the result array) of the errors reported for exactly this instance path + */ + indexes: number[]; + /** + * nodes of nested instance paths, keyed by json pointer segment, created on demand + */ + children: Map | undefined; + /** + * amount of errors stored in this node and in all its descendants + */ + size: number; +}; +/** + * Whether validation is skipped, shared by every `schema-utils` in the process. + */ +export type SkipValidationState = { + /** + * true when validation is disabled + */ + skip: boolean; +}; /** * @returns {void} */ diff --git a/package-lock.json b/package-lock.json index 2d7461f..a87ef49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7313,9 +7313,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", diff --git a/src/ValidationError.js b/src/ValidationError.js index fb1796d..d8c58a0 100644 --- a/src/ValidationError.js +++ b/src/ValidationError.js @@ -47,12 +47,14 @@ const SPECIFICITY = { absolutePath: 2, }; +const IS_NUMERIC = /^-?\d+$/; + /** * @param {string} value value * @returns {value is number} true when is number, otherwise false */ function isNumeric(value) { - return /^-?\d+$/.test(value); + return IS_NUMERIC.test(value); } /** @@ -113,10 +115,11 @@ function extractRefs(error) { * Find all children errors * @param {SchemaUtilErrorObject[]} children children * @param {string[]} schemaPaths schema paths + * @param {number=} end amount of children to look at, i.e. only `children[0..end - 1]` are visited * @returns {number} returns index of first child */ -function findAllChildren(children, schemaPaths) { - let i = children.length - 1; +function findAllChildren(children, schemaPaths, end = children.length) { + let i = end - 1; const predicate = /** * @param {string} schemaPath schema path @@ -127,10 +130,11 @@ function findAllChildren(children, schemaPaths) { while (i > -1 && !schemaPaths.every(predicate)) { if (children[i].keyword === "anyOf" || children[i].keyword === "oneOf") { const refs = extractRefs(children[i]); - const childrenStart = findAllChildren(children.slice(0, i), [ - ...refs, - children[i].schemaPath, - ]); + const childrenStart = findAllChildren( + children, + [...refs, children[i].schemaPath], + i, + ); i = childrenStart - 1; } else { @@ -155,10 +159,11 @@ function groupChildrenByFirstChild(children) { if (child.keyword === "anyOf" || child.keyword === "oneOf") { const refs = extractRefs(child); - const childrenStart = findAllChildren(children.slice(0, i), [ - ...refs, - child.schemaPath, - ]); + const childrenStart = findAllChildren( + children, + [...refs, child.schemaPath], + i, + ); if (childrenStart !== i) { result.push({ ...child, children: children.slice(childrenStart, i) }); @@ -181,12 +186,53 @@ function groupChildrenByFirstChild(children) { } /** + * Indents every line of `str` but the first one, a trailing new line is left alone. * @param {string} str string * @param {string} prefix prefix * @returns {string} string with indent and prefix */ function indent(str, prefix) { - return str.replace(/\n(?!$)/g, `\n${prefix}`); + const firstNewLine = str.indexOf("\n"); + + // Most formatted errors are a single line + if (firstNewLine === -1 || firstNewLine === str.length - 1) { + return str; + } + + const separator = `\n${prefix}`; + + return str.charCodeAt(str.length - 1) === 10 /* \n */ + ? `${str.slice(0, -1).split("\n").join(separator)}\n` + : str.split("\n").join(separator); +} + +// A list of errors longer than this is not readable anyway and formatting it can take a lot of +// memory, i.e. a configuration with 200000 invalid values used to produce a 20MB long message +const MAX_LISTED_ERRORS = 100; + +/** + * Formats a list of errors, listing at most `MAX_LISTED_ERRORS` of them and only counting the rest. + * @param {SchemaUtilErrorObject[]} errors errors + * @param {string} bullet marker put in front of every entry + * @param {(error: SchemaUtilErrorObject) => string} format formats a single error + * @returns {string} formatted list of errors + */ +function formatErrorList(errors, bullet, format) { + const listed = Math.min(errors.length, MAX_LISTED_ERRORS); + /** @type {string[]} */ + const lines = []; + + for (let i = 0; i < listed; i++) { + lines.push(`${bullet}${indent(format(errors[i]), " ")}`); + } + + const rest = errors.length - listed; + + if (rest > 0) { + lines.push(`${bullet}and ${rest} more error${rest > 1 ? "s" : ""}`); + } + + return lines.join("\n"); } /** @@ -916,27 +962,21 @@ class ValidationError extends Error { formatValidationError(error) { const { keyword, instancePath: errorInstancePath } = error; - const splittedInstancePath = errorInstancePath.split("/"); - /** - * @type {string[]} - */ - const defaultValue = []; - const prettyInstancePath = splittedInstancePath - .reduce((acc, val) => { - if (val.length > 0) { - if (isNumeric(val)) { - acc.push(`[${val}]`); - } else if (/^\[/.test(val)) { - acc.push(val); - } else { - acc.push(`.${val}`); - } - } + let instancePath = this.baseDataPath; - return acc; - }, defaultValue) - .join(""); - const instancePath = `${this.baseDataPath}${prettyInstancePath}`; + for (const part of errorInstancePath.split("/")) { + if (part.length === 0) { + continue; + } + + if (isNumeric(part)) { + instancePath += `[${part}]`; + } else if (part.charCodeAt(0) === 91 /* [ */) { + instancePath += part; + } else { + instancePath += `.${part}`; + } + } // const { keyword, instancePath: errorInstancePath } = error; // const instancePath = `${this.baseDataPath}${errorInstancePath.replace(/\//g, '.')}`; @@ -1317,16 +1357,11 @@ class ValidationError extends Error { return `${instancePath} should be one of these:\n${this.getSchemaPartText( parentSchema, - )}\nDetails:\n${filteredChildren - .map( - /** - * @param {SchemaUtilErrorObject} nestedError nested error - * @returns {string} formatted errors - */ - (nestedError) => - ` * ${indent(this.formatValidationError(nestedError), " ")}`, - ) - .join("\n")}`; + )}\nDetails:\n${formatErrorList( + filteredChildren, + " * ", + (nestedError) => this.formatValidationError(nestedError), + )}`; } return `${instancePath} should be one of these:\n${this.getSchemaPartText( @@ -1369,17 +1404,13 @@ class ValidationError extends Error { * @returns {string} formatted errors */ formatValidationErrors(errors) { - return errors - .map((error) => { - let formattedError = this.formatValidationError(error); - - if (this.postFormatter) { - formattedError = this.postFormatter(formattedError, error); - } + return formatErrorList(errors, " - ", (error) => { + const formattedError = this.formatValidationError(error); - return ` - ${indent(formattedError, " ")}`; - }) - .join("\n"); + return this.postFormatter + ? this.postFormatter(formattedError, error) + : formattedError; + }); } } diff --git a/src/validate.js b/src/validate.js index 89c4473..a38ebdd 100644 --- a/src/validate.js +++ b/src/validate.js @@ -93,19 +93,66 @@ function applyPrefix(error, idx) { return error; } -let skipValidation = false; +const IS_TRUTHY = /^(?:y|yes|true|1|on)$/i; +const IS_FALSY = /^(?:n|no|false|0|off)$/i; -// We use `process.env.SKIP_VALIDATION` because you can have multiple `schema-utils` with different version, -// so we want to disable it globally, `process.env` doesn't supported by browsers, so we have the local `skipValidation` variables +/** + * @returns {boolean} true when `process.env.SKIP_VALIDATION` asks to skip validation + */ +function skipValidationFromEnv() { + const value = + process && process.env ? process.env.SKIP_VALIDATION : undefined; + + if (value) { + const trimmedValue = value.trim(); + + if (IS_TRUTHY.test(trimmedValue)) { + return true; + } + + if (IS_FALSY.test(trimmedValue)) { + return false; + } + } + + return false; +} + +/** + * Whether validation is skipped, shared by every `schema-utils` in the process. + * @typedef {object} SkipValidationState + * @property {boolean} skip true when validation is disabled + */ + +const SKIP_VALIDATION_KEY = Symbol.for("schema-utils/skipValidation"); +const globalObject = + /** @type {Record} */ + ( + /** @type {unknown} */ + // eslint-disable-next-line no-undef + typeof globalThis === "undefined" ? global : globalThis + ); + +// `process.env.SKIP_VALIDATION` is read when this module is loaded, not on every validation - +// reading a variable from `process.env` costs about 250ns, which is most of the time a successful +// validation takes. Later changes go through `enableValidation`/`disableValidation`, which share +// the resolved state through the global object so that `schema-utils` copies of different +// versions still turn each other on and off, and keep writing `process.env` for copies too old +// to know about the shared state. +const sharedState = + globalObject[SKIP_VALIDATION_KEY] || + (globalObject[SKIP_VALIDATION_KEY] = { skip: false }); + +sharedState.skip = skipValidationFromEnv(); // Enable validation /** * @returns {void} */ function enableValidation() { - skipValidation = false; + sharedState.skip = false; - // Disable validation for any versions + // Enable validation for any versions if (process && process.env) { process.env.SKIP_VALIDATION = "n"; } @@ -116,7 +163,7 @@ function enableValidation() { * @returns {void} */ function disableValidation() { - skipValidation = true; + sharedState.skip = true; if (process && process.env) { process.env.SKIP_VALIDATION = "y"; @@ -128,52 +175,201 @@ function disableValidation() { * @returns {boolean} true when need validate, otherwise false */ function needValidate() { - if (skipValidation) { - return false; + return !sharedState.skip; +} + +/** + * A node of the prefix tree used by `filterErrors` to look up already reported errors by their + * instance path. + * @typedef {object} ErrorPathNode + * @property {number[]} indexes positions (in the result array) of the errors reported for exactly this instance path + * @property {Map | undefined} children nodes of nested instance paths, keyed by json pointer segment, created on demand + * @property {number} size amount of errors stored in this node and in all its descendants + */ + +/** + * @returns {ErrorPathNode} empty node + */ +function createErrorPathNode() { + return { indexes: [], children: undefined, size: 0 }; +} + +/** + * Splits an instance path (a json pointer) into its segments, i.e. `"/rules/0"` into `["rules", "0"]`. + * @param {string} instancePath instance path + * @returns {string[]} json pointer segments + */ +function parseInstancePath(instancePath) { + // A json pointer is either empty or starts with a separator, so the leading separator is dropped + // instead of splitting off an empty first segment + return instancePath === "" ? [] : instancePath.slice(1).split("/"); +} + +/** + * @param {number} a a + * @param {number} b b + * @returns {number} comparison result + */ +function compareNumbers(a, b) { + return a - b; +} + +/** + * Stores an error at the given instance path and removes every error already stored for that path + * or for anything nested inside it, since those become children of the new one. + * + * The new error keeps the node non empty, so no node ever has to be pruned. + * @param {ErrorPathNode} root root node + * @param {string[]} segments json pointer segments of the error instance path + * @param {number} index position of the error in the result array + * @returns {number[]} positions (in the result array) of the removed errors, in the order they were reported + */ +function replaceErrorPath(root, segments, index) { + /** @type {ErrorPathNode[]} */ + const ancestors = []; + /** @type {number[]} */ + const indexes = []; + let node = root; + let depth = 0; + + // A single walk down, creating the missing nodes on the way + for (const segment of segments) { + ancestors[depth] = node; + depth += 1; + + let { children } = node; + + if (!children) { + children = new Map(); + node.children = children; + } + + let child = children.get(segment); + + if (!child) { + child = createErrorPathNode(); + children.set(segment, child); + } + + node = child; } - if (process && process.env && process.env.SKIP_VALIDATION) { - const value = process.env.SKIP_VALIDATION.trim(); + if (node.size > 0) { + /** @type {ErrorPathNode[]} */ + const stack = [node]; - if (/^(?:y|yes|true|1|on)$/i.test(value)) { - return false; + while (stack.length > 0) { + const current = /** @type {ErrorPathNode} */ (stack.pop()); + + for (const collected of current.indexes) { + indexes.push(collected); + } + + if (current.children) { + for (const child of current.children.values()) { + stack.push(child); + } + } } - if (/^(?:n|no|false|0|off)$/i.test(value)) { - return true; + // The subtree has been consumed, so detach it + node.children = undefined; + indexes.sort(compareNumbers); + } + + node.indexes = [index]; + + const delta = 1 - node.size; + + node.size = 1; + + for (let i = 0; i < depth; i++) { + ancestors[i].size += delta; + } + + return indexes; +} + +/** + * Moves an already reported error under `children`, hoisting the children it collected itself. + * @param {SchemaUtilErrorObject[]} children collected children + * @param {SchemaUtilErrorObject} oldError error to nest + * @returns {SchemaUtilErrorObject[]} collected children, which may be a different array + */ +function absorbError(children, oldError) { + let newChildren = children; + + if (oldError.children) { + if (newChildren.length === 0) { + // Adopt the array instead of copying it - a long run of sibling errors re-parents the + // previously collected children on every step, so copying them would be quadratic + newChildren = oldError.children; + } else { + for (const child of oldError.children) { + newChildren.push(child); + } } } - return true; + oldError.children = undefined; + newChildren.push(oldError); + + return newChildren; } /** - * @param {ErrorObject[]} errors array of error objects + * Whether an instance path points at `ancestorPath` itself or at something nested inside it, i.e. + * `"/rules/0"` is inside `"/rules"` but `"/rulesets"` is not. + * @param {string} instancePath instance path + * @param {string} ancestorPath ancestor instance path + * @returns {boolean} true when at or below the ancestor path, otherwise false + */ +function isAtOrBelow(instancePath, ancestorPath) { + if (instancePath.length === ancestorPath.length) { + return instancePath === ancestorPath; + } + + return ( + instancePath.length > ancestorPath.length && + // the next character has to be a separator, otherwise it is a sibling with a longer name + instancePath.charCodeAt(ancestorPath.length) === 47 /* / */ && + instancePath.startsWith(ancestorPath) + ); +} + +// Below this amount of errors scanning the collected errors directly is cheaper than indexing +// them, above it the index is what keeps the whole thing from going quadratic +const MAX_SCANNED_ERRORS = 24; + +/** + * Same as `filterErrors`, without the instance path index - for a small amount of errors walking + * the collected errors is cheaper than building one. + * @param {SchemaUtilErrorObject[]} errors array of error objects * @returns {SchemaUtilErrorObject[]} filtered array of objects */ -function filterErrors(errors) { +function scanErrors(errors) { /** @type {SchemaUtilErrorObject[]} */ - let newErrors = []; + const newErrors = []; - for (const error of /** @type {SchemaUtilErrorObject[]} */ (errors)) { + for (const error of errors) { const { instancePath } = error; /** @type {SchemaUtilErrorObject[]} */ let children = []; + let kept = 0; - newErrors = newErrors.filter((oldError) => { - if (oldError.instancePath.includes(instancePath)) { - if (oldError.children) { - children = [...children, ...oldError.children]; - } - - oldError.children = undefined; - children.push(oldError); + for (let i = 0; i < newErrors.length; i++) { + const oldError = newErrors[i]; - return false; + if (!isAtOrBelow(oldError.instancePath, instancePath)) { + newErrors[kept] = oldError; + kept += 1; + continue; } - return true; - }); + children = absorbError(children, oldError); + } + + newErrors.length = kept; if (children.length) { error.children = children; @@ -185,6 +381,56 @@ function filterErrors(errors) { return newErrors; } +/** + * Nests every error under the last reported error that covers its instance path, so that only the + * outermost errors are left at the top level. + * @param {ErrorObject[]} errors array of error objects + * @returns {SchemaUtilErrorObject[]} filtered array of objects + */ +function filterErrors(errors) { + if (errors.length <= MAX_SCANNED_ERRORS) { + return scanErrors(/** @type {SchemaUtilErrorObject[]} */ (errors)); + } + + /** @type {(SchemaUtilErrorObject | undefined)[]} */ + const newErrors = []; + const root = createErrorPathNode(); + let lastInstancePath; + /** @type {string[]} */ + let segments = []; + + for (const error of /** @type {SchemaUtilErrorObject[]} */ (errors)) { + const { instancePath } = error; + + // Errors reported next to each other usually share the instance path, i.e. the branches of an + // `anyOf`, so the split is worth reusing + if (instancePath !== lastInstancePath) { + lastInstancePath = instancePath; + segments = parseInstancePath(instancePath); + } + + /** @type {SchemaUtilErrorObject[]} */ + let children = []; + + for (const index of replaceErrorPath(root, segments, newErrors.length)) { + const oldError = /** @type {SchemaUtilErrorObject} */ (newErrors[index]); + + newErrors[index] = undefined; + children = absorbError(children, oldError); + } + + if (children.length) { + error.children = children; + } + + newErrors.push(error); + } + + return /** @type {SchemaUtilErrorObject[]} */ ( + newErrors.filter((error) => typeof error !== "undefined") + ); +} + /** * @param {Schema} schema schema * @param {object[] | object} options options @@ -215,9 +461,10 @@ function validate(schema, options, configuration) { if (Array.isArray(options)) { for (let i = 0; i <= options.length - 1; i++) { - errors.push( - ...validateObject(schema, options[i]).map((err) => applyPrefix(err, i)), - ); + // Not `errors.push(...)`, a large amount of errors would overflow the call stack + for (const error of validateObject(schema, options[i])) { + errors.push(applyPrefix(error, i)); + } } } else { errors = validateObject(schema, options); diff --git a/test/api.test.js b/test/api.test.js index 48920f6..ef15e96 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -6,11 +6,46 @@ import { validate, } from "../src/index"; +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + import schemaTitleBrone from "./fixtures/schema-title-broken.json"; import schemaTitle from "./fixtures/schema-title.json"; import schema from "./fixtures/schema.json"; describe("api", () => { + /** + * Loads a fresh copy of `schema-utils`, as if the process had been started with + * `process.env.SKIP_VALIDATION` set to `value` - the variable is read when the module is loaded. + * @param {string | undefined} value value of `process.env.SKIP_VALIDATION` + * @param {(api: EXPECTED_ANY) => void} fn receives the freshly loaded module + * @returns {void} + */ + function withSkipValidation(value, fn) { + const oldValue = process.env.SKIP_VALIDATION; + const set = (newValue) => { + if (typeof newValue === "undefined") { + delete process.env.SKIP_VALIDATION; + } else { + process.env.SKIP_VALIDATION = newValue; + } + }; + + set(value); + + try { + jest.isolateModules(() => { + fn(require("../src/index")); + }); + } finally { + set(oldValue); + // the state is shared with the already loaded copy, reload so it matches the environment again + jest.isolateModules(() => { + require("../src/index"); + }); + } + } + it("should export validate and ValidateError", () => { expect(typeof validate).toBe("function"); expect(typeof ValidationError).toBe("function"); @@ -212,39 +247,31 @@ describe("api", () => { }); it('should allow to disable validation using "process.env.SKIP_VALIDATION"', () => { - const oldValue = process.env.SKIP_VALIDATION; - - let errored; - - process.env.SKIP_VALIDATION = "y"; - - try { - validate(schemaTitle, { foo: "bar" }, { name: "NAME" }); - } catch (error) { - errored = error; - } + withSkipValidation("y", ({ validate: freshValidate }) => { + let errored; - expect(errored).toBeUndefined(); + try { + freshValidate(schemaTitle, { foo: "bar" }, { name: "NAME" }); + } catch (error) { + errored = error; + } - process.env.SKIP_VALIDATION = oldValue; + expect(errored).toBeUndefined(); + }); }); it('should allow to disable validation using "process.env.SKIP_VALIDATION" #2', () => { - const oldValue = process.env.SKIP_VALIDATION; - - let errored; - - process.env.SKIP_VALIDATION = "YeS"; - - try { - validate(schemaTitle, { foo: "bar" }, { name: "NAME" }); - } catch (error) { - errored = error; - } + withSkipValidation("YeS", ({ validate: freshValidate }) => { + let errored; - expect(errored).toBeUndefined(); + try { + freshValidate(schemaTitle, { foo: "bar" }, { name: "NAME" }); + } catch (error) { + errored = error; + } - process.env.SKIP_VALIDATION = oldValue; + expect(errored).toBeUndefined(); + }); }); it('should allow to enable validation using "process.env.SKIP_VALIDATION"', () => { @@ -314,15 +341,54 @@ describe("api", () => { } }); - it("should allow to enable and disable validation using API", () => { - process.env.SKIP_VALIDATION = "unknown"; - expect(needValidate()).toBe(true); + it('should read "process.env.SKIP_VALIDATION" when loaded, not on every validation', () => { + enableValidation(); - process.env.SKIP_VALIDATION = "no"; - expect(needValidate()).toBe(true); + try { + process.env.SKIP_VALIDATION = "y"; - process.env.SKIP_VALIDATION = "yes"; - expect(needValidate()).toBe(false); + let errored; + + try { + validate(schemaTitle, { foo: "bar" }, { name: "NAME" }); + } catch (error) { + errored = error; + } + + // the already loaded copy keeps the value it read when it was loaded + expect(errored).toBeDefined(); + } finally { + enableValidation(); + } + }); + + it("should share the state with other copies of `schema-utils`", () => { + try { + disableValidation(); + + jest.isolateModules(() => { + // another copy, as if a dependency depended on a different version + + const api = require("../src/index"); + + expect(api.needValidate()).toBe(false); + + api.enableValidation(); + }); + + // turning it back on in the other copy turns it back on here + expect(needValidate()).toBe(true); + } finally { + enableValidation(); + } + }); + + it("should allow to enable and disable validation using API", () => { + withSkipValidation("unknown", (api) => + expect(api.needValidate()).toBe(true), + ); + withSkipValidation("no", (api) => expect(api.needValidate()).toBe(true)); + withSkipValidation("yes", (api) => expect(api.needValidate()).toBe(false)); enableValidation(); expect(process.env.SKIP_VALIDATION).toBe("n"); diff --git a/test/filter-errors.test.js b/test/filter-errors.test.js new file mode 100644 index 0000000..e816099 --- /dev/null +++ b/test/filter-errors.test.js @@ -0,0 +1,208 @@ +import { validate } from "../src"; + +/** + * @param {object} schema schema + * @param {object} options options + * @returns {import("../src/validate").SchemaUtilErrorObject[]} reported errors + */ +function getErrors(schema, options) { + try { + validate(schema, options); + } catch (error) { + if (error.name !== "ValidationError") { + throw error; + } + + return error.errors; + } + + throw new Error("Validation didn't fail"); +} + +/** + * @param {object} schema schema + * @param {object} options options + * @returns {string} error message + */ +function getMessage(schema, options) { + try { + validate(schema, options); + } catch (error) { + if (error.name !== "ValidationError") { + throw error; + } + + return error.message; + } + + throw new Error("Validation didn't fail"); +} + +describe("filter errors", () => { + it("should not nest errors of a property inside errors of a sibling property with a shorter name", () => { + const schema = { + type: "object", + properties: { + foobar: { type: "string" }, + foo: { type: "string" }, + }, + }; + + const message = getMessage(schema, { foobar: 1, foo: 1 }); + + expect(message).toContain("configuration.foobar should be a string."); + expect(message).toContain("configuration.foo should be a string."); + }); + + it("should not nest errors of a nested property inside errors of an unrelated property", () => { + const schema = { + type: "object", + properties: { + a: { type: "object", properties: { b: { type: "string" } } }, + b: { type: "array", items: { type: "string" } }, + }, + }; + + const message = getMessage(schema, { a: { b: 1 }, b: 1 }); + + expect(message).toContain("configuration.a.b should be a string."); + expect(message).toContain("configuration.b should be an array"); + }); + + it("should nest errors of nested properties inside errors of their parent", () => { + const schema = { + type: "object", + properties: { + a: { + anyOf: [ + { type: "object", properties: { b: { type: "string" } } }, + { type: "string" }, + ], + }, + }, + }; + + const errors = getErrors(schema, { a: { b: 1 } }); + + expect(errors).toHaveLength(1); + expect(errors[0].keyword).toBe("anyOf"); + expect(errors[0].instancePath).toBe("/a"); + expect(errors[0].children.map((error) => error.instancePath)).toContain( + "/a/b", + ); + }); + + it("should nest sibling errors reported for the same instance path", () => { + const schema = { + type: "object", + additionalProperties: false, + properties: { a: { type: "string" } }, + }; + + const options = {}; + + for (let i = 0; i < 10; i++) { + options[`unknown${i}`] = 1; + } + + const errors = getErrors(schema, options); + + expect(errors).toHaveLength(1); + expect(errors[0].children).toHaveLength(9); + expect( + errors[0].children.every( + (error) => typeof error.children === "undefined", + ), + ).toBe(true); + }); + + // Errors are collected by scanning them directly below a threshold and through an instance path + // index above it, both have to nest them the same way + it.each([3, 40])("should nest the same way for %i errors", (length) => { + const properties = { nested: { type: "object", properties: {} } }; + + for (let i = 0; i < length; i++) { + properties.nested.properties[`p${i}`] = { type: "string" }; + } + + const schema = { type: "object", properties }; + const options = { nested: {} }; + + for (let i = 0; i < length; i++) { + options.nested[`p${i}`] = 1; + } + + const errors = getErrors(schema, options); + + expect(errors).toHaveLength(length); + expect(errors.every((error) => typeof error.children === "undefined")).toBe( + true, + ); + expect(errors.map((error) => error.instancePath)).toStrictEqual( + Array.from({ length }, (_, i) => `/nested/p${i}`), + ); + }); + + // `filterErrors` used to be quadratic in the amount of reported errors, so a large invalid + // configuration was enough to lock up the process for minutes + it("should filter a large amount of sibling errors in a reasonable time", () => { + const schema = { + type: "object", + additionalProperties: false, + properties: { a: { type: "string" } }, + }; + + const options = {}; + + for (let i = 0; i < 40000; i++) { + options[`unknown${i}`] = 1; + } + + const start = process.hrtime.bigint(); + const errors = getErrors(schema, options); + const elapsed = Number(process.hrtime.bigint() - start) / 1e6; + + expect(errors).toHaveLength(1); + expect(errors[0].children).toHaveLength(39999); + expect(elapsed).toBeLessThan(5000); + }, 30000); + + // The errors of each entry used to be spread into the result with `push(...errors)`, which + // overflows the call stack for a large amount of errors + it("should report a large amount of errors for an array of options", () => { + const schema = { + type: "object", + properties: { + list: { type: "array", items: { type: "string" } }, + }, + }; + + const options = [{ list: Array.from({ length: 200000 }, () => 1) }]; + + const errors = getErrors(schema, options); + + expect(errors).toHaveLength(200000); + expect(errors[0].instancePath).toBe("[0]/list/0"); + }, 30000); + + it("should filter a large amount of errors with distinct instance paths in a reasonable time", () => { + const schema = { + type: "object", + properties: { + list: { + type: "array", + items: { anyOf: [{ type: "string" }, { type: "boolean" }] }, + }, + }, + }; + + const options = { list: Array.from({ length: 40000 }, () => 1) }; + + const start = process.hrtime.bigint(); + const errors = getErrors(schema, options); + const elapsed = Number(process.hrtime.bigint() - start) / 1e6; + + expect(errors).toHaveLength(40000); + expect(elapsed).toBeLessThan(5000); + }, 30000); +}); diff --git a/test/large-errors.test.js b/test/large-errors.test.js new file mode 100644 index 0000000..0e6ecb6 --- /dev/null +++ b/test/large-errors.test.js @@ -0,0 +1,123 @@ +import { validate } from "../src"; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * @param {object} schema schema + * @param {object} options options + * @returns {Error & { errors: EXPECTED_ANY[] }} thrown error + */ +function getError(schema, options) { + try { + validate(schema, options); + } catch (error) { + if (error.name !== "ValidationError") { + throw error; + } + + return error; + } + + throw new Error("Validation didn't fail"); +} + +/** + * @param {number} length length + * @returns {Record} object with unknown properties + */ +function unknownProperties(length) { + /** @type {Record} */ + const options = {}; + + for (let i = 0; i < length; i++) { + options[`unknown${i}`] = 1; + } + + return options; +} + +describe("large amount of errors", () => { + it("should list at most 100 errors and count the rest", () => { + const schema = { + type: "object", + properties: { list: { type: "array", items: { type: "string" } } }, + }; + + const { message, errors } = getError(schema, { + list: Array.from({ length: 5000 }, () => 1), + }); + const listed = message.split("\n").filter((line) => line.startsWith(" - ")); + + expect(errors).toHaveLength(5000); + expect(listed).toHaveLength(101); + expect(listed[0]).toBe(" - configuration.list[0] should be a string."); + expect(listed[100]).toBe(" - and 4900 more errors"); + }); + + it("should list at most 100 errors of a `anyOf` and count the rest", () => { + const schema = { + type: "object", + properties: { + a: { + anyOf: [ + { + type: "object", + additionalProperties: false, + properties: { x: { type: "string" } }, + }, + { type: "string" }, + ], + }, + }, + }; + + const { message } = getError(schema, { a: unknownProperties(5000) }); + const listed = message.split("\n").filter((line) => line.includes(" * ")); + + expect(listed).toHaveLength(101); + expect(listed[100]).toContain("* and 4900 more errors"); + }); + + it("should not list a count when nothing was left out", () => { + const schema = { + type: "object", + properties: { list: { type: "array", items: { type: "string" } } }, + }; + + const { message } = getError(schema, { list: [1, 2, 3] }); + + expect(message).not.toContain("more error"); + expect( + message.split("\n").filter((line) => line.startsWith(" - ")), + ).toHaveLength(3); + }); + + it("should use the singular form for a single left out error", () => { + const schema = { + type: "object", + properties: { list: { type: "array", items: { type: "string" } } }, + }; + + const { message } = getError(schema, { + list: Array.from({ length: 101 }, () => 1), + }); + + expect(message).toContain(" - and 1 more error"); + expect(message).not.toContain("more errors"); + }); + + it("should keep the message small for a huge amount of errors", () => { + const schema = { + type: "object", + properties: { list: { type: "array", items: { type: "string" } } }, + }; + + const { message, errors } = getError(schema, { + list: Array.from({ length: 200000 }, () => 1), + }); + + expect(errors).toHaveLength(200000); + expect(message.length).toBeLessThan(100 * 1024); + }, 30000); +});