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
JavaScript
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