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).
489 lines • 23.1 kB
JavaScript
import { isJSONSchema } from '../../json-schema/index.js';
import uniq from 'lodash/uniq.js';
import difference from 'lodash/difference.js';
import isString from 'lodash/isString.js';
import { AtomicSchemaObject, NotSchema, AllOfSchema, } from '../../atomic-schema/index.js';
import { TypeAtomicSchema } from '../type.js';
import { negateIntegerMaximum, negateIntegerMinimum, } from '../../utils/negate-integer-min-max/negate-integer-min-max.js';
import {} from '../../validate/index.js';
import { groupByAndMap } from '../../utils/group-by-and-map/index.js';
import { createCachedFunction } from '../../utils/cached-function/index.js';
import { extractIfThenElse } from '../if-then-else.js';
import { retrieveStringSchemaConstsAndPatterns } from './retrieve-string-schema-consts-and-patterns/index.js';
import { UnsupportedKeywordError } from '../../unsupported-keyword-error/index.js';
import { isArrayOf, isNonArrayObject, isRecordOf, } from '../../utils/type-guards/index.js';
import { getUniqueNegatedConstSchemasFromNegatedAtomicSchemasByConstructor } from '../negated-const-helpers.js';
export class RequiredAtomicSchema extends AtomicSchemaObject {
requiredKey;
constructor(requiredKey) {
super();
this.requiredKey = requiredKey;
}
negate() {
return new AllOfSchema([
new PropertyAtomicSchema(this.requiredKey, false),
new TypeAtomicSchema('object'),
]);
}
toJSONSchema() {
return { required: [this.requiredKey] };
}
}
export class MinPropertiesAtomicSchema extends AtomicSchemaObject {
minProperties;
constructor(minProperties) {
super();
this.minProperties = minProperties;
}
negate() {
return new AllOfSchema([
new MaxPropertiesAtomicSchema(negateIntegerMinimum(this.minProperties)),
new TypeAtomicSchema('object'),
]);
}
toJSONSchema() {
return { minProperties: this.minProperties };
}
}
export class MaxPropertiesAtomicSchema extends AtomicSchemaObject {
maxProperties;
constructor(maxProperties) {
super();
this.maxProperties = maxProperties;
}
negate() {
return new AllOfSchema([
new MinPropertiesAtomicSchema(negateIntegerMaximum(this.maxProperties)),
new TypeAtomicSchema('object'),
]);
}
toJSONSchema() {
return { maxProperties: this.maxProperties };
}
}
export class PropertyAtomicSchema extends AtomicSchemaObject {
key;
schema;
constructor(key, schema) {
super();
this.key = key;
this.schema = schema;
}
negate() {
return new AllOfSchema([
new TypeAtomicSchema('object'),
new RequiredAtomicSchema(this.key),
new PropertyAtomicSchema(this.key, { not: this.schema }),
]);
}
toJSONSchema() {
return { properties: { [this.key]: this.schema } };
}
}
export class PatternPropertyAtomicSchema extends AtomicSchemaObject {
keyPattern;
schema;
constructor(keyPattern, schema) {
super();
this.keyPattern = keyPattern;
this.schema = schema;
}
negate() {
return new AllOfSchema([
new NotSchema(this),
/* actually redundant, but makes checking for contradictions easier */
new TypeAtomicSchema('object'),
]);
}
toJSONSchema() {
return { patternProperties: { [this.keyPattern]: this.schema } };
}
}
export class AdditionalPropertiesAtomicSchema extends AtomicSchemaObject {
additionalProperties;
propertyKeys;
propertyKeyPatterns;
constructor(additionalProperties, propertyKeys, propertyKeyPatterns) {
super();
this.additionalProperties = additionalProperties;
this.propertyKeys = [...propertyKeys];
this.propertyKeyPatterns = [...propertyKeyPatterns];
}
negate() {
return new AllOfSchema([
new NotSchema(this),
/* actually redundant, but makes checking for contradictions easier */
new TypeAtomicSchema('object'),
]);
}
toJSONSchema() {
return {
additionalProperties: this.additionalProperties,
...(this.propertyKeys.length > 0
? {
properties: Object.fromEntries(this.propertyKeys.map((key) => [key, true])),
}
: {}),
...(this.propertyKeyPatterns.length > 0
? {
patternProperties: Object.fromEntries(this.propertyKeyPatterns.map((key) => [key, true])),
}
: {}),
};
}
}
export class PropertyNamesAtomicSchema extends AtomicSchemaObject {
propertyNames;
constructor(propertyNames) {
super();
this.propertyNames = propertyNames;
}
negate() {
return new AllOfSchema([
new NotSchema(this),
/* actually redundant, but makes checking for contradictions easier */
new TypeAtomicSchema('object'),
]);
}
toJSONSchema() {
return { propertyNames: this.propertyNames };
}
}
export class UnevaluatedPropertiesAtomicSchema extends AtomicSchemaObject {
negate() {
return new AllOfSchema([
new NotSchema(this),
/* actually redundant, but makes checking for contradictions easier */
new TypeAtomicSchema('object'),
]);
}
toJSONSchema() {
throw new Error('unimplemented');
}
}
export const objectExtraction = {
extract: ({ schema: { required, minProperties, maxProperties, properties, patternProperties, propertyNames, additionalProperties, dependentRequired, dependentSchemas, unevaluatedProperties, }, split, }) => {
const allOf = [];
if (isArrayOf(isString)(required)) {
allOf.push(...required.map((requiredKey) => new RequiredAtomicSchema(requiredKey)));
}
if (typeof minProperties === 'number') {
allOf.push(new MinPropertiesAtomicSchema(minProperties));
}
if (typeof maxProperties === 'number') {
allOf.push(new MaxPropertiesAtomicSchema(maxProperties));
}
if (isRecordOf(isJSONSchema)(properties)) {
allOf.push(...Object.entries(properties).map(([key, schema]) => new PropertyAtomicSchema(key, schema)));
}
if (isRecordOf(isJSONSchema)(patternProperties)) {
allOf.push(...Object.entries(patternProperties).map(([keyPattern, schema]) => new PatternPropertyAtomicSchema(keyPattern, schema)));
}
if (isJSONSchema(additionalProperties)) {
allOf.push(new AdditionalPropertiesAtomicSchema(additionalProperties, Object.keys(properties ?? {}), Object.keys(patternProperties ?? {})));
}
if (isJSONSchema(propertyNames)) {
allOf.push(new PropertyNamesAtomicSchema(propertyNames));
}
if (isRecordOf(isArrayOf(isString))(dependentRequired)) {
allOf.push(...Object.entries(dependentRequired).map(([ifRequired, thenRequired]) => extractIfThenElse(split, { required: [ifRequired] }, { required: thenRequired })));
}
if (isRecordOf(isJSONSchema)(dependentSchemas)) {
allOf.push(...Object.entries(dependentSchemas).map(([ifRequired, thenSchema]) => extractIfThenElse(split, { required: [ifRequired] }, thenSchema)));
}
if (unevaluatedProperties !== undefined) {
allOf.push(new UnevaluatedPropertiesAtomicSchema());
}
return allOf.length === 0 || new AllOfSchema(allOf);
},
};
export function createGetPropertyValueSchemaByKey({ validate, propertyNames, propertyAtomicSchemasByKey, patternPropertyAtomicSchemasByKeyPatternEntries, additionalPropertiesAtomicSchemas, }) {
return createCachedFunction((key) => {
if (!validate(propertyNames, key)) {
return false;
}
const allOf = [
...(propertyAtomicSchemasByKey[key] ?? []),
/* `patternProperties` that match `key` */
...patternPropertyAtomicSchemasByKeyPatternEntries
.filter(([pattern]) => validate({ pattern }, key))
.flatMap(([, patternPropertySchemas]) => patternPropertySchemas),
/* `additionalProperties` that would apply to `key` */
...additionalPropertiesAtomicSchemas
.filter(({ propertyKeys, propertyKeyPatterns }) => !propertyKeys.includes(key) &&
!propertyKeyPatterns.some((pattern) => validate({ pattern }, key)))
.flatMap(({ additionalProperties }) => additionalProperties),
];
return allOf.length === 0 ? true : { allOf };
});
}
export function createGetPropertyValueSchemaByKeyPattern({ validate, schemaDescribesEmptySet, propertyNames, patternPropertyAtomicSchemasByKeyPattern, additionalPropertiesAtomicSchemas, }) {
/**
* Checks for pattern equality only. Overlapping patterns might result in a
* false negative.
*/
return createCachedFunction((keyPattern) => {
if (schemaDescribesEmptySet({
allOf: [propertyNames, { pattern: keyPattern }],
})) {
return false;
}
const allOf = [
...(patternPropertyAtomicSchemasByKeyPattern[keyPattern] ?? []),
/* `additionalProperties` that would apply to `keyPattern` */
...additionalPropertiesAtomicSchemas
.filter(({ propertyKeys, propertyKeyPatterns }) =>
/* cannot have `propertyKeyPatterns`, because there is no
* way to check for overlaps with `keyPattern` */
propertyKeyPatterns.length === 0 &&
/* if there is also no overlap with any of the `propertyKeys`,
* `additionalProperties` applies */
!propertyKeys.some((key) => validate({ pattern: keyPattern }, key)))
.flatMap(({ additionalProperties }) => additionalProperties)
.filter(isJSONSchema),
];
return allOf.length === 0 ? true : { allOf };
});
}
export const objectSimplification = {
appliesToJSONSchemaType: 'object',
mergeableKeywords: [
'properties',
'patternProperties',
'required',
'propertyNames',
'minProperties',
'maxProperties',
],
simplify({ atomicSchemasByConstructor,
/**
* Might contain {@link PatternPropertyAtomicSchema}
* {@link AdditionalPropertiesAtomicSchema} or
* {@link PropertyNamesAtomicSchema}, everything else can be negated without
* using {@link NotSchema}
*/
negatedAtomicSchemasByConstructor, schemaDescribesEmptySet, options, splitToRawDNF, validateConst, }) {
const validate = options.validate;
const minProperties = Math.ceil(Math.max(0, ...atomicSchemasByConstructor
.get(MinPropertiesAtomicSchema)
.map(({ minProperties }) => minProperties)));
const maxProperties = Math.floor(Math.min(...atomicSchemasByConstructor
.get(MaxPropertiesAtomicSchema)
.map(({ maxProperties }) => maxProperties)));
if (minProperties > maxProperties) {
return false;
}
if (maxProperties <= 0) {
return validateConst({});
}
const propertyNamesAllOf = atomicSchemasByConstructor
.get(PropertyNamesAtomicSchema)
.map(({ propertyNames }) => propertyNames);
const propertyNames = {
allOf: [...propertyNamesAllOf, { type: 'string' }],
};
const propertyAtomicSchemasByKey = groupByAndMap(atomicSchemasByConstructor.get(PropertyAtomicSchema), ({ key }) => key, ({ schema }) => schema);
const patternPropertyAtomicSchemasByKeyPattern = groupByAndMap(atomicSchemasByConstructor.get(PatternPropertyAtomicSchema), ({ keyPattern }) => keyPattern, ({ schema }) => schema);
const patternPropertyAtomicSchemasByKeyPatternEntries = Object.entries(patternPropertyAtomicSchemasByKeyPattern);
const additionalPropertiesAtomicSchemas = atomicSchemasByConstructor.get(AdditionalPropertiesAtomicSchema);
const getPropertyValueSchemaByKey = createGetPropertyValueSchemaByKey({
validate,
propertyNames,
additionalPropertiesAtomicSchemas,
patternPropertyAtomicSchemasByKeyPatternEntries,
propertyAtomicSchemasByKey,
});
const getPropertyValueSchemaByKeyPattern = createGetPropertyValueSchemaByKeyPattern({
validate,
schemaDescribesEmptySet,
propertyNames,
additionalPropertiesAtomicSchemas,
patternPropertyAtomicSchemasByKeyPattern,
});
let requiredCount = 0;
function incrementRequiredCount(amount = 1) {
requiredCount += amount;
return requiredCount > maxProperties;
}
const requiredPropertySchemas = [];
/**
* Pushes to {@link requiredPropertySchemas} and updates
* {@link requiredCount}. Returns true if a contradiction was found.
*
*/
function pushRequiredPropertySchema(keySchema, valueSchema) {
const extendedKeySchema = { allOf: [keySchema, propertyNames] };
if (schemaDescribesEmptySet(extendedKeySchema)) {
return true;
}
if (schemaDescribesEmptySet(valueSchema)) {
return true;
}
const hasNoOverlapWithPreviousRequiredPropertySchemas = requiredPropertySchemas.every(([previousKeySchema, previousValueSchema]) => schemaDescribesEmptySet({
allOf: [previousKeySchema, keySchema, propertyNames],
}) ||
schemaDescribesEmptySet({
allOf: [previousValueSchema, valueSchema],
}));
requiredPropertySchemas.push([keySchema, valueSchema]);
return (hasNoOverlapWithPreviousRequiredPropertySchemas &&
incrementRequiredCount());
}
const required = uniq(atomicSchemasByConstructor
.get(RequiredAtomicSchema)
.map(({ requiredKey }) => requiredKey));
if (incrementRequiredCount(required.length)) {
return false;
}
for (const key of required) {
const valueSchema = getPropertyValueSchemaByKey(key);
if (schemaDescribesEmptySet(valueSchema)) {
/* cannot satisfy required key */
return false;
}
requiredPropertySchemas.push([{ const: key }, valueSchema]);
}
const negatedPropertyNamesAtomicSchemas = negatedAtomicSchemasByConstructor.get(PropertyNamesAtomicSchema);
for (const { propertyNames: propertyNamesNegated, } of negatedPropertyNamesAtomicSchemas) {
const keySchema = { not: propertyNamesNegated };
const { isStringWithConstsOrPatterns, consts, patterns } = retrieveStringSchemaConstsAndPatterns({
schema: keySchema,
options,
splitToRawDNF,
});
const valueSchema = isStringWithConstsOrPatterns
? {
anyOf: [
...consts.map(getPropertyValueSchemaByKey),
...patterns.map(getPropertyValueSchemaByKeyPattern),
],
}
: true;
if (pushRequiredPropertySchema(keySchema, valueSchema)) {
return false;
}
}
const negatedPatternPropertyAtomicSchema = negatedAtomicSchemasByConstructor.get(PatternPropertyAtomicSchema);
/* check negated `patternProperties` */
const negatedPatternPropertyAtomicSchemasByKeyPattern = groupByAndMap(negatedPatternPropertyAtomicSchema, ({ keyPattern }) => keyPattern, ({ schema }) => schema);
/* negated patternProperty implies that there has to be a property whose
* key matches the pattern, but whose value doesn't satisfy the schema
* ⇒ return false if that's not possible */
for (const [keyPattern, negatedPatternPropertySchemas] of Object.entries(negatedPatternPropertyAtomicSchemasByKeyPattern)) {
const valueSchema = {
allOf: [
/* if a key matches any of the keys of
* negatedPatternPropertySchemas it will match all of them
* ⇒ value must not satisfy any of the
* negatedPatternPropertySchemas */
...negatedPatternPropertySchemas.map((schema) => ({
not: schema,
})),
getPropertyValueSchemaByKeyPattern(keyPattern),
],
};
if (pushRequiredPropertySchema({ pattern: keyPattern }, valueSchema)) {
return false;
}
}
const negatedAdditionalPropertiesAtomicSchemas = negatedAtomicSchemasByConstructor.get(AdditionalPropertiesAtomicSchema);
/* check negated `additionalProperties` */
for (const { additionalProperties: additionalPropertiesNegated, propertyKeyPatterns: propertyKeyPatternsNegated, propertyKeys: propertyKeysNegated, } of negatedAdditionalPropertiesAtomicSchemas) {
/* For this schema to be invalid there would need to be
* a property whose key would not match any of `patternProperties`
* keys or `properties` keys and whose value is unsatisfiable.
*
* Can there be a property whose key is not included in
* `propertyKeysNegated` and doesn't match any of
* `patternPropertiesNegated`? If not, then there is no contradiction
* ⇒ continue. If yes, can that property's value be satisfied?
*/
const allOf = [{ not: additionalPropertiesNegated }];
for (const { additionalProperties: additionalPropertiesNotNegated, propertyKeys: propertyKeysNotNegated, propertyKeyPatterns: propertyKeyPatternsNotNegated, } of additionalPropertiesAtomicSchemas) {
const overlappingPropertyKeys = difference(propertyKeysNotNegated, propertyKeysNegated);
const overlappingPropertyKeyPatterns = difference(propertyKeyPatternsNotNegated, propertyKeyPatternsNegated);
allOf.push({
anyOf: [
additionalPropertiesNotNegated,
...overlappingPropertyKeys.map((key) => getPropertyValueSchemaByKey(key)),
...overlappingPropertyKeyPatterns.map((keyPattern) => getPropertyValueSchemaByKeyPattern(keyPattern)),
],
});
}
const keySchemas = [
...propertyKeysNegated.map((key) => ({ const: key })),
...propertyKeyPatternsNegated.map((pattern) => ({
pattern,
})),
];
if (pushRequiredPropertySchema(keySchemas.length > 0 ? { not: { anyOf: keySchemas } } : true, { allOf })) {
return false;
}
}
const totalMinPropertiesCount = Math.max(requiredCount, minProperties);
if (schemaDescribesEmptySet(propertyNames) && totalMinPropertiesCount > 0) {
return false;
}
/* check for `additionalProperties` that would need to apply to at least
* one property, because the number of relevant properties is less than
* `totalMinPropertiesCount` */
if (schemaDescribesEmptySet({
allOf: additionalPropertiesAtomicSchemas
.filter(({ propertyKeys, propertyKeyPatterns }) =>
/* if there are `patternProperties`, there's no way to tell,
* whether `additionalProperties` applies to something or not */
propertyKeyPatterns.length === 0 &&
/* if the minimum number of properties exceeds the number of
* properties that do not apply to `additionalProperties`, there needs
* to be at least one value that satisfies `additionalProperties` */
propertyKeys.filter((key) =>
/* Negating the result of `schemaDescribesEmptySet` is okay,
* even if it might be a false negative.
* In case of false negative more propertyKeys are considered. */
!schemaDescribesEmptySet(getPropertyValueSchemaByKey(key))).length < totalMinPropertiesCount)
.map(({ additionalProperties }) => additionalProperties),
})) {
return false;
}
const propertiesEntries = Object.keys(propertyAtomicSchemasByKey).map((key) => [key, getPropertyValueSchemaByKey(key)]);
const patternPropertiesEntries = patternPropertyAtomicSchemasByKeyPatternEntries.map(([keyPattern]) => [
keyPattern,
getPropertyValueSchemaByKeyPattern(keyPattern),
]);
const unevaluatedPropertiesAtomicSchema = atomicSchemasByConstructor.get(UnevaluatedPropertiesAtomicSchema)[0] ??
negatedAtomicSchemasByConstructor.get(UnevaluatedPropertiesAtomicSchema)[0];
if (unevaluatedPropertiesAtomicSchema !== undefined) {
throw new UnsupportedKeywordError('unevaluatedProperties');
}
return {
...(propertiesEntries.length > 0
? { properties: Object.fromEntries(propertiesEntries) }
: {}),
...(patternPropertiesEntries.length > 0
? { patternProperties: Object.fromEntries(patternPropertiesEntries) }
: {}),
...(totalMinPropertiesCount > 0
? { minProperties: totalMinPropertiesCount }
: {}),
...(maxProperties < Infinity ? { maxProperties } : {}),
// propertyNames includes a `{type: 'string'}` which is actually redundant
...(propertyNames.allOf.length > 1 ? { propertyNames } : {}),
...(required.length > 0 ? { required } : {}),
allOf: [
...additionalPropertiesAtomicSchemas.map((atomicSchema) => atomicSchema.toJSONSchema()),
...negatedAdditionalPropertiesAtomicSchemas.map((atomicSchema) => ({
not: atomicSchema.toJSONSchema(),
})),
...negatedPropertyNamesAtomicSchemas.map((atomicSchema) => ({
not: atomicSchema.toJSONSchema(),
})),
...negatedPatternPropertyAtomicSchema.map((atomicSchema) => ({
not: atomicSchema.toJSONSchema(),
})),
...getUniqueNegatedConstSchemasFromNegatedAtomicSchemasByConstructor(schemaDescribesEmptySet, negatedAtomicSchemasByConstructor, isNonArrayObject),
],
// TODO: unevaluatedProperties
};
},
};
//# sourceMappingURL=object.js.map