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).

458 lines 18.7 kB
import isArray from 'lodash/isArray.js'; import { isJSONSchema } from '../json-schema/index.js'; import { AllOfSchema, AnyOfSchema, AtomicSchemaObject, NotSchema, } from '../atomic-schema/index.js'; import { negateIntegerMaximum, negateIntegerMinimum, } from '../utils/negate-integer-min-max/index.js'; import { TypeAtomicSchema } from './type.js'; import { schemaDescribesSubsetFactory } from '../schema-describes-subset/schema-describes-subset-factory.js'; import { createSameElementsArray } from '../utils/array/index.js'; import { isArrayOf, isNotNull } from '../utils/type-guards/index.js'; import { UnsupportedKeywordError } from '../unsupported-keyword-error/index.js'; import { getUniqueNegatedConstSchemasFromNegatedAtomicSchemasByConstructor } from './negated-const-helpers.js'; export function createTrueArray(length) { return createSameElementsArray(length, true); } export class MinItemsAtomicSchema extends AtomicSchemaObject { minItems; constructor(minItems) { super(); this.minItems = minItems; } negate() { return new AllOfSchema([ new MaxItemsAtomicSchema(negateIntegerMinimum(this.minItems)), new TypeAtomicSchema('array'), ]); } toJSONSchema() { return { minItems: this.minItems }; } } export class MaxItemsAtomicSchema extends AtomicSchemaObject { maxItems; constructor(maxItems) { super(); this.maxItems = maxItems; } negate() { return new AllOfSchema([ new MinItemsAtomicSchema(negateIntegerMaximum(this.maxItems)), new TypeAtomicSchema('array'), ]); } toJSONSchema() { return { maxItems: this.maxItems }; } } export class ItemsAtomicSchema extends AtomicSchemaObject { startIndex; items; constructor(startIndex, items) { super(); this.startIndex = startIndex; this.items = items; } negate() { return new AllOfSchema([ new NotSchema(this), /* actually redundant, but makes checking for contradictions easier */ new TypeAtomicSchema('array'), ]); } toJSONSchema() { if (this.startIndex === 0) { return { items: this.items }; } return { prefixItems: createTrueArray(this.startIndex), items: this.items, }; } } export class PrefixItemAtomicSchema extends AtomicSchemaObject { index; prefixItem; constructor(index, prefixItem) { super(); this.index = index; this.prefixItem = prefixItem; } negate() { return new AllOfSchema([ new TypeAtomicSchema('array'), new MinItemsAtomicSchema(this.index + 1), new PrefixItemAtomicSchema(this.index, { not: this.prefixItem }), ]); } toJSONSchema() { return { prefixItems: [...createTrueArray(this.index), this.prefixItem], }; } } export class ContainsAtomicSchema extends AtomicSchemaObject { contains; minContains; maxContains; constructor(contains, minContains, maxContains) { super(); this.contains = contains; this.minContains = Math.ceil(minContains); this.maxContains = Math.floor(maxContains); } negate() { if (this.minContains > this.maxContains) { return true; } if (this.minContains <= 0 && this.maxContains === Infinity) { return false; } const anyOf = [ this.maxContains < Infinity && new ContainsAtomicSchema(this.contains, this.maxContains + 1, Infinity), this.minContains > 0 && new ContainsAtomicSchema(this.contains, 0, this.minContains - 1), ].filter((schema) => schema !== false); return new AllOfSchema([ new TypeAtomicSchema('array'), ...(anyOf.length > 1 ? [new AnyOfSchema(anyOf)] : anyOf), ]); } toJSONSchema() { return { contains: this.contains, minContains: this.minContains, ...(this.maxContains < Infinity ? { maxContains: this.maxContains } : {}), }; } } export class UniqueItemsAtomicSchema extends AtomicSchemaObject { negate() { return new AllOfSchema([ new NotSchema(this), /* actually redundant, but makes checking for contradictions easier */ new TypeAtomicSchema('array'), ]); } toJSONSchema() { return { uniqueItems: true }; } } export class UnevaluatedItemsAtomicSchema extends AtomicSchemaObject { negate() { return new AllOfSchema([ new NotSchema(this), /* actually redundant, but makes checking for contradictions easier */ new TypeAtomicSchema('array'), ]); } toJSONSchema() { throw new Error('unimplemented'); } } export const arrayExtraction = { extract: ({ schema: { minItems, maxItems, items, prefixItems, contains, minContains, maxContains, uniqueItems, unevaluatedItems, }, }) => { const allOf = []; if (typeof minItems === 'number') { allOf.push(new MinItemsAtomicSchema(minItems)); } if (typeof maxItems === 'number') { allOf.push(new MaxItemsAtomicSchema(maxItems)); } const prefixItemsIsArray = isArrayOf(isJSONSchema)(prefixItems); if (isJSONSchema(items)) { allOf.push(new ItemsAtomicSchema(prefixItemsIsArray ? prefixItems.length : 0, items)); } if (prefixItemsIsArray) { allOf.push(...prefixItems.map((prefixItem, index) => new PrefixItemAtomicSchema(index, prefixItem))); } if (isJSONSchema(contains)) { allOf.push(new ContainsAtomicSchema(contains, typeof minContains === 'number' ? minContains : 1, typeof maxContains === 'number' ? maxContains : Infinity)); } if (uniqueItems === true) { allOf.push(new UniqueItemsAtomicSchema()); } if (unevaluatedItems !== undefined) { allOf.push(new UnevaluatedItemsAtomicSchema()); } return allOf.length === 0 || new AllOfSchema(allOf); }, }; export function mergeItemsSchemas(prefixItems, itemsArray) { const mergedItems = []; for (const { index, prefixItem } of prefixItems) { const allOf = mergedItems[index]; if (allOf !== undefined) { allOf.push(prefixItem); } else { while (mergedItems.length < index) { mergedItems.push([]); } mergedItems.push([prefixItem]); } } for (const { startIndex, items } of itemsArray) { for (const allOf of mergedItems.slice(startIndex)) { allOf.push(items); } } return mergedItems; } export const arraySimplification = { appliesToJSONSchemaType: 'array', mergeableKeywords: [ 'items', 'prefixItems', 'minItems', 'maxItems', 'uniqueItems', ], simplify({ atomicSchemasByConstructor, negatedAtomicSchemasByConstructor, schemaDescribesEmptySet, validateConst, }) { let minItems = Math.ceil(Math.max(0, ...atomicSchemasByConstructor .get(MinItemsAtomicSchema) .map(({ minItems }) => minItems))); const maxItems = Math.floor(Math.min(...atomicSchemasByConstructor .get(MaxItemsAtomicSchema) .map(({ maxItems }) => maxItems))); if (minItems > maxItems) { return false; } if (maxItems <= 0) { return validateConst([]); } const uniqueItems = atomicSchemasByConstructor.has(UniqueItemsAtomicSchema); const negatedUniqueItems = negatedAtomicSchemasByConstructor.has(UniqueItemsAtomicSchema); if (uniqueItems && negatedUniqueItems) { return false; } const itemsAtomicSchemas = atomicSchemasByConstructor.get(ItemsAtomicSchema); const mergedItems = mergeItemsSchemas(atomicSchemasByConstructor.get(PrefixItemAtomicSchema), itemsAtomicSchemas); const nonPrefixItems = itemsAtomicSchemas.map(({ items }) => items); if (minItems > mergedItems.length && nonPrefixItems.length > 0) { if (schemaDescribesEmptySet({ allOf: nonPrefixItems })) { return false; } } for (const allOf of mergedItems.slice(0, minItems)) { if (schemaDescribesEmptySet({ allOf })) { return false; } } /** * Includes `contains` and negated `items` * */ const allContains = [ ...atomicSchemasByConstructor .get(ContainsAtomicSchema) .map(({ contains, minContains, maxContains }) => ({ contains, minContains, maxContains, startIndex: 0, })), ...negatedAtomicSchemasByConstructor .get(ItemsAtomicSchema) .map(({ items, startIndex }) => ({ contains: { not: items }, minContains: 1, maxContains: Infinity, startIndex, })), ]; minItems = Math.max(minItems, ...allContains.map(({ minContains, startIndex }) => startIndex + minContains)); if (minItems > maxItems) { return false; } if (allContains.some(({ minContains, maxContains }) => minContains > maxContains)) { return false; } const schemaDescribesSubset = schemaDescribesSubsetFactory(schemaDescribesEmptySet); /* compare `allContains` against each other */ for (const containsA of allContains) { for (const containsB of allContains) { if (containsA === containsB) { continue; } if (schemaDescribesSubset(containsA.contains, containsB.contains)) { /* every value that satisfies A, also satisfies B => minContains / * maxContains for B must also apply to A */ containsA.minContains = Math.max(containsA.minContains, containsB.minContains); containsA.maxContains = Math.min(containsA.maxContains, containsB.maxContains); if (containsA.minContains > containsA.maxContains) { return false; } } } } /** * Result for these might have changed with `minContainsLoop`, since * nonPrefixItems or mergedItems might have changed => check again * */ const checkMinContains = allContains.filter(({ minContains }) => minContains > 0); while (checkMinContains.length > 0) { const checkMinContainsAgain = []; /* check `minContains` contradictions */ minContainsLoop: for (const containsObject of checkMinContains) { const { contains, minContains, startIndex } = containsObject; /* check against (non prefix) items, no need to compare with * `startIndex`, since startIndex is always >= mergedItems.length */ const possibleNonPrefixItemsCount = (() => { const possibleNonPrefixItemsCount = maxItems - mergedItems.length; if (possibleNonPrefixItemsCount <= 0 || schemaDescribesEmptySet({ allOf: [contains, ...nonPrefixItems], })) { return 0; } return possibleNonPrefixItemsCount; })(); if (possibleNonPrefixItemsCount > minContains) { /* all necessary contains fit within non prefix items and since * there are more `possibleNonPrefixItemsCount` than `minContains`, * we cannot conclude that any non prefix item must also satisfy * `contains` */ checkMinContainsAgain.push(containsObject); continue; } /* check against prefixItems */ const possiblePrefixItems = []; for (const prefixItemsAllOf of mergedItems.slice(startIndex)) { if (!schemaDescribesEmptySet({ allOf: [contains, ...prefixItemsAllOf] })) { possiblePrefixItems.push(prefixItemsAllOf); if (possibleNonPrefixItemsCount + possiblePrefixItems.length > minContains) { checkMinContainsAgain.push(containsObject); continue minContainsLoop; } } } if (possibleNonPrefixItemsCount + possiblePrefixItems.length < minContains) { /* cannot contain enough `contains` */ return false; } /* * `possibleNonPrefixItemsCount + possiblePrefixItems.length === * minContains`, since `<` and `>` are checked above * can contain exactly the minimum necessary amount of `contains`, all * schemas at the relevant indexes must always also satisfy `contains` */ /* * Checking every possible combination is too computationally intensive, * so this might result in false negatives. */ possiblePrefixItems.every((allOf) => allOf.push(contains)); if (possibleNonPrefixItemsCount > 0) { nonPrefixItems.push(contains); minItems = Math.max(minItems, mergedItems.length + possibleNonPrefixItemsCount); } } if (checkMinContainsAgain.length === checkMinContains.length) { break; } checkMinContains.length = 0; checkMinContains.push(...checkMinContainsAgain); } /* check maxContains against mergedItems and itemsAtomcSchemas */ for (const { contains, maxContains } of allContains) { if (maxContains === Infinity) { continue; } let matchingItemsCount = 0; const matchingNonPrefixItemsCount = minItems - mergedItems.length; if (matchingNonPrefixItemsCount > 0 && schemaDescribesSubset({ allOf: nonPrefixItems }, contains)) { matchingItemsCount += matchingNonPrefixItemsCount; } if (matchingItemsCount > maxContains) { return false; } for (const allOf of mergedItems.slice(0, minItems)) { if (schemaDescribesSubset({ allOf }, contains)) { matchingItemsCount++; if (matchingItemsCount > maxContains) { return false; } } } } if (negatedUniqueItems) { /* if maxItems > mergedItems.length + 1, there are multiple elements that * only need to satisfy `items` and therefore might overlap => not * necessarily a contradiction */ if (maxItems <= mergedItems.length + 1) { const requiredItems = mergedItems.slice(0, maxItems); if (maxItems > mergedItems.length) { requiredItems.push(nonPrefixItems.length > 0 ? nonPrefixItems : [true]); } const previouslyCheckedItems = []; if (requiredItems.every((itemSchemas) => { if (previouslyCheckedItems.every((previouslyCheckedItemSchemas) => { return schemaDescribesEmptySet({ allOf: [...previouslyCheckedItemSchemas, ...itemSchemas], }); })) { previouslyCheckedItems.push(itemSchemas); return true; } return false; })) { /* no overlap between items => items are always unique */ return false; } } } const prefixItems = mergedItems .map((allOf) => { const [first, ...rest] = allOf; if (first === undefined) { return null; } if (rest.length === 0) { return first; } return { allOf }; }) .filter(isNotNull); const unevaluatedItemsAtomicSchema = atomicSchemasByConstructor.get(UnevaluatedItemsAtomicSchema)[0] ?? negatedAtomicSchemasByConstructor.get(UnevaluatedItemsAtomicSchema)[0]; if (unevaluatedItemsAtomicSchema !== undefined) { throw new UnsupportedKeywordError('unevaluatedItems'); } return { ...(minItems > 0 ? { minItems } : {}), ...(maxItems < Infinity ? { maxItems } : {}), ...(uniqueItems ? { uniqueItems } : {}), ...(prefixItems.length > 0 ? { prefixItems } : {}), ...(nonPrefixItems.length > 0 ? { items: { allOf: nonPrefixItems } } : {}), allOf: [ ...(negatedUniqueItems ? [{ not: { uniqueItems: true } }] : []), ...atomicSchemasByConstructor .get(ContainsAtomicSchema) .map(({ contains, minContains, maxContains }) => ({ contains, ...(minContains > 0 ? { minContains } : {}), ...(maxContains < Infinity ? { maxContains } : {}), })), ...negatedAtomicSchemasByConstructor .get(ItemsAtomicSchema) .map(({ items, startIndex }) => ({ not: { items, ...(startIndex > 0 ? { prefixItems: createTrueArray(startIndex), } : {}), }, })), ...getUniqueNegatedConstSchemasFromNegatedAtomicSchemasByConstructor(schemaDescribesEmptySet, negatedAtomicSchemasByConstructor, isArray), ], }; }, }; //# sourceMappingURL=array.js.map