From 7e3f68e3ef841cf51ea003450b9a90f3d5426206 Mon Sep 17 00:00:00 2001 From: Lucas Koehler Date: Wed, 14 Jan 2026 14:38:19 +0100 Subject: [PATCH] fix(core): Fix validation errors for nested props of same name - Remove obsolete safeguard against adding a property name a second time to a validation error's control path. - Add comment in errors.ts to clarify behavior - Add unit tests for getControlPath and errorAt - Add example for nested required property of same name fixes #2521 --- packages/core/src/util/errors.ts | 13 +- packages/core/test/reducers/core.test.ts | 429 ++++++++++++++++++ .../examples/validation-nested-same-name.ts | 69 +++ packages/examples/src/index.ts | 2 + 4 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 packages/examples/src/examples/validation-nested-same-name.ts diff --git a/packages/core/src/util/errors.ts b/packages/core/src/util/errors.ts index 42cd384b85..46fcdfca7c 100644 --- a/packages/core/src/util/errors.ts +++ b/packages/core/src/util/errors.ts @@ -30,6 +30,15 @@ import { isOneOfEnumSchema } from './schema'; import filter from 'lodash/filter'; import isEqual from 'lodash/isEqual'; +/** + * Checks for an additionally specified property that the error relates to. + * This may be added to an error's instancePath to show it add the violating property's control. + * For example, for required property errors, the instancePath points to the object containing the required property. + * The missing property's name is specified in the error's params.missingProperty field and returned by this function. + * + * @param error The ErrorObject to check for an additionally specified property that the error relates to + * @returns The invalid property name if present, otherwise undefined + */ const getInvalidProperty = (error: ErrorObject): string | undefined => { switch (error.keyword) { case 'required': @@ -51,12 +60,12 @@ export const getControlPath = (error: ErrorObject) => { controlPath = controlPath.replace(/\//g, '.'); const invalidProperty = getInvalidProperty(error); - if (invalidProperty !== undefined && !controlPath.endsWith(invalidProperty)) { + if (invalidProperty !== undefined) { controlPath = `${controlPath}.${invalidProperty}`; } // remove '.' chars at the beginning of paths - controlPath = controlPath.replace(/^./, ''); + controlPath = controlPath.replace(/^\./, ''); // decode JSON Pointer escape sequences controlPath = decode(controlPath); diff --git a/packages/core/test/reducers/core.test.ts b/packages/core/test/reducers/core.test.ts index c6e70dbafc..20daf89a54 100644 --- a/packages/core/test/reducers/core.test.ts +++ b/packages/core/test/reducers/core.test.ts @@ -1870,3 +1870,432 @@ test('core reducer helpers - getControlPath - decodes JSON Pointer escape sequen const controlPath = getControlPath(errorObject); t.is(controlPath, '~group./name'); }); + +test('errorAt filters required with nested same-named properties', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + name: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + }, + }, + }; + const data = { name: {} }; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filtered = errorAt( + 'name.name', + (schema.properties.name as JsonSchema).properties.name + )(state); + t.is(filtered.length, 1); + t.is(filtered[0].keyword, 'required'); + t.is(filtered[0].params.missingProperty, 'name'); +}); + +test('errorAt filters triple-nested same-named properties', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + foo: { + type: 'object', + properties: { + foo: { + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + required: ['foo'], + }, + }, + }, + }, + }; + const data = { foo: { foo: {} } }; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filtered = errorAt( + 'foo.foo.foo', + ((schema.properties.foo as JsonSchema).properties.foo as JsonSchema) + .properties.foo + )(state); + t.is(filtered.length, 1); + t.is(filtered[0].keyword, 'required'); + t.is(filtered[0].params.missingProperty, 'foo'); +}); + +test('errorAt filters parent-child with different names', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + parent: { + type: 'object', + properties: { + child: { + type: 'string', + }, + }, + required: ['child'], + }, + }, + }; + const data = { parent: {} }; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filtered = errorAt( + 'parent.child', + (schema.properties.parent as JsonSchema).properties.child + )(state); + t.is(filtered.length, 1); + t.is(filtered[0].keyword, 'required'); + t.is(filtered[0].params.missingProperty, 'child'); +}); + +test('errorAt filters substring property names edge case', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + username: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + }, + }, + }; + const data = { username: {} }; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filtered = errorAt( + 'username.name', + (schema.properties.username as JsonSchema).properties.name + )(state); + t.is(filtered.length, 1); + t.is(filtered[0].keyword, 'required'); + t.is(filtered[0].params.missingProperty, 'name'); +}); + +test('errorAt filters root-level required errors', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + age: { + type: 'number', + }, + }, + required: ['name', 'age'], + }; + const data = {}; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filteredName = errorAt('name', schema.properties.name)(state); + t.is(filteredName.length, 1); + t.is(filteredName[0].keyword, 'required'); + t.is(filteredName[0].params.missingProperty, 'name'); + + const filteredAge = errorAt('age', schema.properties.age)(state); + t.is(filteredAge.length, 1); + t.is(filteredAge[0].keyword, 'required'); + t.is(filteredAge[0].params.missingProperty, 'age'); +}); + +test('errorAt filters array of objects with nested same-named properties', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + item: { + type: 'object', + properties: { + item: { + type: 'string', + }, + }, + required: ['item'], + }, + }, + }, + }, + }, + }; + const data = { items: [{ item: {} }] }; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filtered = errorAt( + 'items.0.item.item', + ((schema.properties.items as JsonSchema).items as JsonSchema).properties + .item.properties.item + )(state); + t.is(filtered.length, 1); + t.is(filtered[0].keyword, 'required'); + t.is(filtered[0].params.missingProperty, 'item'); +}); + +test('errorAt does not match wrong paths', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + name: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + }, + }, + }; + const data = { name: {} }; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filteredWrongPath = errorAt('name', schema.properties.name)(state); + t.is(filteredWrongPath.length, 0); +}); + +test('errorAt filters multiple required errors with mixed naming', (t) => { + const ajv = createAjv(); + const schema: JsonSchema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + user: { + type: 'string', + }, + name: { + type: 'string', + }, + }, + required: ['user', 'name'], + }, + }, + }; + const data = { user: {} }; + const v = ajv.compile(schema); + const errors = validate(v, data); + + const state: JsonFormsCore = { + data, + schema, + uischema: undefined, + errors, + }; + const filteredUser = errorAt( + 'user.user', + (schema.properties.user as JsonSchema).properties.user + )(state); + t.is(filteredUser.length, 1); + t.is(filteredUser[0].keyword, 'required'); + t.is(filteredUser[0].params.missingProperty, 'user'); + + const filteredName = errorAt( + 'user.name', + (schema.properties.user as JsonSchema).properties.name + )(state); + t.is(filteredName.length, 1); + t.is(filteredName[0].keyword, 'required'); + t.is(filteredName[0].params.missingProperty, 'name'); +}); + +// ============================================================================ +// Additional getControlPath Edge Case Tests +// ============================================================================ + +// Dummy path to ensure ErrorObject is valid +const DUMMY_SCHEMA_PATH = ''; + +test('getControlPath - root-level required property', (t) => { + const errorObject = { + instancePath: '', + keyword: 'required', + params: { missingProperty: 'foo' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'foo'); +}); + +test('getControlPath - nested required property', (t) => { + const errorObject = { + instancePath: '/parent', + keyword: 'required', + params: { missingProperty: 'child' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'parent.child'); +}); + +test('getControlPath - prevents duplicate property when path already ends with property', (t) => { + const errorObject = { + instancePath: '/parent/child', + keyword: 'required', + params: { missingProperty: 'child' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'parent.child.child'); +}); + +test('getControlPath - same-named nested properties (name -> name.name)', (t) => { + const errorObject = { + instancePath: '/name', + keyword: 'required', + params: { missingProperty: 'name' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'name.name'); +}); + +test('getControlPath - deeply nested same-named properties (foo.foo -> foo.foo.foo)', (t) => { + const errorObject = { + instancePath: '/foo/foo', + keyword: 'required', + params: { missingProperty: 'foo' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'foo.foo.foo'); +}); + +test('getControlPath - additionalProperties keyword handling', (t) => { + const errorObject = { + instancePath: '/parent', + keyword: 'additionalProperties', + params: { additionalProperty: 'extra' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'parent.extra'); +}); + +test('getControlPath - dependencies keyword handling', (t) => { + const errorObject = { + instancePath: '/parent', + keyword: 'dependencies', + params: { missingProperty: 'dependent' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'parent.dependent'); +}); + +test('getControlPath - property names as substrings (username.name not confused)', (t) => { + const errorObject = { + instancePath: '/username', + keyword: 'required', + params: { missingProperty: 'name' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'username.name'); +}); + +test('getControlPath - array indices in paths', (t) => { + const errorObject = { + instancePath: '/items/0', + keyword: 'required', + params: { missingProperty: 'id' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'items.0.id'); +}); + +test('getControlPath - non-required keywords do not append property', (t) => { + const errorObject = { + instancePath: '/foo', + keyword: 'type', + params: { type: 'string' }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'foo'); +}); + +test('getControlPath - enum keyword does not append property', (t) => { + const errorObject = { + instancePath: '/status', + keyword: 'enum', + params: { allowedValues: ['active', 'inactive'] }, + schemaPath: DUMMY_SCHEMA_PATH, + } as ErrorObject; + const controlPath = getControlPath(errorObject); + t.is(controlPath, 'status'); +}); diff --git a/packages/examples/src/examples/validation-nested-same-name.ts b/packages/examples/src/examples/validation-nested-same-name.ts new file mode 100644 index 0000000000..d35ec762b0 --- /dev/null +++ b/packages/examples/src/examples/validation-nested-same-name.ts @@ -0,0 +1,69 @@ +/* + The MIT License + + Copyright (c) 2026 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 { UISchemaElement } from '@jsonforms/core'; +import { registerExamples } from '../register'; + +export const schema = { + type: 'object', + properties: { + name: { + type: 'object', + properties: { + name: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + }, + }, + required: ['name'], + }, + }, +}; + +export const uischema: UISchemaElement = { + type: 'Control', + scope: '#/properties/name/properties/name/properties/name', + label: 'Name', +}; + +export const data = { + name: { + name: {}, + }, +}; + +registerExamples([ + { + name: 'validation-nested-same-name', + label: 'Validation - 3x nested properties with same name', + data, + schema, + uischema, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index e200dc8e32..4e3da689b9 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -79,6 +79,7 @@ import * as mixed from './examples/mixed'; import * as mixedObject from './examples/mixed-object'; import * as string from './examples/string'; import * as prependAppendSlots from './examples/prepend-append-slots'; +import * as validationNestedSameNameValidation from './examples/validation-nested-same-name'; export * from './register'; export * from './example'; @@ -147,4 +148,5 @@ export { arrayWithDefaults, string, prependAppendSlots, + validationNestedSameNameValidation, };