From 3cd7fcd019612aacaa724aea2bf041c93f04db9f Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Fri, 25 Oct 2024 16:00:19 +0500 Subject: [PATCH 1/9] feat(additional-properties): adds typed expando object schema --- packages/schema/src/types/object.ts | 154 +++++++++++++----- .../schema/test/types/expandoObject.test.ts | 63 ++++++- 2 files changed, 170 insertions(+), 47 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index 77e9fd94..c8a30112 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -4,6 +4,7 @@ import { SchemaMappedType, SchemaType, SchemaValidationError, + validateAndMap, } from '../schema'; import { OptionalizeObject } from '../typeUtils'; import { @@ -61,6 +62,18 @@ export interface ObjectSchema< readonly objectSchema: T; } +export interface ExtendedObjectSchema< + V extends string, + T extends Record, ObjectXmlOptions?]>, + K extends string, + U +> extends Schema< + ObjectType & { [key in K]?: U }, + MappedObjectType & { [key in K]?: U } + > { + readonly objectSchema: T; +} + /** * Create a Strict Object type schema. * @@ -80,7 +93,7 @@ export function strictObject< } /** - * Create an Expandable Object type schema. + * Create an Expandable Object type schema, allowing all additional properties. * * The object schema allows additional properties during mapping and unmapping. The * additional properties are copied over as is. @@ -92,6 +105,25 @@ export function expandoObject< return internalObject(objectSchema, true, true); } +/** + * Create an Expandable Object type schema, allowing only typed additional properties. + * + * The object schema allows additional properties during mapping and unmapping. The + * additional properties are copied over in a Record> + * with key represented by K. + */ +export function typedExpandoObject< + V extends string, + T extends Record, ObjectXmlOptions?]>, + K extends string, + S extends Schema +>( + objectSchema: T, + additionalPropSchema: [K, S] +): ExtendedObjectSchema>> { + return internalObject(objectSchema, true, additionalPropSchema); +} + /** * Create an Object Type schema. * @@ -162,10 +194,10 @@ function internalObject< >( objectSchema: T, skipValidateAdditionalProps: boolean, - mapAdditionalProps: boolean + mapAdditionalProps: boolean | [string, Schema] ): StrictObjectSchema { const keys = Object.keys(objectSchema); - const reverseObjectSchema = createReverseObjectSchema(objectSchema); + const reverseObjectSchema = createReverseObjectSchema(objectSchema); const xmlMappingInfo = getXmlPropMappingForObjectSchema(objectSchema); const xmlObjectSchema = createXmlObjectSchema(objectSchema); const reverseXmlObjectSchema = createReverseXmlObjectSchema(xmlObjectSchema); @@ -251,7 +283,7 @@ function validateObjectBeforeMapXml( function mapObjectFromXml( xmlObjectSchema: XmlObjectSchema, - allowAdditionalProps: boolean + allowAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; const mapElements = mapObject(elementsSchema, 'mapXml', allowAdditionalProps); @@ -295,7 +327,7 @@ function mapObjectFromXml( function unmapObjectToXml( xmlObjectSchema: XmlObjectSchema, - allowAdditionalProps: boolean + allowAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; const mapElements = mapObject( @@ -449,55 +481,102 @@ function validateObject( function mapObject( objectSchema: T, mappingFn: 'map' | 'unmap' | 'mapXml' | 'unmapXml', - allowAdditionalProperties: boolean + mapAdditionalProps: boolean | [string, Schema] ) { return (value: unknown, ctxt: SchemaContextCreator): any => { const output: Record = {}; - const objectValue = value as Record; - /** Properties seen in the object but not in the schema */ - const unknownKeys = new Set(Object.keys(objectValue)); - - // Map known properties using the schema - for (const key in objectSchema) { - if (!Object.prototype.hasOwnProperty.call(objectSchema, key)) { - continue; - } + const objectValue = { ...(value as Record) }; + const isUnmaping = mappingFn === 'unmap' || mappingFn === 'unmapXml'; + + if ( + isUnmaping && + typeof mapAdditionalProps !== 'boolean' && + mapAdditionalProps[0] in objectValue + ) { + // Pre process to flatten additional properties in objectValue + flattenAdditionalProps(objectValue, objectSchema, mapAdditionalProps[0]); + } - const element = objectSchema[key]; + // Map known properties to output using the schema + Object.entries(objectSchema).forEach(([key, element]) => { const propName = element[0]; const propValue = objectValue[propName]; - unknownKeys.delete(propName); + delete objectValue[propName]; if (isOptionalNullable(element[1].type(), propValue)) { if (typeof propValue === 'undefined') { // Skip mapping to avoid creating properties with value 'undefined' - continue; + return; } output[key] = null; - continue; + return; } if (isOptional(element[1].type(), propValue)) { // Skip mapping to avoid creating properties with value 'undefined' - continue; + return; } output[key] = element[1][mappingFn]( propValue, ctxt.createChild(propName, propValue, element[1]) ); - } + }); + + // Copy the additional unknown properties in output when allowed + Object.entries( + extractAdditionalProperties(objectValue, isUnmaping, mapAdditionalProps) + ).forEach(([k, v]) => (output[k] = v)); - // Copy unknown properties over if additional properties flag is set - if (allowAdditionalProperties) { - unknownKeys.forEach((unknownKey) => { - output[unknownKey] = objectValue[unknownKey]; - }); - } return output; }; } +function flattenAdditionalProps( + objectValue: Record, + objectSchema: T, + additionalPropsKey: string +) { + Object.entries(objectValue[additionalPropsKey]).forEach(([key, value]) => { + if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { + key = objectSchema[key][0]; + } + if (!(key in objectValue)) { + objectValue[key] = value; + } + }); + delete objectValue[additionalPropsKey]; +} + +function extractAdditionalProperties( + objectValue: Record, + isUnmaping: boolean, + mapAdditionalProps: boolean | [string, Schema] +): Record { + const additionalPropertiesMap: Record = {}; + + if (!mapAdditionalProps) { + return additionalPropertiesMap; + } + + Object.entries(objectValue).forEach(([key, value]) => { + if (typeof mapAdditionalProps !== 'boolean') { + const mappingResult = validateAndMap(value, mapAdditionalProps[1]); + if (mappingResult.errors) { + return; + } + value = mappingResult.result; + } + additionalPropertiesMap[key] = value; + }); + + if (typeof mapAdditionalProps !== 'boolean' && !isUnmaping) { + return { [mapAdditionalProps[0]]: additionalPropertiesMap }; + } + + return additionalPropertiesMap; +} + function getXmlPropMappingForObjectSchema(objectSchema: AnyObjectSchema) { const elementsToProps: Record = {}; const attributesToProps: Record = {}; @@ -531,19 +610,16 @@ function getPropMappingForObjectSchema( return propsMapping; } -function createReverseObjectSchema( +const createReverseObjectSchema = ( objectSchema: T -): AnyObjectSchema { - const reverseObjectSchema: AnyObjectSchema = {}; - for (const key in objectSchema) { - /* istanbul ignore else */ - if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { - const element = objectSchema[key]; - reverseObjectSchema[element[0]] = [key, element[1], element[2]]; - } - } - return reverseObjectSchema; -} +): AnyObjectSchema => + Object.entries(objectSchema).reduce( + (result, [key, element]) => ({ + ...result, + [element[0]]: [key, element[1], element[2]], + }), + {} as AnyObjectSchema + ); interface XmlObjectSchema { elementsSchema: AnyObjectSchema; diff --git a/packages/schema/test/types/expandoObject.test.ts b/packages/schema/test/types/expandoObject.test.ts index 329288cc..fa0777ef 100644 --- a/packages/schema/test/types/expandoObject.test.ts +++ b/packages/schema/test/types/expandoObject.test.ts @@ -7,15 +7,24 @@ import { string, validateAndMap, validateAndUnmap, + typedExpandoObject, } from '../../src'; describe('Expando Object', () => { - describe('Mapping', () => { - const userSchema = expandoObject({ + const userSchema = expandoObject({ + id: ['user_id', string()], + age: ['user_age', number()], + }); + + const userSchemaWithAdditionalNumbers = typedExpandoObject( + { id: ['user_id', string()], age: ['user_age', number()], - }); + }, + ['additionalProps', number()] + ); + describe('Mapping', () => { it('should map valid object', () => { const input = { user_id: 'John Smith', @@ -30,6 +39,27 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); + it('should map with additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + number1: 123, + number2: 123.2, + invalid: 'string value', + }; + const output = validateAndMap(input, userSchemaWithAdditionalNumbers); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + additionalProps: { + number1: 123, + number2: 123.2, + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + it('should map object with optional properties', () => { const addressSchema = expandoObject({ address1: ['address1', string()], @@ -147,12 +177,8 @@ describe('Expando Object', () => { `); }); }); - describe('Unmapping', () => { - const userSchema = expandoObject({ - id: ['user_id', string()], - age: ['user_age', number()], - }); + describe('Unmapping', () => { it('should map valid object', () => { const input = { id: 'John Smith', @@ -167,6 +193,27 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); + it('should map with additional properties', () => { + const input = { + id: 'John Smith', + age: 50, // takes precedence over additionalProps[user_age] + additionalProps: { + number1: 123, + number2: 123.2, + user_age: 52, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + const expected = { + user_id: 'John Smith', + user_age: 50, + number1: 123, + number2: 123.2, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + it('should map object with optional properties', () => { const addressSchema = expandoObject({ address1: ['address1', string()], From 9e5fce3f5ad92d02256d63e0a975662ae6eff3b5 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Mon, 28 Oct 2024 17:40:53 +0500 Subject: [PATCH 2/9] feat: adds additional properties validation for unmapping --- packages/schema/src/schema.ts | 4 +- packages/schema/src/types/object.ts | 212 +++++++++--------- .../schema/test/types/expandoObject.test.ts | 191 ++++++++++++++-- 3 files changed, 289 insertions(+), 118 deletions(-) diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index a7604f4a..f974f20b 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -210,8 +210,8 @@ function createSchemaContextCreator( createSchemaContextCreator({ value, type: childSchema.type(), - branch: [...currentContext.branch, value], - path: [...currentContext.path, key], + branch: currentContext.branch.concat(value), + path: currentContext.path.concat(key), strictValidation: currentContext.strictValidation, }); diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index c8a30112..92e83a29 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -120,7 +120,7 @@ export function typedExpandoObject< >( objectSchema: T, additionalPropSchema: [K, S] -): ExtendedObjectSchema>> { +): ExtendedObjectSchema> { return internalObject(objectSchema, true, additionalPropSchema); } @@ -193,7 +193,7 @@ function internalObject< T extends Record, ObjectXmlOptions?]> >( objectSchema: T, - skipValidateAdditionalProps: boolean, + skipAdditionalPropValidation: boolean, mapAdditionalProps: boolean | [string, Schema] ): StrictObjectSchema { const keys = Object.keys(objectSchema); @@ -206,19 +206,22 @@ function internalObject< validateBeforeMap: validateObject( objectSchema, 'validateBeforeMap', - skipValidateAdditionalProps + skipAdditionalPropValidation, + mapAdditionalProps ), validateBeforeUnmap: validateObject( reverseObjectSchema, 'validateBeforeUnmap', - skipValidateAdditionalProps + skipAdditionalPropValidation, + mapAdditionalProps ), map: mapObject(objectSchema, 'map', mapAdditionalProps), unmap: mapObject(reverseObjectSchema, 'unmap', mapAdditionalProps), validateBeforeMapXml: validateObjectBeforeMapXml( objectSchema, xmlMappingInfo, - skipValidateAdditionalProps + skipAdditionalPropValidation, + mapAdditionalProps ), mapXml: mapObjectFromXml(xmlObjectSchema, mapAdditionalProps), unmapXml: unmapObjectToXml(reverseXmlObjectSchema, mapAdditionalProps), @@ -229,7 +232,8 @@ function internalObject< function validateObjectBeforeMapXml( objectSchema: Record, ObjectXmlOptions?]>, xmlMappingInfo: ReturnType, - allowAdditionalProperties: boolean + skipAdditionalPropValidation: boolean, + mapAdditionalProps: boolean | [string, Schema] ) { const { elementsToProps, attributesToProps } = xmlMappingInfo; return ( @@ -254,30 +258,32 @@ function validateObjectBeforeMapXml( const attributes = attrs ?? {}; // Validate all known elements and attributes using the schema - return [ - ...validateValueObject({ - validationMethod: 'validateBeforeMapXml', - propTypeName: 'child elements', - propTypePrefix: 'element', - valueTypeName: 'element', - propMapping: elementsToProps, - objectSchema, - valueObject: elements, - ctxt, - allowAdditionalProperties, - }), - ...validateValueObject({ - validationMethod: 'validateBeforeMapXml', - propTypeName: 'attributes', - propTypePrefix: '@', - valueTypeName: 'element', - propMapping: attributesToProps, + + return validateValueObject( + 'validateBeforeMapXml', + 'child elements', + 'element', + 'element', + elementsToProps, + objectSchema, + elements, + ctxt, + skipAdditionalPropValidation, + mapAdditionalProps + ).concat( + validateValueObject( + 'validateBeforeMapXml', + 'attributes', + '@', + 'element', + attributesToProps, objectSchema, - valueObject: attributes, + attributes, ctxt, - allowAdditionalProperties, - }), - ]; + skipAdditionalPropValidation, + mapAdditionalProps + ) + ); }; } @@ -369,34 +375,49 @@ function unmapObjectToXml( }; } -function validateValueObject({ - validationMethod, - propTypeName, - propTypePrefix, - valueTypeName, - propMapping, - objectSchema, - valueObject, - ctxt, - allowAdditionalProperties, -}: { +function validateValueObject( validationMethod: | 'validateBeforeMap' | 'validateBeforeUnmap' - | 'validateBeforeMapXml'; - propTypeName: string; - propTypePrefix: string; - valueTypeName: string; - propMapping: Record; - objectSchema: AnyObjectSchema; - valueObject: { [key: string]: unknown }; - ctxt: SchemaContextCreator; - allowAdditionalProperties: boolean; -}) { + | 'validateBeforeMapXml', + propTypeName: string, + propTypePrefix: string, + valueTypeName: string, + propMapping: Record, + objectSchema: AnyObjectSchema, + valueObject: { [key: string]: any }, + ctxt: SchemaContextCreator, + skipAdditionalPropValidation: boolean, + mapAdditionalProps: boolean | [string, Schema] +) { const errors: SchemaValidationError[] = []; const missingProps: Set = new Set(); const unknownProps: Set = new Set(Object.keys(valueObject)); + if ( + validationMethod !== 'validateBeforeMap' && + typeof mapAdditionalProps !== 'boolean' && + mapAdditionalProps[0] in valueObject + ) { + Object.entries(valueObject[mapAdditionalProps[0]]).forEach(([key, _]) => { + if (key.trim().length === 0) { + ctxt + .fail( + 'The additional property key should not be empty or whitespace.' + ) + .forEach((e) => errors.push(e)); + } + + if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { + ctxt + .fail( + `An additional property key, '${key}' conflicts with one of the model's properties.` + ) + .forEach((e) => errors.push(e)); + } + }); + } + // Validate all known properties using the schema for (const key in propMapping) { if (Object.prototype.hasOwnProperty.call(propMapping, key)) { @@ -404,12 +425,10 @@ function validateValueObject({ const schema = objectSchema[propName][1]; unknownProps.delete(key); if (key in valueObject) { - errors.push( - ...schema[validationMethod]( - valueObject[key], - ctxt.createChild(propTypePrefix + key, valueObject[key], schema) - ) - ); + schema[validationMethod]( + valueObject[key], + ctxt.createChild(propTypePrefix + key, valueObject[key], schema) + ).forEach((e) => errors.push(e)); } else if (!isOptionalOrNullableType(schema.type())) { // Add to missing keys if it is not an optional property missingProps.add(key); @@ -419,26 +438,26 @@ function validateValueObject({ // Create validation error for unknown properties encountered const unknownPropsArray = Array.from(unknownProps); - if (unknownPropsArray.length > 0 && !allowAdditionalProperties) { - errors.push( - ...ctxt.fail( + if (unknownPropsArray.length > 0 && !skipAdditionalPropValidation) { + ctxt + .fail( `Some unknown ${propTypeName} were found in the ${valueTypeName}: ${unknownPropsArray .map(literalToString) .join(', ')}.` ) - ); + .forEach((e) => errors.push(e)); } // Create validation error for missing required properties const missingPropsArray = Array.from(missingProps); if (missingPropsArray.length > 0) { - errors.push( - ...ctxt.fail( + ctxt + .fail( `Some ${propTypeName} are missing in the ${valueTypeName}: ${missingPropsArray .map(literalToString) .join(', ')}.` ) - ); + .forEach((e) => errors.push(e)); } return errors; @@ -450,7 +469,8 @@ function validateObject( | 'validateBeforeMap' | 'validateBeforeUnmap' | 'validateBeforeMapXml', - allowAdditionalProperties: boolean + skipAdditionalPropValidation: boolean, + mapAdditionalProps: boolean | [string, Schema] ) { const propsMapping = getPropMappingForObjectSchema(objectSchema); return (value: unknown, ctxt: SchemaContextCreator) => { @@ -464,17 +484,18 @@ function validateObject( }' but found 'Array<${typeof value}>'.` ); } - return validateValueObject({ + return validateValueObject( validationMethod, - propTypeName: 'properties', - propTypePrefix: '', - valueTypeName: 'object', - propMapping: propsMapping, + 'properties', + '', + 'object', + propsMapping, objectSchema, - valueObject: value as Record, + value as Record, ctxt, - allowAdditionalProperties, - }); + skipAdditionalPropValidation, + mapAdditionalProps + ); }; } @@ -494,7 +515,10 @@ function mapObject( mapAdditionalProps[0] in objectValue ) { // Pre process to flatten additional properties in objectValue - flattenAdditionalProps(objectValue, objectSchema, mapAdditionalProps[0]); + Object.entries(objectValue[mapAdditionalProps[0]]).forEach( + ([k, v]) => (objectValue[k] = v) + ); + delete objectValue[mapAdditionalProps[0]]; } // Map known properties to output using the schema @@ -532,49 +556,35 @@ function mapObject( }; } -function flattenAdditionalProps( - objectValue: Record, - objectSchema: T, - additionalPropsKey: string -) { - Object.entries(objectValue[additionalPropsKey]).forEach(([key, value]) => { - if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { - key = objectSchema[key][0]; - } - if (!(key in objectValue)) { - objectValue[key] = value; - } - }); - delete objectValue[additionalPropsKey]; -} - function extractAdditionalProperties( objectValue: Record, isUnmaping: boolean, mapAdditionalProps: boolean | [string, Schema] ): Record { - const additionalPropertiesMap: Record = {}; + const properties: Record = {}; if (!mapAdditionalProps) { - return additionalPropertiesMap; + return properties; } - Object.entries(objectValue).forEach(([key, value]) => { - if (typeof mapAdditionalProps !== 'boolean') { - const mappingResult = validateAndMap(value, mapAdditionalProps[1]); - if (mappingResult.errors) { - return; - } - value = mappingResult.result; + if (typeof mapAdditionalProps === 'boolean') { + Object.entries(objectValue).forEach(([k, v]) => (properties[k] = v)); + return properties; + } + + Object.entries(objectValue).forEach(([k, v]) => { + const mappingResult = validateAndMap({ [k]: v }, mapAdditionalProps[1]); + if (mappingResult.errors) { + return; } - additionalPropertiesMap[key] = value; + properties[k] = mappingResult.result[k]; }); - if (typeof mapAdditionalProps !== 'boolean' && !isUnmaping) { - return { [mapAdditionalProps[0]]: additionalPropertiesMap }; + if (isUnmaping || Object.entries(properties).length === 0) { + return properties; } - return additionalPropertiesMap; + return { [mapAdditionalProps[0]]: properties }; } function getXmlPropMappingForObjectSchema(objectSchema: AnyObjectSchema) { diff --git a/packages/schema/test/types/expandoObject.test.ts b/packages/schema/test/types/expandoObject.test.ts index fa0777ef..69281872 100644 --- a/packages/schema/test/types/expandoObject.test.ts +++ b/packages/schema/test/types/expandoObject.test.ts @@ -8,6 +8,7 @@ import { validateAndMap, validateAndUnmap, typedExpandoObject, + dict, } from '../../src'; describe('Expando Object', () => { @@ -21,7 +22,7 @@ describe('Expando Object', () => { id: ['user_id', string()], age: ['user_age', number()], }, - ['additionalProps', number()] + ['additionalProps', optional(dict(number()))] ); describe('Mapping', () => { @@ -39,7 +40,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); - it('should map with additional properties', () => { + it('AdditionalProperties: should map with additional properties', () => { const input = { user_id: 'John Smith', user_age: 50, @@ -60,6 +61,35 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); + it('AdditionalProperties: should map with only invalid additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + invalid: 'string value', + }; + const output = validateAndMap(input, userSchemaWithAdditionalNumbers); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should map without additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + }; + const output = validateAndMap(input, userSchemaWithAdditionalNumbers); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + it('should map object with optional properties', () => { const addressSchema = expandoObject({ address1: ['address1', string()], @@ -179,36 +209,49 @@ describe('Expando Object', () => { }); describe('Unmapping', () => { - it('should map valid object', () => { + it('AdditionalProperties: should map with additional properties', () => { const input = { id: 'John Smith', - age: 50, + age: 50, // takes precedence over additionalProps[user_age] + additionalProps: { + number1: 123, + number2: 123.2, + }, }; - const output = validateAndUnmap(input, userSchema); - const expected: SchemaMappedType = { + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + const expected = { user_id: 'John Smith', user_age: 50, + number1: 123, + number2: 123.2, }; expect(output.errors).toBeFalsy(); expect((output as any).result).toStrictEqual(expected); }); - it('should map with additional properties', () => { + it('AdditionalProperties: should map without additional properties', () => { const input = { id: 'John Smith', - age: 50, // takes precedence over additionalProps[user_age] - additionalProps: { - number1: 123, - number2: 123.2, - user_age: 52, - }, + age: 50, }; const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); const expected = { user_id: 'John Smith', user_age: 50, - number1: 123, - number2: 123.2, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('should map valid object', () => { + const input = { + id: 'John Smith', + age: 50, + }; + const output = validateAndUnmap(input, userSchema); + const expected: SchemaMappedType = { + user_id: 'John Smith', + user_age: 50, }; expect(output.errors).toBeFalsy(); expect((output as any).result).toStrictEqual(expected); @@ -243,6 +286,124 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); + it('AdditionalProperties: should fail with conflicting additional properties', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + number1: 123, + number2: 123.2, + user_age: 52, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + expect(output.errors).toHaveLength(1); + expect(output.errors).toMatchInlineSnapshot(` + Array [ + Object { + "branch": Array [ + Object { + "additionalProps": Object { + "number1": 123, + "number2": 123.2, + "user_age": 52, + }, + "age": 50, + "id": "John Smith", + }, + ], + "message": "An additional property key, 'user_age' conflicts with one of the model's properties. + + Given value: {\\"id\\":\\"John Smith\\",\\"age\\":50,\\"additionalProps\\":{\\"number1\\":123,\\"number2\\":123.2,\\"user_age\\":52}} + Type: 'object' + Expected type: 'Object<{id,age,...}>'", + "path": Array [], + "type": "Object<{id,age,...}>", + "value": Object { + "additionalProps": Object { + "number1": 123, + "number2": 123.2, + "user_age": 52, + }, + "age": 50, + "id": "John Smith", + }, + }, + ] + `); + }); + + it('AdditionalProperties: should fail with empty or blank additional property keys', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + ' ': 123.2, + '': 52, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + expect(output.errors).toHaveLength(2); + expect(output.errors).toMatchInlineSnapshot(` + Array [ + Object { + "branch": Array [ + Object { + "additionalProps": Object { + "": 52, + " ": 123.2, + }, + "age": 50, + "id": "John Smith", + }, + ], + "message": "The additional property key should not be empty or whitespace. + + Given value: {\\"id\\":\\"John Smith\\",\\"age\\":50,\\"additionalProps\\":{\\" \\":123.2,\\"\\":52}} + Type: 'object' + Expected type: 'Object<{id,age,...}>'", + "path": Array [], + "type": "Object<{id,age,...}>", + "value": Object { + "additionalProps": Object { + "": 52, + " ": 123.2, + }, + "age": 50, + "id": "John Smith", + }, + }, + Object { + "branch": Array [ + Object { + "additionalProps": Object { + "": 52, + " ": 123.2, + }, + "age": 50, + "id": "John Smith", + }, + ], + "message": "The additional property key should not be empty or whitespace. + + Given value: {\\"id\\":\\"John Smith\\",\\"age\\":50,\\"additionalProps\\":{\\" \\":123.2,\\"\\":52}} + Type: 'object' + Expected type: 'Object<{id,age,...}>'", + "path": Array [], + "type": "Object<{id,age,...}>", + "value": Object { + "additionalProps": Object { + "": 52, + " ": 123.2, + }, + "age": 50, + "id": "John Smith", + }, + }, + ] + `); + }); + it('should fail on non-object value', () => { const input = 'not an object'; const output = validateAndUnmap(input as any, userSchema); From 38ba990da172be9d2ba48bdd7477b1f64cccba16 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Wed, 30 Oct 2024 09:14:41 +0500 Subject: [PATCH 3/9] test: update POC for more additional properties related unit tests --- packages/schema/src/types/object.ts | 6 +- .../schema/test/types/expandoObject.test.ts | 160 ++++++++++++++++-- 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index 92e83a29..1a3c8ee3 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -5,6 +5,7 @@ import { SchemaType, SchemaValidationError, validateAndMap, + validateAndUnmap, } from '../schema'; import { OptionalizeObject } from '../typeUtils'; import { @@ -573,7 +574,10 @@ function extractAdditionalProperties( } Object.entries(objectValue).forEach(([k, v]) => { - const mappingResult = validateAndMap({ [k]: v }, mapAdditionalProps[1]); + const testValue = { [k]: v }; + const mappingResult = isUnmaping + ? validateAndUnmap(testValue, mapAdditionalProps[1]) + : validateAndMap(testValue, mapAdditionalProps[1]); if (mappingResult.errors) { return; } diff --git a/packages/schema/test/types/expandoObject.test.ts b/packages/schema/test/types/expandoObject.test.ts index 69281872..a0933d7b 100644 --- a/packages/schema/test/types/expandoObject.test.ts +++ b/packages/schema/test/types/expandoObject.test.ts @@ -9,6 +9,8 @@ import { validateAndUnmap, typedExpandoObject, dict, + object, + anyOf, } from '../../src'; describe('Expando Object', () => { @@ -25,21 +27,28 @@ describe('Expando Object', () => { ['additionalProps', optional(dict(number()))] ); - describe('Mapping', () => { - it('should map valid object', () => { - const input = { - user_id: 'John Smith', - user_age: 50, - }; - const output = validateAndMap(input, userSchema); - const expected: SchemaType = { - id: 'John Smith', - age: 50, - }; - expect(output.errors).toBeFalsy(); - expect((output as any).result).toStrictEqual(expected); - }); + const workSchema = object({ + size: ['size', number()], + name: ['Name', string()], + }); + + const userSchemaWithAdditionalWorks = typedExpandoObject( + { + id: ['user_id', string()], + age: ['user_age', number()], + }, + ['additionalProps', optional(dict(workSchema))] + ); + + const userSchemaWithAdditionalAnyOf = typedExpandoObject( + { + id: ['user_id', string()], + age: ['user_age', number()], + }, + ['additionalProps', optional(dict(anyOf([workSchema, number()])))] + ); + describe('Mapping', () => { it('AdditionalProperties: should map with additional properties', () => { const input = { user_id: 'John Smith', @@ -90,6 +99,79 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); + it('AdditionalProperties: should map with object typed additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + invalid1: 123.2, + invalid2: { + size: '123 A', + Name: 'WorkA', + }, + }; + const output = validateAndMap(input, userSchemaWithAdditionalWorks); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should map with anyOf typed additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + number: 123.2, + invalid2: { + size: '123 A', + Name: 'WorkA', + }, + }; + const output = validateAndMap(input, userSchemaWithAdditionalAnyOf); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + number: 123.2, + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('should map valid object', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + }; + const output = validateAndMap(input, userSchema); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + it('should map object with optional properties', () => { const addressSchema = expandoObject({ address1: ['address1', string()], @@ -243,6 +325,56 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); + it('AdditionalProperties: should map with object typed additional properties', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalWorks); + const expected = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should map with anyOf typed additional properties', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + number: 123.2, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalAnyOf); + const expected = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + number: 123.2, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + it('should map valid object', () => { const input = { id: 'John Smith', From cecc40bc6eb5810393ec41181654e37c87f96415 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Wed, 30 Oct 2024 09:33:25 +0500 Subject: [PATCH 4/9] test: remove empty or blank key error and add tests --- packages/schema/src/types/object.ts | 8 -- .../schema/test/types/expandoObject.test.ts | 105 +++++------------- 2 files changed, 27 insertions(+), 86 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index 1a3c8ee3..a6fd09fa 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -401,14 +401,6 @@ function validateValueObject( mapAdditionalProps[0] in valueObject ) { Object.entries(valueObject[mapAdditionalProps[0]]).forEach(([key, _]) => { - if (key.trim().length === 0) { - ctxt - .fail( - 'The additional property key should not be empty or whitespace.' - ) - .forEach((e) => errors.push(e)); - } - if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { ctxt .fail( diff --git a/packages/schema/test/types/expandoObject.test.ts b/packages/schema/test/types/expandoObject.test.ts index a0933d7b..b7b5023e 100644 --- a/packages/schema/test/types/expandoObject.test.ts +++ b/packages/schema/test/types/expandoObject.test.ts @@ -291,7 +291,7 @@ describe('Expando Object', () => { }); describe('Unmapping', () => { - it('AdditionalProperties: should map with additional properties', () => { + it('AdditionalProperties: should unmap with additional properties', () => { const input = { id: 'John Smith', age: 50, // takes precedence over additionalProps[user_age] @@ -311,7 +311,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); - it('AdditionalProperties: should map without additional properties', () => { + it('AdditionalProperties: should unmap without additional properties', () => { const input = { id: 'John Smith', age: 50, @@ -325,7 +325,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); - it('AdditionalProperties: should map with object typed additional properties', () => { + it('AdditionalProperties: should unmap with object typed additional properties', () => { const input = { id: 'John Smith', age: 50, @@ -349,7 +349,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); - it('AdditionalProperties: should map with anyOf typed additional properties', () => { + it('AdditionalProperties: should unmap with anyOf typed additional properties', () => { const input = { id: 'John Smith', age: 50, @@ -375,7 +375,27 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); - it('should map valid object', () => { + it('AdditionalProperties: should unmap with empty or blank additional property keys', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + ' ': 123.2, + '': 52, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + const expected = { + user_id: 'John Smith', + user_age: 50, + ' ': 123.2, + '': 52, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('should unmap valid object', () => { const input = { id: 'John Smith', age: 50, @@ -389,7 +409,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); - it('should map object with optional properties', () => { + it('should unmap object with optional properties', () => { const addressSchema = expandoObject({ address1: ['address1', string()], address2: ['address2', optional(string())], @@ -402,7 +422,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(input); }); - it('should map valid object with additional properties', () => { + it('should unmap valid object with additional properties', () => { const input = { id: 'John Smith', age: 50, @@ -465,77 +485,6 @@ describe('Expando Object', () => { `); }); - it('AdditionalProperties: should fail with empty or blank additional property keys', () => { - const input = { - id: 'John Smith', - age: 50, - additionalProps: { - ' ': 123.2, - '': 52, - }, - }; - const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); - expect(output.errors).toHaveLength(2); - expect(output.errors).toMatchInlineSnapshot(` - Array [ - Object { - "branch": Array [ - Object { - "additionalProps": Object { - "": 52, - " ": 123.2, - }, - "age": 50, - "id": "John Smith", - }, - ], - "message": "The additional property key should not be empty or whitespace. - - Given value: {\\"id\\":\\"John Smith\\",\\"age\\":50,\\"additionalProps\\":{\\" \\":123.2,\\"\\":52}} - Type: 'object' - Expected type: 'Object<{id,age,...}>'", - "path": Array [], - "type": "Object<{id,age,...}>", - "value": Object { - "additionalProps": Object { - "": 52, - " ": 123.2, - }, - "age": 50, - "id": "John Smith", - }, - }, - Object { - "branch": Array [ - Object { - "additionalProps": Object { - "": 52, - " ": 123.2, - }, - "age": 50, - "id": "John Smith", - }, - ], - "message": "The additional property key should not be empty or whitespace. - - Given value: {\\"id\\":\\"John Smith\\",\\"age\\":50,\\"additionalProps\\":{\\" \\":123.2,\\"\\":52}} - Type: 'object' - Expected type: 'Object<{id,age,...}>'", - "path": Array [], - "type": "Object<{id,age,...}>", - "value": Object { - "additionalProps": Object { - "": 52, - " ": 123.2, - }, - "age": 50, - "id": "John Smith", - }, - }, - ] - `); - }); - it('should fail on non-object value', () => { const input = 'not an object'; const output = validateAndUnmap(input as any, userSchema); From 4bcf68ba1ed41d5a0704e1d264385d16c4e27c9b Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Wed, 30 Oct 2024 10:40:38 +0500 Subject: [PATCH 5/9] refactor: add suggested refactoring and improved error messages --- packages/schema/src/types/object.ts | 84 +++++++++++++------ .../schema/test/types/expandoObject.test.ts | 2 +- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index a6fd09fa..dd4ac2be 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -393,6 +393,7 @@ function validateValueObject( ) { const errors: SchemaValidationError[] = []; const missingProps: Set = new Set(); + const conflictingProps: Set = new Set(); const unknownProps: Set = new Set(Object.keys(valueObject)); if ( @@ -400,17 +401,25 @@ function validateValueObject( typeof mapAdditionalProps !== 'boolean' && mapAdditionalProps[0] in valueObject ) { - Object.entries(valueObject[mapAdditionalProps[0]]).forEach(([key, _]) => { + for (const [key, _] of Object.entries(valueObject[mapAdditionalProps[0]])) { if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { - ctxt - .fail( - `An additional property key, '${key}' conflicts with one of the model's properties.` - ) - .forEach((e) => errors.push(e)); + conflictingProps.add(key); } - }); + } } + addErrorsIfAny( + conflictingProps, + (names) => + createErrorMessage( + `Some keys in additional properties are conflicting with the keys in`, + valueTypeName, + names + ), + errors, + ctxt + ); + // Validate all known properties using the schema for (const key in propMapping) { if (Object.prototype.hasOwnProperty.call(propMapping, key)) { @@ -430,32 +439,57 @@ function validateValueObject( } // Create validation error for unknown properties encountered - const unknownPropsArray = Array.from(unknownProps); - if (unknownPropsArray.length > 0 && !skipAdditionalPropValidation) { - ctxt - .fail( - `Some unknown ${propTypeName} were found in the ${valueTypeName}: ${unknownPropsArray - .map(literalToString) - .join(', ')}.` - ) - .forEach((e) => errors.push(e)); + if (!skipAdditionalPropValidation) { + addErrorsIfAny( + unknownProps, + (names) => + createErrorMessage( + `Some unknown ${propTypeName} were found in the`, + valueTypeName, + names + ), + errors, + ctxt + ); } // Create validation error for missing required properties - const missingPropsArray = Array.from(missingProps); - if (missingPropsArray.length > 0) { + addErrorsIfAny( + missingProps, + (names) => + createErrorMessage( + `Some ${propTypeName} are missing in the`, + valueTypeName, + names + ), + errors, ctxt - .fail( - `Some ${propTypeName} are missing in the ${valueTypeName}: ${missingPropsArray - .map(literalToString) - .join(', ')}.` - ) - .forEach((e) => errors.push(e)); - } + ); return errors; } +function createErrorMessage( + message: string, + type: string, + properties: string[] +): string { + return `${message} ${type}: ${properties.map(literalToString).join(', ')}.`; +} + +function addErrorsIfAny( + conflictingProps: Set, + messageGetter: (propNames: string[]) => string, + errors: SchemaValidationError[], + ctxt: SchemaContextCreator +) { + const conflictingPropsArray = Array.from(conflictingProps); + if (conflictingPropsArray.length > 0) { + const message = messageGetter(conflictingPropsArray); + ctxt.fail(message).forEach((e) => errors.push(e)); + } +} + function validateObject( objectSchema: AnyObjectSchema, validationMethod: diff --git a/packages/schema/test/types/expandoObject.test.ts b/packages/schema/test/types/expandoObject.test.ts index b7b5023e..0fe8466d 100644 --- a/packages/schema/test/types/expandoObject.test.ts +++ b/packages/schema/test/types/expandoObject.test.ts @@ -464,7 +464,7 @@ describe('Expando Object', () => { "id": "John Smith", }, ], - "message": "An additional property key, 'user_age' conflicts with one of the model's properties. + "message": "Some keys in additional properties are conflicting with the keys in object: \\"user_age\\". Given value: {\\"id\\":\\"John Smith\\",\\"age\\":50,\\"additionalProps\\":{\\"number1\\":123,\\"number2\\":123.2,\\"user_age\\":52}} Type: 'object' From 3cba023703fa84ad9dfd234108082746bc36fa9e Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Wed, 30 Oct 2024 11:40:42 +0500 Subject: [PATCH 6/9] refactor: add suggested refactoring --- packages/schema/src/types/object.ts | 90 ++++++++++++++++------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index dd4ac2be..7d1e1a09 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -260,30 +260,30 @@ function validateObjectBeforeMapXml( // Validate all known elements and attributes using the schema - return validateValueObject( - 'validateBeforeMapXml', - 'child elements', - 'element', - 'element', - elementsToProps, + return validateValueObject({ + validationMethod: 'validateBeforeMapXml', + propTypeName: 'child elements', + propTypePrefix: 'element', + valueTypeName: 'element', + propMapping: elementsToProps, objectSchema, - elements, + valueObject: elements, ctxt, skipAdditionalPropValidation, - mapAdditionalProps - ).concat( - validateValueObject( - 'validateBeforeMapXml', - 'attributes', - '@', - 'element', - attributesToProps, + mapAdditionalProps, + }).concat( + validateValueObject({ + validationMethod: 'validateBeforeMapXml', + propTypeName: 'attributes', + propTypePrefix: '@', + valueTypeName: 'element', + propMapping: attributesToProps, objectSchema, - attributes, + valueObject: attributes, ctxt, skipAdditionalPropValidation, - mapAdditionalProps - ) + mapAdditionalProps, + }) ); }; } @@ -376,21 +376,32 @@ function unmapObjectToXml( }; } -function validateValueObject( +function validateValueObject({ + validationMethod, + propTypeName, + propTypePrefix, + valueTypeName, + propMapping, + objectSchema, + valueObject, + ctxt, + skipAdditionalPropValidation, + mapAdditionalProps, +}: { validationMethod: | 'validateBeforeMap' | 'validateBeforeUnmap' - | 'validateBeforeMapXml', - propTypeName: string, - propTypePrefix: string, - valueTypeName: string, - propMapping: Record, - objectSchema: AnyObjectSchema, - valueObject: { [key: string]: any }, - ctxt: SchemaContextCreator, - skipAdditionalPropValidation: boolean, - mapAdditionalProps: boolean | [string, Schema] -) { + | 'validateBeforeMapXml'; + propTypeName: string; + propTypePrefix: string; + valueTypeName: string; + propMapping: Record; + objectSchema: AnyObjectSchema; + valueObject: { [key: string]: any }; + ctxt: SchemaContextCreator; + skipAdditionalPropValidation: boolean; + mapAdditionalProps: boolean | [string, Schema]; +}) { const errors: SchemaValidationError[] = []; const missingProps: Set = new Set(); const conflictingProps: Set = new Set(); @@ -408,6 +419,7 @@ function validateValueObject( } } + // Create validation errors for conflicting additional properties keys addErrorsIfAny( conflictingProps, (names) => @@ -499,7 +511,7 @@ function validateObject( skipAdditionalPropValidation: boolean, mapAdditionalProps: boolean | [string, Schema] ) { - const propsMapping = getPropMappingForObjectSchema(objectSchema); + const propMapping = getPropMappingForObjectSchema(objectSchema); return (value: unknown, ctxt: SchemaContextCreator) => { if (typeof value !== 'object' || value === null) { return ctxt.fail(); @@ -511,18 +523,18 @@ function validateObject( }' but found 'Array<${typeof value}>'.` ); } - return validateValueObject( + return validateValueObject({ validationMethod, - 'properties', - '', - 'object', - propsMapping, + propTypeName: 'properties', + propTypePrefix: '', + valueTypeName: 'object', + propMapping, objectSchema, - value as Record, + valueObject: value as Record, ctxt, skipAdditionalPropValidation, - mapAdditionalProps - ); + mapAdditionalProps, + }); }; } From 19b9c17584b001b896412364afc1f6106b5b8f8d Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Wed, 30 Oct 2024 12:01:17 +0500 Subject: [PATCH 7/9] refactor: add suggested changes to remove duplicate code --- packages/schema/src/types/object.ts | 39 +++++++++++++---------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index 7d1e1a09..a1a2f41f 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -256,11 +256,8 @@ function validateObjectBeforeMapXml( [key: string]: unknown; }; const { $: attrs, ...elements } = valueObject; - const attributes = attrs ?? {}; - - // Validate all known elements and attributes using the schema - return validateValueObject({ + let validationObj = { validationMethod: 'validateBeforeMapXml', propTypeName: 'child elements', propTypePrefix: 'element', @@ -271,20 +268,21 @@ function validateObjectBeforeMapXml( ctxt, skipAdditionalPropValidation, mapAdditionalProps, - }).concat( - validateValueObject({ - validationMethod: 'validateBeforeMapXml', - propTypeName: 'attributes', - propTypePrefix: '@', - valueTypeName: 'element', - propMapping: attributesToProps, - objectSchema, - valueObject: attributes, - ctxt, - skipAdditionalPropValidation, - mapAdditionalProps, - }) - ); + }; + // Validate all known elements using the schema + const elementErrors = validateValueObject(validationObj); + + validationObj = { + ...validationObj, + propTypeName: 'attributes', + propTypePrefix: '@', + propMapping: attributesToProps, + valueObject: attrs ?? {}, + }; + // Validate all known attributes using the schema + const attributesErrors = validateValueObject(validationObj); + + return elementErrors.concat(attributesErrors); }; } @@ -388,10 +386,7 @@ function validateValueObject({ skipAdditionalPropValidation, mapAdditionalProps, }: { - validationMethod: - | 'validateBeforeMap' - | 'validateBeforeUnmap' - | 'validateBeforeMapXml'; + validationMethod: string; propTypeName: string; propTypePrefix: string; valueTypeName: string; From 9ab8bbe4917dfe8036df943c09263a6b6a4be043 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Wed, 30 Oct 2024 12:12:01 +0500 Subject: [PATCH 8/9] refactor: update an argument name to match its type --- packages/schema/src/types/object.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index a1a2f41f..a992e79b 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -288,10 +288,10 @@ function validateObjectBeforeMapXml( function mapObjectFromXml( xmlObjectSchema: XmlObjectSchema, - allowAdditionalProps: boolean | [string, Schema] + mapAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; - const mapElements = mapObject(elementsSchema, 'mapXml', allowAdditionalProps); + const mapElements = mapObject(elementsSchema, 'mapXml', mapAdditionalProps); const mapAttributes = mapObject( attributesSchema, 'mapXml', @@ -317,7 +317,7 @@ function mapObjectFromXml( ...mapElements(elements, ctxt), }; - if (allowAdditionalProps) { + if (mapAdditionalProps) { // Omit known attributes and copy the rest as additional attributes. const additionalAttrs = omitKeysFromObject(attributes, attributeKeys); if (Object.keys(additionalAttrs).length > 0) { @@ -332,14 +332,10 @@ function mapObjectFromXml( function unmapObjectToXml( xmlObjectSchema: XmlObjectSchema, - allowAdditionalProps: boolean | [string, Schema] + mapAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; - const mapElements = mapObject( - elementsSchema, - 'unmapXml', - allowAdditionalProps - ); + const mapElements = mapObject(elementsSchema, 'unmapXml', mapAdditionalProps); const mapAttributes = mapObject( attributesSchema, 'unmapXml', @@ -347,7 +343,7 @@ function unmapObjectToXml( ); // These are later used to omit attribute props from the value object so that they - // do not get mapped during element mapping, if the allowAdditionalProps is true. + // do not get mapped during element mapping, if the mapAdditionalProps is set. const attributeKeys = objectEntries(attributesSchema).map( ([_, [name]]) => name ); @@ -363,7 +359,7 @@ function unmapObjectToXml( const additionalAttributes = typeof attributes === 'object' && attributes !== null && - allowAdditionalProps + mapAdditionalProps ? attributes : {}; From 601d00d89ad44722fc8e991919d7c51b107f58ed Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Thu, 31 Oct 2024 19:07:30 +0500 Subject: [PATCH 9/9] refactor: fixed the user interface of typedExpandoObject --- packages/schema/src/types/object.ts | 14 ++++++++++---- packages/schema/test/types/expandoObject.test.ts | 10 ++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index a992e79b..172438c0 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -17,6 +17,8 @@ import { objectKeyEncode, omitKeysFromObject, } from '../utils'; +import { dict } from './dict'; +import { optional } from './optional'; type AnyObjectSchema = Record< string, @@ -69,8 +71,8 @@ export interface ExtendedObjectSchema< K extends string, U > extends Schema< - ObjectType & { [key in K]?: U }, - MappedObjectType & { [key in K]?: U } + ObjectType & { [key in K]?: Record }, + MappedObjectType & { [key in K]?: Record } > { readonly objectSchema: T; } @@ -120,9 +122,13 @@ export function typedExpandoObject< S extends Schema >( objectSchema: T, - additionalPropSchema: [K, S] + additionalPropertyKey: K, + additionalPropertySchema: S ): ExtendedObjectSchema> { - return internalObject(objectSchema, true, additionalPropSchema); + return internalObject(objectSchema, true, [ + additionalPropertyKey, + optional(dict(additionalPropertySchema)), + ]); } /** diff --git a/packages/schema/test/types/expandoObject.test.ts b/packages/schema/test/types/expandoObject.test.ts index 0fe8466d..a9119915 100644 --- a/packages/schema/test/types/expandoObject.test.ts +++ b/packages/schema/test/types/expandoObject.test.ts @@ -8,7 +8,6 @@ import { validateAndMap, validateAndUnmap, typedExpandoObject, - dict, object, anyOf, } from '../../src'; @@ -24,7 +23,8 @@ describe('Expando Object', () => { id: ['user_id', string()], age: ['user_age', number()], }, - ['additionalProps', optional(dict(number()))] + 'additionalProps', + number() ); const workSchema = object({ @@ -37,7 +37,8 @@ describe('Expando Object', () => { id: ['user_id', string()], age: ['user_age', number()], }, - ['additionalProps', optional(dict(workSchema))] + 'additionalProps', + workSchema ); const userSchemaWithAdditionalAnyOf = typedExpandoObject( @@ -45,7 +46,8 @@ describe('Expando Object', () => { id: ['user_id', string()], age: ['user_age', number()], }, - ['additionalProps', optional(dict(anyOf([workSchema, number()])))] + 'additionalProps', + anyOf([workSchema, number()]) ); describe('Mapping', () => {