diff --git a/packages/core/src/mappers/renderer.ts b/packages/core/src/mappers/renderer.ts index d80392c85..08d65ec4b 100644 --- a/packages/core/src/mappers/renderer.ts +++ b/packages/core/src/mappers/renderer.ts @@ -84,6 +84,7 @@ import { getUiSchema, } from '../store'; import { isInherentlyEnabled, isInherentlyReadonly } from './util'; +import { isStructuralMatch } from '../util/structural'; import { CombinatorKeyword } from './combinators'; import isEqual from 'lodash/isEqual'; @@ -1163,41 +1164,14 @@ export const mapStateToCombinatorRendererProps = ( const { data, schema, rootSchema, i18nKeyPrefix, label, ...props } = mapStateToControlProps(state, ownProps); - const ajv = state.jsonforms.core.ajv; - const structuralKeywords = [ - 'required', - 'additionalProperties', - 'type', - 'enum', - 'const', - ]; - const dataIsValid = (errors: ErrorObject[]): boolean => { - return ( - !errors || - errors.length === 0 || - !errors.find((e) => structuralKeywords.indexOf(e.keyword) !== -1) - ); - }; + // Pick the first branch whose structural keywords fit the data. This is a + // pure structural check (see isStructuralMatch): no validator instance and + // no code generation are involved, so it also works under a strict CSP. let indexOfFittingSchema: number; - // TODO instead of compiling the combinator subschemas we can compile the original schema - // without the combinator alternatives and then revalidate and check the errors for the - // element for (let i = 0; i < schema[keyword]?.length; i++) { - try { - let _schema = schema[keyword][i]; - if (_schema.$ref) { - _schema = Resolve.schema(rootSchema, _schema.$ref, rootSchema); - } - const valFn = ajv.compile(_schema); - valFn(data); - if (dataIsValid(valFn.errors)) { - indexOfFittingSchema = i; - break; - } - } catch (error) { - console.debug( - "Combinator subschema is not self contained, can't hand it over to AJV" - ); + if (isStructuralMatch(schema[keyword][i], data, rootSchema)) { + indexOfFittingSchema = i; + break; } } diff --git a/packages/core/src/util/index.ts b/packages/core/src/util/index.ts index 0d4ec3564..0987ee61e 100644 --- a/packages/core/src/util/index.ts +++ b/packages/core/src/util/index.ts @@ -34,6 +34,7 @@ export * from './schema'; export * from './uischema'; export * from './util'; export * from './validator'; +export * from './structural'; export * from './defaultDateFormat'; export * from './errors'; export * from './helpers'; diff --git a/packages/core/src/util/structural.ts b/packages/core/src/util/structural.ts new file mode 100644 index 000000000..e3575f68c --- /dev/null +++ b/packages/core/src/util/structural.ts @@ -0,0 +1,248 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import isEqual from 'lodash/isEqual'; +import type { JsonSchema } from '../models'; +import { resolveSchema } from './resolvers'; + +/** + * The JSON Schema keywords the Structural Matcher evaluates. + * Combinator tab selection has always ignored every other keyword. + */ +export const structuralKeywords = [ + 'required', + 'additionalProperties', + 'type', + 'enum', + 'const', +] as const; + +type SchemaLike = JsonSchema & { + nullable?: boolean; + prefixItems?: JsonSchema[]; + if?: JsonSchema; + then?: JsonSchema; + else?: JsonSchema; +}; + +/** Schema/data pairs already being evaluated on the current path. */ +type Visited = ReadonlyArray; + +const isPlainObject = (data: unknown): data is Record => + typeof data === 'object' && data !== null && !Array.isArray(data); + +const matchesType = (type: string, data: unknown): boolean => { + switch (type) { + case 'null': + return data === null; + case 'boolean': + return typeof data === 'boolean'; + case 'string': + return typeof data === 'string'; + case 'number': + return typeof data === 'number'; + case 'integer': + return typeof data === 'number' && Number.isInteger(data); + case 'array': + return Array.isArray(data); + case 'object': + return isPlainObject(data); + default: + // Unknown type names cannot be decided structurally. + return true; + } +}; + +const compilePattern = (pattern: string): RegExp | undefined => { + try { + return new RegExp(pattern, 'u'); + } catch { + return undefined; + } +}; + +/** + * Structural Matcher: decides whether `data` fits `schema` by evaluating only + * the structural keywords `type`, `enum`, `const`, `required` and + * `additionalProperties`, recursively through `properties`, + * `patternProperties`, `items`, `$ref`, nested combinators and + * `if`/`then`/`else`. It needs no validator instance and generates no code, + * so it works under a Content Security Policy without `unsafe-eval`. + * + * Keywords outside that set (`format`, `pattern`, `minimum`, ...) are ignored, + * matching what combinator tab selection has always done. A `$ref` that + * cannot be resolved against `rootSchema` is treated as not matching. + * + * @param schema the schema to match against + * @param data the data to check + * @param rootSchema the root schema used to resolve `$ref`s; defaults to `schema` + * @returns `true` when no structural keyword rejects the data + */ +export const isStructuralMatch = ( + schema: JsonSchema | boolean | undefined, + data: unknown, + rootSchema?: JsonSchema +): boolean => { + const root = rootSchema ?? (typeof schema === 'object' ? schema : {}); + return match(schema, data, root, []); +}; + +const match = ( + schema: JsonSchema | boolean | undefined, + data: unknown, + rootSchema: JsonSchema, + visited: Visited +): boolean => { + if (schema === undefined || schema === null) { + return true; + } + if (typeof schema === 'boolean') { + return schema; + } + if (visited.some(([s, d]) => s === schema && d === data)) { + // The same schema applied to the same data again on this path: a + // recursive schema that cannot be decided any further structurally. + return true; + } + const nextVisited: Visited = [...visited, [schema, data]]; + + if (typeof schema.$ref === 'string') { + const resolved = resolveSchema(rootSchema, schema.$ref, rootSchema); + if (resolved === undefined) { + return false; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { $ref, ...siblings } = schema; + return ( + match(resolved, data, rootSchema, nextVisited) && + matchKeywords(siblings, data, rootSchema, nextVisited) + ); + } + return matchKeywords(schema, data, rootSchema, nextVisited); +}; + +const matchKeywords = ( + schema: SchemaLike, + data: unknown, + rootSchema: JsonSchema, + visited: Visited +): boolean => { + const sub = (s: JsonSchema | boolean | undefined, d: unknown): boolean => + match(s, d, rootSchema, visited); + + if (schema.nullable === true && data === null) { + return true; + } + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((type) => matchesType(type, data))) { + return false; + } + } + if ( + Array.isArray(schema.enum) && + !schema.enum.some((value) => isEqual(value, data)) + ) { + return false; + } + if ('const' in schema && !isEqual(schema.const, data)) { + return false; + } + if (Array.isArray(schema.allOf) && !schema.allOf.every((s) => sub(s, data))) { + return false; + } + if (Array.isArray(schema.anyOf) && !schema.anyOf.some((s) => sub(s, data))) { + return false; + } + // When several oneOf branches match, AJV reports only a `oneOf` error, which + // tab selection ignores. "At least one branch" is therefore the structural + // reading of oneOf. + if (Array.isArray(schema.oneOf) && !schema.oneOf.some((s) => sub(s, data))) { + return false; + } + if (schema.if !== undefined) { + const branch = sub(schema.if, data) ? schema.then : schema.else; + if (!sub(branch, data)) { + return false; + } + } + + if (isPlainObject(data)) { + if ( + Array.isArray(schema.required) && + !schema.required.every((key) => data[key] !== undefined) + ) { + return false; + } + const properties = schema.properties ?? {}; + const patterns = Object.entries(schema.patternProperties ?? {}) + .map(([pattern, s]) => ({ regex: compilePattern(pattern), schema: s })) + .filter( + (p): p is { regex: RegExp; schema: JsonSchema } => p.regex !== undefined + ); + for (const [key, value] of Object.entries(data)) { + if (value === undefined) { + continue; + } + const matchingPatterns = patterns.filter((p) => p.regex.test(key)); + const isDeclared = key in properties || matchingPatterns.length > 0; + if (key in properties && !sub(properties[key], value)) { + return false; + } + if (!matchingPatterns.every((p) => sub(p.schema, value))) { + return false; + } + if (!isDeclared && schema.additionalProperties !== undefined) { + if (schema.additionalProperties === false) { + return false; + } + if ( + typeof schema.additionalProperties === 'object' && + !sub(schema.additionalProperties, value) + ) { + return false; + } + } + } + } + + if (Array.isArray(data)) { + const tuple = Array.isArray(schema.prefixItems) + ? schema.prefixItems + : Array.isArray(schema.items) + ? schema.items + : undefined; + if (tuple !== undefined) { + if (!data.every((item, i) => i >= tuple.length || sub(tuple[i], item))) { + return false; + } + } else if ( + typeof schema.items === 'object' && + !data.every((item) => sub(schema.items as JsonSchema, item)) + ) { + return false; + } + } + return true; +}; diff --git a/packages/core/test/mappers/renderer.test.ts b/packages/core/test/mappers/renderer.test.ts index 9d0dd516c..c46626086 100644 --- a/packages/core/test/mappers/renderer.test.ts +++ b/packages/core/test/mappers/renderer.test.ts @@ -1741,6 +1741,172 @@ test('mapStateToAnyOfProps - const constraint in anyOf schema should return corr t.is(props.indexOfFittingSchema, 2); }); +test('mapStateToOneOfProps - indexOfFittingSchema selects the branch whose required properties are present', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { + type: 'object', + properties: { iban: { type: 'string' } }, + required: ['iban'], + }, + { + type: 'object', + properties: { cardNumber: { type: 'string' } }, + required: ['cardNumber'], + }, + ], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { cardNumber: '4111' }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema honours additionalProperties: false', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false, + }, + { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'integer' } }, + }, + ], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { name: 'Ada', age: 36 }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToAnyOfProps - indexOfFittingSchema selects by primitive type', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: true, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToAnyOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 2); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema resolves $ref branches against the root schema', (t) => { + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/pet', + }; + const schema: JsonSchema7 = { + type: 'object', + properties: { + pet: { + oneOf: [{ $ref: '#/definitions/cat' }, { $ref: '#/definitions/dog' }], + }, + }, + definitions: { + cat: { + type: 'object', + properties: { kind: { const: 'cat' } }, + required: ['kind'], + }, + dog: { + type: 'object', + properties: { kind: { const: 'dog' } }, + required: ['kind'], + }, + }, + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { pet: { kind: 'dog' } }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema works without an ajv instance in state', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { type: 'object', properties: { kind: { const: 'a' } } }, + { type: 'object', properties: { kind: { const: 'b' } } }, + ], + }; + const state = { + jsonforms: { + core: { + schema, + data: { kind: 'b' }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema is undefined when no branch fits', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { type: 'object', properties: { kind: { const: 'a' } } }, + { type: 'object', properties: { kind: { const: 'b' } } }, + ], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { kind: 'c' }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, undefined); +}); + test('mapStateToControlProps - i18n - mapStateToControlProps should not crash without i18n', (t) => { const ownProps = { uischema: coreUISchema, diff --git a/packages/core/test/util/structural.test.ts b/packages/core/test/util/structural.test.ts new file mode 100644 index 000000000..9b375d6d1 --- /dev/null +++ b/packages/core/test/util/structural.test.ts @@ -0,0 +1,331 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import test from 'ava'; +import type { ErrorObject } from 'ajv'; +import { JsonSchema } from '../../src/models'; +import { + isStructuralMatch, + structuralKeywords, +} from '../../src/util/structural'; +import { createAjv } from '../../src/util/validator'; + +/** + * The behaviour the Structural Matcher replaces: compile the branch with AJV, + * validate, and ignore every error whose keyword is not structural. + */ +const ajvStructuralMatch = (schema: JsonSchema, data: unknown): boolean => { + const validate = createAjv().compile(schema); + validate(data); + const errors: ErrorObject[] = validate.errors ?? []; + return !errors.some((e) => + (structuralKeywords as readonly string[]).includes(e.keyword) + ); +}; + +const cases: Array<{ name: string; schema: JsonSchema; data: unknown }> = [ + { name: 'type string ok', schema: { type: 'string' }, data: 'a' }, + { name: 'type string fail', schema: { type: 'string' }, data: 1 }, + { name: 'type integer ok', schema: { type: 'integer' }, data: 2 }, + { name: 'type integer fail', schema: { type: 'integer' }, data: 2.5 }, + { name: 'type number ok', schema: { type: 'number' }, data: 2.5 }, + { name: 'type null ok', schema: { type: 'null' }, data: null }, + { name: 'type object fail on array', schema: { type: 'object' }, data: [] }, + { name: 'type array ok', schema: { type: 'array' }, data: [] }, + { + name: 'type union ok', + schema: { type: ['string', 'null'] }, + data: null, + }, + { + name: 'type union fail', + schema: { type: ['string', 'null'] }, + data: 1, + }, + { name: 'enum ok', schema: { enum: ['a', 'b'] }, data: 'b' }, + { name: 'enum fail', schema: { enum: ['a', 'b'] }, data: 'c' }, + { + name: 'enum deep equal ok', + schema: { enum: [{ x: 1 }] }, + data: { x: 1 }, + }, + { name: 'const ok', schema: { const: 5 }, data: 5 }, + { name: 'const fail', schema: { const: 5 }, data: 6 }, + { name: 'const null ok', schema: { const: null }, data: null }, + { + name: 'required ok', + schema: { type: 'object', required: ['a'] }, + data: { a: 1 }, + }, + { + name: 'required fail', + schema: { type: 'object', required: ['a'] }, + data: { b: 1 }, + }, + { + name: 'required ignored on non-object', + schema: { required: ['a'] }, + data: 'str', + }, + { + name: 'nested properties type fail', + schema: { + type: 'object', + properties: { + a: { type: 'object', properties: { b: { type: 'string' } } }, + }, + }, + data: { a: { b: 1 } }, + }, + { + name: 'nested properties ok', + schema: { + type: 'object', + properties: { + a: { type: 'object', properties: { b: { type: 'string' } } }, + }, + }, + data: { a: { b: 'x' } }, + }, + { + name: 'additionalProperties false fail', + schema: { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + }, + data: { a: 'x', b: 1 }, + }, + { + name: 'additionalProperties false ok', + schema: { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + }, + data: { a: 'x' }, + }, + { + name: 'additionalProperties schema fail', + schema: { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: { type: 'number' }, + }, + data: { a: 'x', b: 'not a number' }, + }, + { + name: 'additionalProperties with patternProperties ok', + schema: { + type: 'object', + patternProperties: { '^x-': { type: 'string' } }, + additionalProperties: false, + }, + data: { 'x-a': 'ok' }, + }, + { + name: 'patternProperties type fail', + schema: { + type: 'object', + patternProperties: { '^x-': { type: 'string' } }, + }, + data: { 'x-a': 1 }, + }, + { + name: 'items type fail', + schema: { type: 'array', items: { type: 'string' } }, + data: ['a', 1], + }, + { + name: 'items type ok', + schema: { type: 'array', items: { type: 'string' } }, + data: ['a', 'b'], + }, + { + name: 'tuple items fail', + schema: { type: 'array', items: [{ type: 'string' }, { type: 'number' }] }, + data: ['a', 'b'], + }, + { + name: 'nested anyOf ok', + schema: { + type: 'object', + properties: { v: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + }, + data: { v: 1 }, + }, + { + name: 'nested anyOf fail', + schema: { + type: 'object', + properties: { v: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + }, + data: { v: true }, + }, + { + name: 'nested allOf fail', + schema: { allOf: [{ type: 'object' }, { required: ['a'] }] }, + data: { b: 1 }, + }, + { + name: 'nested oneOf none fail', + schema: { oneOf: [{ const: 'a' }, { const: 'b' }] }, + data: 'c', + }, + { + name: 'nested oneOf several match is structurally fine', + schema: { oneOf: [{ type: 'string' }, { const: 'a' }] }, + data: 'a', + }, + { + name: 'not is ignored like AJV filtering does', + schema: { not: { type: 'string' } }, + data: 'a', + }, + { + name: 'non-structural keywords are ignored (pattern, minLength, format)', + schema: { + type: 'string', + pattern: '^a', + minLength: 10, + format: 'email', + }, + data: 'b', + }, + { + name: 'if then else uses then branch', + schema: { + type: 'object', + if: { properties: { kind: { const: 'a' } } }, + then: { required: ['aValue'] }, + else: { required: ['bValue'] }, + }, + data: { kind: 'a', bValue: 1 }, + }, + { + name: 'if then else uses else branch', + schema: { + type: 'object', + if: { properties: { kind: { const: 'a' } } }, + then: { required: ['aValue'] }, + else: { required: ['bValue'] }, + }, + data: { kind: 'b', bValue: 1 }, + }, + { + name: '$ref resolved against root', + schema: { + type: 'object', + properties: { pet: { $ref: '#/definitions/cat' } }, + definitions: { + cat: { type: 'object', required: ['meow'] }, + }, + }, + data: { pet: { bark: true } }, + }, + { + name: 'nullable with type allows null', + schema: { type: 'string', nullable: true } as JsonSchema, + data: null, + }, +]; + +for (const c of cases) { + test(`isStructuralMatch agrees with filtered AJV: ${c.name}`, (t) => { + t.is( + isStructuralMatch(c.schema, c.data), + ajvStructuralMatch(c.schema, c.data) + ); + }); +} + +test('isStructuralMatch - boolean schemas', (t) => { + t.true(isStructuralMatch(true, 'anything')); + t.false(isStructuralMatch(false, 'anything')); + t.true(isStructuralMatch(undefined, 'anything')); +}); + +test('isStructuralMatch - unresolvable $ref does not match', (t) => { + const root: JsonSchema = { definitions: {} }; + t.false(isStructuralMatch({ $ref: '#/definitions/missing' }, {}, root)); +}); + +test('isStructuralMatch - $ref branch resolved via explicit root schema', (t) => { + const root: JsonSchema = { + definitions: { + dog: { + type: 'object', + properties: { kind: { const: 'dog' } }, + required: ['kind'], + }, + }, + }; + t.true( + isStructuralMatch({ $ref: '#/definitions/dog' }, { kind: 'dog' }, root) + ); + t.false( + isStructuralMatch({ $ref: '#/definitions/dog' }, { kind: 'cat' }, root) + ); +}); + +test('isStructuralMatch - recursive schema terminates', (t) => { + const root: JsonSchema = { + definitions: { + node: { + type: 'object', + properties: { + children: { type: 'array', items: { $ref: '#/definitions/node' } }, + }, + }, + }, + }; + const data: unknown = { children: [{ children: [{ children: [] }] }] }; + t.true(isStructuralMatch({ $ref: '#/definitions/node' }, data, root)); + t.false( + isStructuralMatch( + { $ref: '#/definitions/node' }, + { children: [{ children: 'not an array' }] }, + root + ) + ); +}); + +test('isStructuralMatch - invalid patternProperties regex is ignored', (t) => { + const schema: JsonSchema = { + type: 'object', + patternProperties: { '(': { type: 'string' } }, + }; + t.true(isStructuralMatch(schema, { a: 1 })); +}); + +test('isStructuralMatch - undefined property values do not count as present', (t) => { + const schema: JsonSchema = { + type: 'object', + required: ['a'], + properties: { a: { type: 'string' } }, + additionalProperties: false, + }; + t.false(isStructuralMatch(schema, { a: undefined })); + t.true(isStructuralMatch(schema, { a: 'x', b: undefined })); +});