diff --git a/src/error-handlers/anyOf.js b/src/error-handlers/anyOf.js index cab2475..67a6fc1 100644 --- a/src/error-handlers/anyOf.js +++ b/src/error-handlers/anyOf.js @@ -1,8 +1,10 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; +import * as JsonPointer from "@hyperjump/json-pointer"; +import * as Pact from "@hyperjump/pact"; import { getErrors } from "../json-schema-errors.js"; /** - * @import { ErrorHandler, ErrorObject } from "../index.d.ts" + * @import { ErrorHandler, ErrorObject, NormalizedOutput } from "../index.d.ts" */ /** @type ErrorHandler */ @@ -16,20 +18,65 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { continue; } - const alternatives = []; const instanceLocation = Instance.uri(instance); + let filtered = anyOf; + + const instanceProps = Pact.pipe( + Instance.keys(instance), + Pact.map((keyNode) => /** @type {string} */ (Instance.value(keyNode))), + Pact.collectSet + ); + + const discriminators = Pact.pipe( + instanceProps, + Pact.filter((prop) => { + const propLocation = JsonPointer.append(prop, instanceLocation); + return Pact.some((alternative) => propertyPasses(alternative[propLocation]), anyOf); + }), + Pact.collectSet + ); + + const prefix = `${instanceLocation}/`; + filtered = []; for (const alternative of anyOf) { - const typeErrors = alternative[instanceLocation]["https://json-schema.org/keyword/type"]; - const match = !typeErrors || Object.values(typeErrors).every((isValid) => isValid); + // Filter alternatives whose declared type doesn't match the instance type + const typeResults = alternative[instanceLocation]["https://json-schema.org/keyword/type"]; + if (typeResults && !Object.values(typeResults).every((isValid) => isValid)) { + continue; + } - if (match) { - alternatives.push(await getErrors(alternative, instance, localization)); + if (Instance.typeOf(instance) === "object") { + const declaredProps = Pact.pipe( + Object.keys(alternative), + Pact.filter((loc) => loc.startsWith(prefix)), + Pact.map((loc) => /** @type {string} */ (Pact.head(JsonPointer.pointerSegments(loc.slice(prefix.length - 1))))), + Pact.collectSet + ); + + // Filter alternative if it has no declared properties in common with the instance + if (!Pact.some((prop) => declaredProps.has(prop), instanceProps)) { + continue; + } + + // Filter alternative if it has failing properties that are decalred and passing in another alternative + if (Pact.some((prop) => !propertyPasses(alternative[JsonPointer.append(prop, instanceLocation)]), discriminators)) { + continue; + } } + + filtered.push(alternative); + } + + if (filtered.length === 0) { + filtered = anyOf; } + /** @type ErrorObject[][] */ + const alternatives = []; + if (alternatives.length === 0) { - for (const alternative of anyOf) { + for (const alternative of filtered) { alternatives.push(await getErrors(alternative, instance, localization)); } } @@ -39,8 +86,8 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { } else { errors.push({ message: localization.getAnyOfErrorMessage(), - alternatives: alternatives, - instanceLocation: Instance.uri(instance), + alternatives, + instanceLocation, schemaLocations: [schemaLocation] }); } @@ -49,4 +96,12 @@ const anyOfErrorHandler = async (normalizedErrors, instance, localization) => { return errors; }; +/** @type (propOutput: NormalizedOutput[string] | undefined) => boolean */ +const propertyPasses = (propOutput) => { + if (!propOutput) { + return false; + } + return Object.values(propOutput).every((keywordResults) => Object.values(keywordResults).every((v) => v === true)); +}; + export default anyOfErrorHandler; diff --git a/src/error-handlers/oneOf.js b/src/error-handlers/oneOf.js index 82b15d7..fa78ee3 100644 --- a/src/error-handlers/oneOf.js +++ b/src/error-handlers/oneOf.js @@ -1,8 +1,10 @@ import * as Instance from "@hyperjump/json-schema/instance/experimental"; +import * as JsonPointer from "@hyperjump/json-pointer"; +import * as Pact from "@hyperjump/pact"; import { getErrors } from "../json-schema-errors.js"; /** - * @import { ErrorHandler, ErrorObject } from "../index.d.ts" + * @import { ErrorHandler, ErrorObject, NormalizedOutput } from "../index.d.ts" */ /** @type ErrorHandler */ @@ -16,48 +18,128 @@ const oneOfErrorHandler = async (normalizedErrors, instance, localization) => { continue; } - const alternatives = []; const instanceLocation = Instance.uri(instance); + let matchCount = 0; + /** @type ErrorObject[][] */ + const failingAlternatives = []; for (const alternative of oneOf) { - const typeErrors = alternative[instanceLocation]["https://json-schema.org/keyword/type"]; - const match = !typeErrors || Object.values(typeErrors).every((isValid) => isValid); + const alternativeErrors = await getErrors(alternative, instance, localization); + if (alternativeErrors.length) { + failingAlternatives.push(alternativeErrors); + } else { + matchCount++; + } + } - if (match) { - const alternativeErrors = await getErrors(alternative, instance, localization); - if (alternativeErrors.length) { - alternatives.push(alternativeErrors); - } else { - matchCount++; + if (matchCount > 1) { + /** @type ErrorObject */ + const error = { + message: localization.getOneOfErrorMessage(matchCount), + instanceLocation, + schemaLocations: [schemaLocation] + }; + if (failingAlternatives.length) { + error.alternatives = failingAlternatives; + } + errors.push(error); + continue; + } + + let filtered = oneOf; + + const isObject = Instance.typeOf(instance) === "object"; + const instanceProps = isObject + ? Pact.collectSet(Pact.pipe(Instance.keys(instance), Pact.map((keyNode) => /** @type {string} */ (Instance.value(keyNode))))) + : undefined; + const prefix = `${instanceLocation}/`; + + filtered = []; + for (const alternative of oneOf) { + const typeResults = alternative[instanceLocation]["https://json-schema.org/keyword/type"]; + if (typeResults && !Object.values(typeResults).every((isValid) => isValid)) { + continue; + } + + if (isObject) { + const declaredProps = Pact.collectSet(Pact.pipe( + Object.keys(alternative), + Pact.filter((loc) => loc.startsWith(prefix)), + Pact.map((loc) => /** @type {string} */ (Pact.head(JsonPointer.pointerSegments(loc.slice(prefix.length - 1))))) + )); + + if (declaredProps.size > 0 && !Pact.some((prop) => declaredProps.has(prop), /** @type {Set} */ (instanceProps))) { + continue; } } + + filtered.push(alternative); + } + + if (filtered.length === 0) { + filtered = oneOf; } - if (matchCount === 0 && alternatives.length === 0) { - for (const alternative of oneOf) { + /** @type ErrorObject[][] */ + const alternatives = []; + + if (isObject) { + const discriminators = Pact.collectSet( + /** @type {Iterable} */ (Pact.pipe( + filtered, + Pact.map((alternative) => Pact.pipe( + /** @type {Set} */ (instanceProps), + Pact.filter((prop) => propertyPasses(alternative[JsonPointer.append(prop, instanceLocation)])) + )), + Pact.flatten + )) + ); + + for (const alternative of filtered) { + if (!Pact.some((prop) => !propertyPasses(alternative[JsonPointer.append(prop, instanceLocation)]), discriminators)) { + const alternativeErrors = await getErrors(alternative, instance, localization); + if (alternativeErrors.length) { + alternatives.push(alternativeErrors); + } + } + } + } + + if (alternatives.length === 0) { + for (const alternative of filtered) { const alternativeErrors = await getErrors(alternative, instance, localization); - alternatives.push(alternativeErrors); + if (alternativeErrors.length) { + alternatives.push(alternativeErrors); + } } } - if (alternatives.length === 1 && matchCount === 0) { + if (alternatives.length === 1) { errors.push(...alternatives[0]); } else { /** @type ErrorObject */ - const alternativeErrors = { - message: localization.getOneOfErrorMessage(matchCount), - instanceLocation: Instance.uri(instance), + const error = { + message: localization.getOneOfErrorMessage(0), + instanceLocation, schemaLocations: [schemaLocation] }; if (alternatives.length) { - alternativeErrors.alternatives = alternatives; + error.alternatives = alternatives; } - errors.push(alternativeErrors); + errors.push(error); } } return errors; }; +/** @type (propOutput: NormalizedOutput[string] | undefined) => boolean */ +const propertyPasses = (propOutput) => { + if (!propOutput) { + return false; + } + return Object.values(propOutput).every((keywordResults) => Object.values(keywordResults).every((v) => v === true)); +}; + export default oneOfErrorHandler; diff --git a/src/test-suite/tests/anyOf.json b/src/test-suite/tests/anyOf.json index 93ee92e..1bf82c8 100644 --- a/src/test-suite/tests/anyOf.json +++ b/src/test-suite/tests/anyOf.json @@ -207,6 +207,185 @@ "schemaLocations": ["#/$defs/bar/maxLength"] } ] + }, + { + "description": "anyOf object alternatives keep only one branch when only one branch has a passing instance property", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "a": { "type": "string" } + }, + "required": ["type", "a"] + }, + { + "type": "object", + "properties": { + "type": { "const": "b" }, + "b": { "type": "number" } + }, + "required": ["type", "b"] + } + ] + }, + "instance": { "type": "b", "b": "oops" }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": "number" + }, + "instanceLocation": "#/b", + "schemaLocations": ["#/anyOf/1/properties/b/type"] + } + ] + }, + { + "description": "anyOf object alternatives keep both branches when each branch has at least one passing instance property", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "x": { "type": "string" } + }, + "required": ["type", "x"] + }, + { + "type": "object", + "properties": { + "type": { + "allOf": [ + { "type": "string" }, + { "minLength": 2 } + ] + }, + "x": { "type": "number" } + }, + "required": ["type", "x"] + } + ] + }, + "instance": { "type": "a", "x": 42 }, + "errors": [ + { + "messageId": "anyOf-message", + "alternatives": [ + [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": "string" + }, + "instanceLocation": "#/x", + "schemaLocations": ["#/anyOf/0/properties/x/type"] + } + ], + [ + { + "messageId": "minLength-message", + "messageParams": { + "minLength": "2" + }, + "instanceLocation": "#/type", + "schemaLocations": ["#/anyOf/1/properties/type/allOf/1/minLength"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/anyOf"] + } + ] + }, + { + "description": "anyOf object alternatives filters to matching branch by declared property intersection", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "a": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": "string" + }, + "instanceLocation": "#/a", + "schemaLocations": ["#/anyOf/0/properties/a/type"] + } + ] + }, + { + "description": "anyOf object alternatives fallback to all when no instance property passes in any branch", + "compatibility": "6", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { "const": "a" } + }, + "required": ["kind"] + }, + { + "type": "object", + "properties": { + "kind": { "const": "b" } + }, + "required": ["kind"] + } + ] + }, + "instance": { "kind": "c" }, + "errors": [ + { + "messageId": "anyOf-message", + "alternatives": [ + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"a\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/anyOf/0/properties/kind/const"] + } + ], + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"b\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/anyOf/1/properties/kind/const"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/anyOf"] + } + ] } ] } diff --git a/src/test-suite/tests/oneOf.json b/src/test-suite/tests/oneOf.json index 94743ef..7072b89 100644 --- a/src/test-suite/tests/oneOf.json +++ b/src/test-suite/tests/oneOf.json @@ -272,6 +272,191 @@ ] } ] + }, + { + "description": "oneOf object alternatives keep only one branch when only one branch has a passing instance property", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "a": { "type": "string" } + }, + "required": ["type", "a"] + }, + { + "type": "object", + "properties": { + "type": { "const": "b" }, + "b": { "type": "number" } + }, + "required": ["type", "b"] + } + ] + }, + "instance": { "type": "b", "b": "oops" }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": "number" + }, + "instanceLocation": "#/b", + "schemaLocations": ["#/oneOf/1/properties/b/type"] + } + ] + }, + { + "description": "oneOf object alternatives filters to matching branch by declared property intersection", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "type": "object", + "properties": { + "b": { "type": "string" } + }, + "required": ["b"] + } + ] + }, + "instance": { "a": 42 }, + "errors": [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": "string" + }, + "instanceLocation": "#/a", + "schemaLocations": ["#/oneOf/0/properties/a/type"] + } + ] + }, + { + "description": "oneOf object alternatives fallback to all when no instance property passes in any branch", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "const": "a" } + }, + "required": ["kind"] + }, + { + "type": "object", + "properties": { + "kind": { "const": "b" } + }, + "required": ["kind"] + } + ] + }, + "instance": { "kind": "c" }, + "errors": [ + { + "messageId": "oneOf-message", + "messageParams": { + "matchCount": 0 + }, + "alternatives": [ + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"a\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/oneOf/0/properties/kind/const"] + } + ], + [ + { + "messageId": "const-message", + "messageParams": { + "expected": "\"b\"" + }, + "instanceLocation": "#/kind", + "schemaLocations": ["#/oneOf/1/properties/kind/const"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/oneOf"] + } + ] + }, + { + "description": "oneOf object alternatives keep both branches when each branch has at least one passing instance property", + "compatibility": "6", + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "const": "a" }, + "x": { "type": "string" } + }, + "required": ["type", "x"] + }, + { + "type": "object", + "properties": { + "type": { + "allOf": [ + { "type": "string" }, + { "minLength": 2 } + ] + }, + "x": { "type": "number" } + }, + "required": ["type", "x"] + } + ] + }, + "instance": { "type": "a", "x": 42 }, + "errors": [ + { + "messageId": "oneOf-message", + "messageParams": { + "matchCount": 0 + }, + "alternatives": [ + [ + { + "messageId": "type-message", + "messageParams": { + "expectedTypes": "string" + }, + "instanceLocation": "#/x", + "schemaLocations": ["#/oneOf/0/properties/x/type"] + } + ], + [ + { + "messageId": "minLength-message", + "messageParams": { + "minLength": "2" + }, + "instanceLocation": "#/type", + "schemaLocations": ["#/oneOf/1/properties/type/allOf/1/minLength"] + } + ] + ], + "instanceLocation": "#", + "schemaLocations": ["#/oneOf"] + } + ] } ] }