UNPKG

json-schema-describes-subset

Version:

Tools for static JSON schema analysis, including functions to determine if one schema describes a subset of another or if a schema describes the empty set or to convert a schema to its disjunctive normal form (DNF).

477 lines 17.1 kB
import pick from 'lodash/pick.js'; import {} from '../json-schema/index.js'; import { getSimplificationPluginsForTypeFromInternalOptions, toInternalOptions, } from '../options/index.js'; import { isConstSchema, } from '../plugin/index.js'; import { groupLogicLiteralConjuncts } from './group-logic-literal-conjuncts.js'; import { splitToRawDNF } from '../atomic-schema/split/index.js'; import { resolveSchemaArgumentsIds } from '../id/index.js'; import { schemaDescribesSubsetFactory } from '../schema-describes-subset/schema-describes-subset-factory.js'; import { forEachElementCombination } from '../utils/array/index.js'; export function createValidateConst(options, schema) { return (constValue) => options.validate(schema, constValue) ? { const: constValue } : false; } /** * Brings the given disjunct of a {@link RawDNF} to a simpler canonical form by * summarizing keywords. Returns `false` (which is a valid JSON Schema) if there * is a contradiction in the disjunct. * */ export function simplifyDisjunct({ type, atomicSchemasByConstructor, negatedAtomicSchemasByConstructor, options, validateConst, }) { const simplificationPlugins = getSimplificationPluginsForTypeFromInternalOptions(options, type); let simplificationResultSchema = { type, }; for (const { simplify, mergeableKeywords } of simplificationPlugins) { const nextSimplificationResultSchema = simplify({ type, atomicSchemasByConstructor, negatedAtomicSchemasByConstructor, previousSimplificationResultSchema: simplificationResultSchema, options, schemaDescribesEmptySet: (schema) => schemaDescribesEmptySetInternal(schema, options), splitToRawDNF: (schema) => splitToRawDNF(schema, options), validateConst, }); if (nextSimplificationResultSchema === false) { return false; } if (nextSimplificationResultSchema === true) { continue; } if (isConstSchema(nextSimplificationResultSchema)) { return nextSimplificationResultSchema; } simplificationResultSchema = { ...simplificationResultSchema, ...pick(nextSimplificationResultSchema, ...mergeableKeywords), allOf: [ ...(simplificationResultSchema.allOf ?? []), ...(nextSimplificationResultSchema.allOf ?? []), ], }; } if (simplificationResultSchema.allOf?.length === 0) { delete simplificationResultSchema.allOf; } return simplificationResultSchema; } export function isJSONSchemaTypeForDNF(type) { return type !== 'null' && type !== 'boolean'; } /** * @param rawDisjunct Represents multiple disjuncts, because there might be * multiple types * */ export function simplifyRawDisjunct(rawDisjunct, validateConst, options) { const { booleanSchema, atomicSchemasByConstructor, negatedAtomicSchemasByConstructor, types, } = groupLogicLiteralConjuncts(rawDisjunct.allOf); if (booleanSchema !== undefined) { return [booleanSchema]; } /* filter to exclude 'null' and 'boolean' to make DNF more canonical by * instead using `{ const: null | boolean}` */ return types.filter(isJSONSchemaTypeForDNF).map((type) => simplifyDisjunct({ type, atomicSchemasByConstructor, negatedAtomicSchemasByConstructor, options, validateConst, })); } //#endregion DefaultDNFSpelledOut /** * Sort const schemas to the start of the array (mainly for aesthetic reasons) * */ export function sortCompareDisjuncts(...disjuncts) { const [disjunctAIsConstSchema, disjunctBIsConstSchema] = disjuncts.map(isConstSchema); if (disjunctAIsConstSchema === disjunctBIsConstSchema) { return 0; } return disjunctAIsConstSchema ? -1 : 1; } function isNotBoolean(value) { return typeof value !== 'boolean'; } /** * Removes unnecessary disjuncts (if they are subsets of other disjuncts) * */ export function removeSubsetDisjuncts(disjuncts, schemaDescribesSubset) { const disjunctsWithSubsetsSetToFalse = [...disjuncts]; forEachElementCombination(disjunctsWithSubsetsSetToFalse, (schemaI, schemaJ, i, j, array) => { if (schemaDescribesSubset(schemaJ, schemaI)) { array[j] = false; } else if (schemaDescribesSubset(schemaI, schemaJ)) { array[i] = false; } }); return disjunctsWithSubsetsSetToFalse.filter(isNotBoolean); } /** * Removes unnecessary conjuncts (if they are supersets of other conjuncts) * */ export function removeSupersetConjuncts(conjuncts, schemaDescribesSubset) { const conjunctsWithSupersetsSetToTrue = [...conjuncts]; forEachElementCombination(conjunctsWithSupersetsSetToTrue, (schemaI, schemaJ, i, j, array) => { if (schemaDescribesSubset(schemaJ, schemaI)) { array[i] = true; } else if (schemaDescribesSubset(schemaI, schemaJ)) { array[j] = true; } }); return conjunctsWithSupersetsSetToTrue.filter(isNotBoolean); } /** * Transforms the given schema to a [disjunctive normal * form](https://en.wikipedia.org/wiki/Disjunctive_normal_form) similar to the * one utilized by {@link schemaDescribesEmptySet}. * * @returns The resulting dnf schema will be equivalent to the provided schema (meaning * that it will accept the same data values) but all * [boolean combinations](https://json-schema.org/understanding-json-schema/reference/combining) * will be restructured. * * Subschemas that represent property values of a JSON object or elements of a * JSON array do not represent boolean combinations. They are currently * considered atomic for that purpose. * * The resulting dnf schema will be simplified so that disjuncts that were determined * to be unsatisfiable are already eliminated. If each disjunct was determined * to be unsatisfiable the return value is `false`. * * The return type's most general form (without specified * {@link Options.plugins | plugin} types, for example returned by * `toDNF<Options>(...)`) is equivalent to: * * ```ts * type GeneralDNFSpelledOut = * | boolean * | { * anyOf: ( * | { const: unknown } * | { * [mergeableKeyword: string]: unknown * type: 'string' | 'number' | 'object' | 'array' * allOf?: JSONSchema[] * const?: never * anyOf?: never * not?: never * } * )[] * } * ``` * * If the provided option's type does not contain any custom * {@link Options.plugins | plugins}, the default return type (for example * returned by `toDNF(schema)` (without options) or by * `toDNF<{ plugins: [] }>(...)`) is equivalent to: * * ```ts * type DefaultDNFSpelledOut = * | boolean * | { * anyOf: ( * | { const: unknown } * | { * type: 'number' * maximum?: number * minimum?: number * multipleOf?: number * allOf?: ( * | { not: { const: number } } * | { not: { multipleOf: number } } * | { $ref: string } * | { not: { $ref: string } } * )[] * const?: never * anyOf?: never * not?: never * } * | { * type: 'string' * maxLength?: number * minLength?: number * allOf?: ( * | { not: { const: string } } * | { pattern: string } * | { not: { pattern: string } } * | { $ref: string } * | { not: { $ref: string } } * )[] * const?: never * anyOf?: never * not?: never * } * | { * type: 'object' * maxProperties?: number * minProperties?: number * patternProperties?: Record<string, JSONSchema> * properties?: Record<string, JSONSchema> * propertyNames?: JSONSchema * required?: string[] * allOf?: ( * | { not: { const: Record<string, unknown> } } * | { * additionalProperties: JSONSchema * properties?: Record<string, true> * patternProperties?: Record<string, true> * } * | { not: { patternProperties: Record<string, JSONSchema> } } * | { * not: { * additionalProperties: JSONSchema * properties?: Record<string, true> * patternProperties?: Record<string, true> * } * } * | { not: { propertyNames: JSONSchema } } * | { $ref: string } * | { not: { $ref: string } } * )[] * const?: never * anyOf?: never * not?: never * } * | { * type: 'array' * items?: JSONSchema * maxItems?: number * minItems?: number * prefixItems?: JSONSchema[] * uniqueItems?: boolean * allOf?: ( * | { not: { const: unknown[] } } * | { * contains: JSONSchema * minContains?: number * maxContains?: number * } * | { * not: { uniqueItems?: boolean } * } * | { * not: { * prefixItems?: true[] * items?: JSONSchema * } * } * | { $ref: string } * | { not: { $ref: string } } * )[] * const?: never * anyOf?: never * not?: never * } * )[] * } * ``` * * The return type will adjust according to the (explicit or inferred) type of * the property `plugins` of the provided `options`. * * @example ```typescript * import { toDNF } from 'json-schema-describes-subset' * * console.log( * toDNF({ * anyOf: [{ minimum: 2 }, { exclusiveMinimum: 1 }], * }), * ) * ``` * * logs: * * ```json * { * "anyOf": [ * { "const": null }, * { "const": true }, * { "const": false }, * { "type": "number", "minimum": 1, "allOf": [{ "not": { "const": 1 } }] }, * { "type": "string" }, * { "type": "array" }, * { "type": "object" } * ] * } * ``` * * *** * * ```typescript * import { toDNF } from 'json-schema-describes-subset' * * console.log( * toDNF({ * anyOf: [{ multipleOf: 2 }, { multipleOf: 3 }, { multipleOf: 4 }], * }), * ) * ``` * * logs: * * ```json * { * "anyOf": [ * { "const": null }, * { "const": true }, * { "const": false }, * { "type": "number", "multipleOf": 2 }, * { "type": "number", "multipleOf": 3 }, * { "type": "string" }, * { "type": "array" }, * { "type": "object" } * ] * } * ``` * * @remarks Use cases * * This function was created mainly for demonstration purposes, but might also * have some real world use cases. For example when creating a data mocking * tool, that generates example data for a given schema, it might be easier to * generate that data for one of the logically flat disjuncts instead of a * complex schema which is logically deeply nested. * */ export function toDNF(schema, options) { const resolved = resolveSchemaArgumentsIds([schema], options); const internalOptions = toInternalOptions(resolved.options); const resolvedSchema = resolved.schemas[0]; const rawDNF = splitToRawDNF(resolvedSchema, internalOptions); const validateConst = createValidateConst(internalOptions, resolvedSchema); const anyOf = /* check all null and boolean values (there are only three in total) once * in advance, so they don't need to be checked later */ [null, true, false].map(validateConst).filter(isNotBoolean); for (const rawDisjunct of rawDNF.anyOf) { const disjuncts = simplifyRawDisjunct(rawDisjunct, validateConst, internalOptions); if (disjuncts.includes(true)) { return true; } const nonBooleanDisjuncts = disjuncts.filter(isNotBoolean); anyOf.push(...nonBooleanDisjuncts); } if (anyOf.length === 0) { return false; } anyOf.sort(sortCompareDisjuncts); const schemaDescribesSubset = schemaDescribesSubsetFactory((schema) => schemaDescribesEmptySetInternal(schema, internalOptions)); return { /* TODO: maybe make removing of unnecessary disjuncts / conjuncts optional, * in some cases it might more desirable to have more explicit disjuncts * ??? */ anyOf: removeSubsetDisjuncts(anyOf, schemaDescribesSubset).map((disjunct) => isConstSchema(disjunct) || disjunct.allOf === undefined ? disjunct : { ...disjunct, allOf: removeSupersetConjuncts(disjunct.allOf, schemaDescribesSubset), }), }; } export function rawDisjunctDescribesEmptySet(rawDisjunct, validateConst, options) { const { booleanSchema, atomicSchemasByConstructor, negatedAtomicSchemasByConstructor, types, } = groupLogicLiteralConjuncts(rawDisjunct.allOf); if (booleanSchema !== undefined) { return !booleanSchema; } for (const type of types) { // TODO: maybe add an option that says whether we need the full result? // A `null` return would be sufficient, if we are only interested whether // contradictions could be found const resultSchema = simplifyDisjunct({ type, atomicSchemasByConstructor, negatedAtomicSchemasByConstructor, options, validateConst, }); if (resultSchema === false) { continue; } if (isConstSchema(resultSchema)) { return false; } return null; } return true; } export function rawDNFDescribesEmptySet(rawDNF, validateConst, options) { const subResults = rawDNF.anyOf.map((rawDisjunct) => rawDisjunctDescribesEmptySet(rawDisjunct, validateConst, options)); if (subResults.every((result) => result === true)) { /* all disjuncts are unsatisfiable */ return true; } if (subResults.includes(false)) { /* one disjunct is definitely satisfiable => true negative */ return false; } /* (possibly false) negative */ return null; } /** * Tries to determine whether the provided JSON Schema is unsatisfiable and * therefore describes the empty set. In that case, the schema would be * equivalent to the `false` schema. * * @returns Returns `true` if it does find a reason why the schema will not accept any * value. * * If such a reason cannot be found, usually `null` is returned to indicate the * possibility of false negatives. * * The true positive `false` return value is currently only returned if an * example data value that satisfies the schema can be trivially found. * See [Limitations](https://github.com/jobohner/json-schema-describes-subset/blob/v0.4.0/src/dnf/dnf.ts#limitations) for more details. * * @example ```ts * import { schemaDescribesEmptySet } from 'json-schema-describes-subset' * * console.log(schemaDescribesEmptySet(false)) // logs: `true` * * console.log( * schemaDescribesEmptySet( * // this schema will accept anything that is not a number * { minimum: 2, maximum: 1 }, * ), * ) // logs: `false` * * console.log( * schemaDescribesEmptySet({ * type: 'number', * minimum: 2, * maximum: 1, * }), * ) // logs: `true` * ``` * * @remarks How does this work? * * The provided schema is first transformed to a [disjunctive normal * form](https://en.wikipedia.org/wiki/Disjunctive_normal_form) similar to the * one returned by {@link toDNF}. Then each disjunct is checked for * contradictions which would make it unsatisfiable. If a contradiction is found * for each disjunct, the complete schema is unsatisfiable and `true` is * returned. * */ export function schemaDescribesEmptySet(schema, options) { const resolved = resolveSchemaArgumentsIds([schema], options); const internalOptions = toInternalOptions(resolved.options); return schemaDescribesEmptySetInternal(...resolved.schemas, internalOptions); } export function schemaDescribesEmptySetInternal(schema, options) { /* check all null and boolean values once * in advance, so they don't need to be checked later */ if ([null, true, false].some((value) => { return options.validate(schema, value); })) { return false; } const rawDNF = splitToRawDNF(schema, options); return rawDNFDescribesEmptySet(rawDNF, createValidateConst(options, schema), options); } //# sourceMappingURL=dnf.js.map