From 4fd6fc8ac383f44ccb8c8120ce246966525aff36 Mon Sep 17 00:00:00 2001 From: Niklas Maier Date: Thu, 31 Mar 2022 11:32:19 +0200 Subject: [PATCH 1/7] Add support for conditional schema compositions If properties of a schema were added conditionally inside an anyof|oneof|allof list their paths were not resolved correctly. For reference see second example in: https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else --- packages/core/src/util/path.ts | 6 +++--- packages/core/test/util/path.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/core/src/util/path.ts b/packages/core/src/util/path.ts index 401cfd685..5d4971757 100644 --- a/packages/core/src/util/path.ts +++ b/packages/core/src/util/path.ts @@ -57,9 +57,9 @@ export { compose as composePaths }; */ export const toDataPathSegments = (schemaPath: string): string[] => { const s = schemaPath - .replace(/anyOf\/[\d]\//g, '') - .replace(/allOf\/[\d]\//g, '') - .replace(/oneOf\/[\d]\//g, ''); + .replace(/anyOf\/[\d]\/((then|else)\/)?/g, '') + .replace(/allOf\/[\d]\/((then|else)\/)?/g, '') + .replace(/oneOf\/[\d]\/((then|else)\/)?/g, ''); const segments = s.split('/'); const decodedSegments = segments.map(decode); diff --git a/packages/core/test/util/path.test.ts b/packages/core/test/util/path.test.ts index 80cdb24d6..c29367244 100644 --- a/packages/core/test/util/path.test.ts +++ b/packages/core/test/util/path.test.ts @@ -46,12 +46,39 @@ test('toDataPath ', t => { test('toDataPath replace anyOf', t => { t.is(toDataPath('/anyOf/1/properties/foo/anyOf/1/properties/bar'), 'foo.bar'); }); +test('toDataPath replace anyOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/anyOf/1/then/properties/foo'), 'foo'); +}); +test('toDataPath replace multiple directly nested anyOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/anyOf/1/then/anyOf/0/then/properties/foo'), 'foo'); +}); +test('toDataPath replace multiple nested properties with anyOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/anyOf/1/properties/foo/anyOf/0/then/properties/bar'), 'foo.bar'); +}); test('toDataPath replace allOf', t => { t.is(toDataPath('/allOf/1/properties/foo/allOf/1/properties/bar'), 'foo.bar'); }); +test('toDataPath replace allOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/allOf/1/then/properties/foo'), 'foo'); +}); +test('toDataPath replace multiple directly nested allOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/allOf/1/then/allOf/0/then/properties/foo'), 'foo'); +}); +test('toDataPath replace multiple nested properties with allOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/allOf/1/properties/foo/allOf/0/then/properties/bar'), 'foo.bar'); +}); test('toDataPath replace oneOf', t => { t.is(toDataPath('/oneOf/1/properties/foo/oneOf/1/properties/bar'), 'foo.bar'); }); +test('toDataPath replace oneOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/oneOf/1/then/properties/foo'), 'foo'); +}); +test('toDataPath replace multiple directly nested oneOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/oneOf/1/then/oneOf/0/then/properties/foo'), 'foo'); +}); +test('toDataPath replace multiple nested properties with oneOf in combination with conditional schema compositions', t => { + t.is(toDataPath('/oneOf/1/properties/foo/oneOf/0/then/properties/bar'), 'foo.bar'); +}); test('toDataPath replace all combinators', t => { t.is( toDataPath( From 6706e50a6b9a92fb3b421ba038107d1d8404dacc Mon Sep 17 00:00:00 2001 From: Niklas Maier Date: Sun, 17 Apr 2022 15:28:33 +0200 Subject: [PATCH 2/7] Add support to ommit oneOf|allOf|anyOf + then/else inside scope --- packages/core/src/util/resolvers.ts | 20 ++++++++- packages/core/test/util/resolvers.test.ts | 52 ++++++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/core/src/util/resolvers.ts b/packages/core/src/util/resolvers.ts index 5a4c62d70..daa4b2448 100644 --- a/packages/core/src/util/resolvers.ts +++ b/packages/core/src/util/resolvers.ts @@ -134,13 +134,29 @@ export const resolveSchema = ( const schemas = [].concat( resultSchema?.oneOf ?? [], resultSchema?.allOf ?? [], - resultSchema?.anyOf ?? [] + resultSchema?.anyOf ?? [], + // also add root level schema composition entries + schema?.oneOf ?? [], + schema?.allOf ?? [], + schema?.anyOf ?? [] ); - for (let item of schemas) { + for (const item of schemas) { curSchema = resolveSchema(item, validPathSegments.slice(i).map(encode).join('/')); if (curSchema) { break; } + if (!curSchema) { + const conditionalSchemas = [].concat( + item.then ?? [], + item.else ?? [], + ); + for (const condiItem of conditionalSchemas) { + curSchema = resolveSchema(condiItem, schemaPath); + if (curSchema) { + break; + } + } + } } if (curSchema) { // already resolved rest of the path diff --git a/packages/core/test/util/resolvers.test.ts b/packages/core/test/util/resolvers.test.ts index 80f109536..0fb194300 100644 --- a/packages/core/test/util/resolvers.test.ts +++ b/packages/core/test/util/resolvers.test.ts @@ -53,15 +53,65 @@ test('resolveSchema - resolves schema with any ', t => { } }] } - } + }, + anyOf: [{ + type: 'object', + properties: { + test: { + type: 'boolean' + } + } + }, + { + if: { + properties: { + exist: { + const: true + } + } + }, + then: { + properties: { + lastname: { + type: 'string' + } + } + }, + else: { + properties: { + firstname: { + type: 'string' + }, + address: { + type: 'object', + anyOf: [ + { + properties: { + street: { + type: 'string' + } + } + } + ] + } + } + } + } + ] }; // test backward compatibility t.deepEqual(resolveSchema(schema, '#/properties/description/oneOf/0/properties/name'), {type: 'string'}); t.deepEqual(resolveSchema(schema, '#/properties/description/oneOf/1/properties/index'), {type: 'number'}); + t.deepEqual(resolveSchema(schema, '#/anyOf/0/then/properties/lastname'), {type: 'string'}); + t.deepEqual(resolveSchema(schema, '#/anyOf/0/else/properties/firstname'), {type: 'string'}); + t.deepEqual(resolveSchema(schema, '#/anyOf/0/else/properties/address/anyOf/0/properties/street'), {type: 'string'}); // new simple approach t.deepEqual(resolveSchema(schema, '#/properties/description/properties/name'), {type: 'string'}); t.deepEqual(resolveSchema(schema, '#/properties/description/properties/index'), {type: 'number'}); t.deepEqual(resolveSchema(schema, '#/properties/description/properties/exist'), {type: 'boolean'}); + t.deepEqual(resolveSchema(schema, '#/properties/lastname'), {type: 'string'}); + t.deepEqual(resolveSchema(schema, '#/properties/firstname'), {type: 'string'}); + t.deepEqual(resolveSchema(schema, '#/properties/address/properties/street'), {type: 'string'}); t.is(resolveSchema(schema, '#/properties/description/properties/notfound'), undefined); }); From 33eb9598d45e8d162d24048d5cd03b61b086cfff Mon Sep 17 00:00:00 2001 From: Niklas Maier Date: Sun, 17 Apr 2022 15:57:39 +0200 Subject: [PATCH 3/7] Add example --- .../conditional-schema-compositions.ts | 118 ++++++++++++++++++ packages/examples/src/index.ts | 4 +- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 packages/examples/src/examples/conditional-schema-compositions.ts diff --git a/packages/examples/src/examples/conditional-schema-compositions.ts b/packages/examples/src/examples/conditional-schema-compositions.ts new file mode 100644 index 000000000..98c7b3b6f --- /dev/null +++ b/packages/examples/src/examples/conditional-schema-compositions.ts @@ -0,0 +1,118 @@ +/* + 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 { registerExamples } from '../register'; + +export const schema = { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + description: 'The task\'s name', + }, + recurrence: { + type: 'string', + enum: ['Never', 'Daily', 'Weekly', 'Monthly'], + }, + }, + anyOf: [ + { + if: { + properties: { + recurrence: { + const: 'Never' + } + } + }, + then: { + properties: { + lastname: { + type: 'string' + }, + age: { + type: 'number' + } + } + } + }, + ] +}; + +export const uischema = { + type: 'HorizontalLayout', + elements: [ + { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/name', + }, + { + type: 'Control', + scope: '#/properties/recurrence', + }, + { + type: 'Control', + scope: '#/anyOf/0/then/properties/lastname', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/recurrence', + schema: { + const: 'Never' + } + } + } + }, + { + type: 'Control', + scope: '#/properties/age', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/recurrence', + schema: { + const: 'Never' + } + } + } + }, + ], + }, + ], +}; + +const data = {}; + +registerExamples([ + { + name: 'conditional-schema-compositions', + label: 'Conditional Schema Compositions', + data, + schema, + uischema + } +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index fc173b516..4694fcc7b 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -76,6 +76,7 @@ import * as enumInArray from './examples/enumInArray'; import * as readonly from './examples/readonly'; import * as bug_1779 from './examples/1779'; import * as bug_1645 from './examples/1645'; +import * as conditionalSchemaComposition from './examples/conditional-schema-compositions'; export * from './register'; export * from './example'; @@ -136,5 +137,6 @@ export { enumInArray, readonly, bug_1779, - bug_1645 + bug_1645, + conditionalSchemaComposition }; From 7ce3e05d28049a5e856b282a2f7f1542b206d3c2 Mon Sep 17 00:00:00 2001 From: Niklas Maier Date: Tue, 19 Apr 2022 09:05:50 +0200 Subject: [PATCH 4/7] Remove obsolete test data --- packages/core/test/util/resolvers.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/core/test/util/resolvers.test.ts b/packages/core/test/util/resolvers.test.ts index 0fb194300..d0c5e4bcc 100644 --- a/packages/core/test/util/resolvers.test.ts +++ b/packages/core/test/util/resolvers.test.ts @@ -54,14 +54,7 @@ test('resolveSchema - resolves schema with any ', t => { }] } }, - anyOf: [{ - type: 'object', - properties: { - test: { - type: 'boolean' - } - } - }, + anyOf: [ { if: { properties: { From 7d543eb553f43f3624ad36417c3a45274258ee48 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Wed, 11 May 2022 16:24:36 +0200 Subject: [PATCH 5/7] Rewrite resolver --- packages/core/src/util/path.ts | 7 +- packages/core/src/util/resolvers.ts | 111 ++++++++++++++-------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/packages/core/src/util/path.ts b/packages/core/src/util/path.ts index 5d4971757..b62ed6afc 100644 --- a/packages/core/src/util/path.ts +++ b/packages/core/src/util/path.ts @@ -57,9 +57,8 @@ export { compose as composePaths }; */ export const toDataPathSegments = (schemaPath: string): string[] => { const s = schemaPath - .replace(/anyOf\/[\d]\/((then|else)\/)?/g, '') - .replace(/allOf\/[\d]\/((then|else)\/)?/g, '') - .replace(/oneOf\/[\d]\/((then|else)\/)?/g, ''); + .replace(/(anyOf|allOf|oneOf)\/[\d]\//g, '') + .replace(/(then|else)\//g, ''); const segments = s.split('/'); const decodedSegments = segments.map(decode); @@ -79,7 +78,7 @@ export const toDataPathSegments = (schemaPath: string): string[] => { */ export const toDataPath = (schemaPath: string): string => { return toDataPathSegments(schemaPath).join('.'); -}; + }; export const composeWithUi = (scopableUi: Scopable, path: string): string => { const segments = toDataPathSegments(scopableUi.scope); diff --git a/packages/core/src/util/resolvers.ts b/packages/core/src/util/resolvers.ts index 71af92569..b27c016c3 100644 --- a/packages/core/src/util/resolvers.ts +++ b/packages/core/src/util/resolvers.ts @@ -25,8 +25,8 @@ import isEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; -import { JsonSchema } from '../models'; -import { decode, encode } from './path'; +import { JsonSchema, JsonSchema7 } from '../models'; +import { decode } from './path'; /** * Map for storing refs and the respective schemas they are pointing to. @@ -112,66 +112,67 @@ export const resolveSchema = ( schema: JsonSchema, schemaPath: string, rootSchema: JsonSchema +): JsonSchema => { + const segments = schemaPath.split('/').map(decode); + return resolveSchemaWithSegments(schema, segments, rootSchema); +}; + +const resolveSchemaWithSegments = ( + schema: JsonSchema, + pathSegments: string[], + rootSchema: JsonSchema ): JsonSchema => { if (isEmpty(schema)) { return undefined; } - const validPathSegments = schemaPath.split('/').map(decode); - let resultSchema = schema; - for (let i = 0; i < validPathSegments.length; i++) { - let pathSegment = validPathSegments[i]; - resultSchema = - resultSchema === undefined || resultSchema.$ref === undefined - ? resultSchema - // use rootSchema as value for schema, since schema is undefined or a ref - : resolveSchema(rootSchema, resultSchema.$ref, rootSchema); - if (invalidSegment(pathSegment)) { - // skip invalid segments - continue; - } - let curSchema = get(resultSchema, pathSegment); - if (!curSchema) { - // resolving was not successful, check whether the scope omitted an oneOf, allOf or anyOf and resolve anyway - const schemas = [].concat( - resultSchema?.oneOf ?? [], - resultSchema?.allOf ?? [], - resultSchema?.anyOf ?? [], - // also add root level schema composition entries - schema?.oneOf ?? [], - schema?.allOf ?? [], - schema?.anyOf ?? [] + + if (schema.$ref) { + schema = resolveSchema(rootSchema, schema.$ref, rootSchema); + } + + if (pathSegments.length === 0) { + return schema; + } + + const [segment, ...remainingSegments] = pathSegments; + + if (invalidSegment(segment)) { + return resolveSchemaWithSegments(schema, remainingSegments, rootSchema); + } + + const singleSegmentResolveSchema = get(schema, segment); + + const resolvedSchema = resolveSchemaWithSegments(singleSegmentResolveSchema, remainingSegments, rootSchema); + if (resolvedSchema) { + return resolvedSchema; + } + + if (segment === 'properties' || segment === 'items') { + // Let's try to resolve the path, assuming oneOf/allOf/anyOf/then/else was omitted. + // We only do this when traversing an object or array as we want to avoid + // following a property which is named oneOf, allOf, anyOf, then or else. + let alternativeResolveResult = undefined; + + const subSchemas = [].concat( + schema.oneOf ?? [], + schema.allOf ?? [], + schema.anyOf ?? [], + (schema as JsonSchema7).then ?? [], + (schema as JsonSchema7).else ?? [] + ); + + for (const subSchema of subSchemas) { + alternativeResolveResult = resolveSchemaWithSegments( + subSchema, + [segment, ...remainingSegments], + rootSchema ); - for (const item of schemas) { - curSchema = resolveSchema(item, validPathSegments.slice(i).map(encode).join('/'), rootSchema); - if (curSchema) { - break; - } - if (!curSchema) { - const conditionalSchemas = [].concat( - item.then ?? [], - item.else ?? [], - ); - for (const condiItem of conditionalSchemas) { - curSchema = resolveSchema(condiItem, schemaPath); - if (curSchema) { - break; - } - } - } - } - if (curSchema) { - // already resolved rest of the path - resultSchema = curSchema; + if (alternativeResolveResult) { break; } } - resultSchema = curSchema; + return alternativeResolveResult; } - if (resultSchema !== undefined && resultSchema.$ref !== undefined) { - return resolveSchema(rootSchema, resultSchema.$ref, rootSchema) - ?? schema; - } - - return resultSchema; -}; + return undefined; +} \ No newline at end of file From 85a8fbcccdd25ccc0a00d79e60e3d68a8fb3e3ae Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Thu, 12 May 2022 08:36:43 +0200 Subject: [PATCH 6/7] Add sanity check in resolveSchema --- packages/core/src/util/resolvers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/util/resolvers.ts b/packages/core/src/util/resolvers.ts index b27c016c3..ad3d89c85 100644 --- a/packages/core/src/util/resolvers.ts +++ b/packages/core/src/util/resolvers.ts @@ -113,7 +113,7 @@ export const resolveSchema = ( schemaPath: string, rootSchema: JsonSchema ): JsonSchema => { - const segments = schemaPath.split('/').map(decode); + const segments = schemaPath?.split('/').map(decode); return resolveSchemaWithSegments(schema, segments, rootSchema); }; @@ -130,7 +130,7 @@ const resolveSchemaWithSegments = ( schema = resolveSchema(rootSchema, schema.$ref, rootSchema); } - if (pathSegments.length === 0) { + if (!pathSegments || pathSegments.length === 0) { return schema; } From 8dd48528035309508ca8ac003dc773dca91d1193 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Thu, 12 May 2022 12:39:08 +0200 Subject: [PATCH 7/7] Remove testing artifact --- packages/core/test/util/resolvers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/util/resolvers.test.ts b/packages/core/test/util/resolvers.test.ts index b5ace8397..0efc7dc31 100644 --- a/packages/core/test/util/resolvers.test.ts +++ b/packages/core/test/util/resolvers.test.ts @@ -25,7 +25,7 @@ import { resolveSchema } from '../../src/util/resolvers'; import test from 'ava'; -test.only('resolveSchema - resolves schema with any ', t => { +test('resolveSchema - resolves schema with any ', t => { const schema = { $defs: { Base: {