diff --git a/README.md b/README.md index a9e64d882f..e02a2bfe3a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Class-validator works on both browser and node.js platforms. - [Validating arrays](#validating-arrays) - [Validating sets](#validating-sets) - [Validating maps](#validating-maps) + - [Validating plain objects](#validating-plain-objects) - [Validating nested objects](#validating-nested-objects) - [Validating promises](#validating-promises) - [Inheriting Validation decorators](#inheriting-validation-decorators) @@ -316,6 +317,25 @@ export class Post { This will validate each item in `post.tags` map. +## Validating plain objects + +If your field is a plain object and you want to perform validation of each item in the object you must specify a +special `each: true` and `plain: true` decorator option: + +```typescript +import { MinLength, MaxLength } from 'class-validator'; + +export class Post { + @MaxLength(20, { + each: true, + plain: true, + }) + tags: Record; +} +``` + +This will validate each item in `post.tags` object. + ## Validating nested objects If your object contains nested objects and you want the validator to perform their validation too, then you need to diff --git a/sample/sample10-object-literal/Post.ts b/sample/sample10-object-literal/Post.ts new file mode 100644 index 0000000000..56ff3e5f6d --- /dev/null +++ b/sample/sample10-object-literal/Post.ts @@ -0,0 +1,20 @@ +import { IsString, Length, ValidateNested } from '../../src/decorator/decorators'; +import { Tag } from './Tag'; + +export class Post { + @Length(10, 20, { + message: 'Incorrect length!', + }) + title: string; + + @IsString({ + each: true, + objectLiteral: true, + }) + categories: Record; + + @ValidateNested({ + objectLiteral: true, + }) + tags: Record; +} diff --git a/sample/sample10-object-literal/Tag.ts b/sample/sample10-object-literal/Tag.ts new file mode 100644 index 0000000000..370a5f351c --- /dev/null +++ b/sample/sample10-object-literal/Tag.ts @@ -0,0 +1,8 @@ +import { Length } from '../../src/decorator/decorators'; + +export class Tag { + @Length(10, 20, { + message: 'Tag value is too short or long', + }) + value: string; +} diff --git a/sample/sample10-object-literal/app.ts b/sample/sample10-object-literal/app.ts new file mode 100644 index 0000000000..fda2badac8 --- /dev/null +++ b/sample/sample10-object-literal/app.ts @@ -0,0 +1,26 @@ +import { Validator } from '../../src/validation/Validator'; +import { Post } from './Post'; +import { Tag } from './Tag'; + +let validator = new Validator(); + +let tag1 = new Tag(); +tag1.value = 'ja'; + +let tag2 = new Tag(); +tag2.value = 'node.js'; + +let post1 = new Post(); +post1.title = 'Hello world'; +post1.categories = { + foo: 'bar', + bar: 123, +}; +post1.tags = { + tag1, + tag2, +}; + +validator.validate(post1).then(result => { + console.log('1. should not pass: ', result); +}); diff --git a/src/decorator/ValidationOptions.ts b/src/decorator/ValidationOptions.ts index 60059a5fa6..7da037adc7 100644 --- a/src/decorator/ValidationOptions.ts +++ b/src/decorator/ValidationOptions.ts @@ -4,6 +4,17 @@ import { ValidationArguments } from '../validation/ValidationArguments'; * Options used to pass to validation decorators. */ export interface ValidationOptions { + /** + * Indicates that an object is to be considered object literal record. + * + * For an object-valued property marked as object literal, the object the property holds may neither + * be specifically class-typed nor validated, but all the child values of said object MUST be. + * Effectively, this declares object literal, which will be validated the same way any other + * JavaScript collection does (Array, Map, Set, etc). + * The default is `false`; that is, an object-value must be an instance of a class. + */ + objectLiteral?: boolean; + /** * Specifies if validated value is an array and each of its items must be validated. */ @@ -35,5 +46,12 @@ export function isValidationOptions(val: any): val is ValidationOptions { if (!val) { return false; } - return 'each' in val || 'message' in val || 'groups' in val || 'always' in val || 'context' in val; + return ( + 'objectLiteral' in val || + 'each' in val || + 'message' in val || + 'groups' in val || + 'always' in val || + 'context' in val + ); } diff --git a/src/metadata/ValidationMetadata.ts b/src/metadata/ValidationMetadata.ts index c1b1acce82..7f35ec789e 100644 --- a/src/metadata/ValidationMetadata.ts +++ b/src/metadata/ValidationMetadata.ts @@ -54,6 +54,17 @@ export class ValidationMetadata { */ always?: boolean; + /** + * Indicates that an object is to be considered object literal record. + * + * For an object-valued property marked as object literal, the object the property holds may neither + * be specifically class-typed nor validated, but all the child values of said object MUST be. + * Effectively, this declares object literal, which will be validated the same way any other + * JavaScript collection does (Array, Map, Set, etc). + * The default is `false`; that is, an object-value must be an instance of a class. + */ + objectLiteral: boolean = false; + /** * Specifies if validated value is an array and each of its item must be validated. */ @@ -85,6 +96,7 @@ export class ValidationMetadata { this.message = args.validationOptions.message; this.groups = args.validationOptions.groups; this.always = args.validationOptions.always; + this.objectLiteral = args.validationOptions.objectLiteral; this.each = args.validationOptions.each; this.context = args.validationOptions.context; } diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index 9d3d312f14..06a21f700b 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -6,7 +6,7 @@ import { ValidationTypes } from './ValidationTypes'; import { ConstraintMetadata } from '../metadata/ConstraintMetadata'; import { ValidationArguments } from './ValidationArguments'; import { ValidationUtils } from './ValidationUtils'; -import { isPromise, convertToArray } from '../utils'; +import { isPromise, convertToArray, isObjectLiteral } from '../utils'; import { getMetadataStorage } from '../metadata/MetadataStorage'; /** @@ -267,7 +267,15 @@ export class ValidationExecutor { constraints: metadata.constraints, }; - if (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map)) { + if ( + !metadata.each || + !( + Array.isArray(value) || + value instanceof Set || + value instanceof Map || + (isObjectLiteral(value) && metadata.objectLiteral) + ) + ) { const validatedValue = customConstraintMetadata.instance.validate(value, validationArguments); if (isPromise(validatedValue)) { const promise = validatedValue.then(isValid => { @@ -293,8 +301,9 @@ export class ValidationExecutor { return; } - // convert set and map into array + // convert object literal, set and map into array const arrayValue = convertToArray(value); + // Validation needs to be applied to each array item const validatedSubValues = arrayValue.map((subValue: any) => customConstraintMetadata.instance.validate(subValue, validationArguments) @@ -354,12 +363,34 @@ export class ValidationExecutor { return; } - if (Array.isArray(value) || value instanceof Set || value instanceof Map) { - // Treats Set as an array - as index of Set value is value itself and it is common case to have Object as value - const arrayLikeValue = value instanceof Set ? Array.from(value) : value; + const isValueObjectLiteralAndMetadataPlain = isObjectLiteral(value) && metadata.objectLiteral; + + if ( + Array.isArray(value) || + value instanceof Set || + value instanceof Map || + isValueObjectLiteralAndMetadataPlain + ) { + let arrayLikeValue: Set[] | Map; + + if (isValueObjectLiteralAndMetadataPlain) { + // Convert object literal value into Map + arrayLikeValue = new Map(Object.entries(value)); + // We must set `objectLiteral` to false because meta is also used for the child objects + metadata.objectLiteral = false; + } else { + // Treats Set as an array - as index of Set value is value itself and it is common case to have Object as value + arrayLikeValue = value instanceof Set ? Array.from(value) : value; + } + arrayLikeValue.forEach((subValue: any, index: any) => { this.performValidations(value, subValue, index.toString(), [], metadatas, error.children); }); + + if (isValueObjectLiteralAndMetadataPlain) { + // Restore original value for `objectLiteral` in metadata + metadata.objectLiteral = true; + } } else if (value instanceof Object) { const targetSchema = typeof metadata.target === 'string' ? metadata.target : metadata.target.name; this.execute(value, targetSchema, error.children); diff --git a/test/functional/nested-validation.spec.ts b/test/functional/nested-validation.spec.ts index b7c3eb152b..7c3e9b8411 100644 --- a/test/functional/nested-validation.spec.ts +++ b/test/functional/nested-validation.spec.ts @@ -1,4 +1,4 @@ -import { Contains, IsDefined, MinLength, ValidateNested } from '../../src/decorator/decorators'; +import { Contains, IsDefined, IsString, MinLength, ValidateNested } from '../../src/decorator/decorators'; import { Validator } from '../../src/validation/Validator'; import { ValidationTypes } from '../../src/validation/ValidationTypes'; @@ -306,6 +306,93 @@ describe('nested validation', () => { }); }); + it('should validate nested object literal', () => { + expect.assertions(28); + + class MySubClass { + @MinLength(5) + name: string; + } + + class MyClass { + @Contains('hello') + title: string; + + @IsString({ + each: true, + objectLiteral: true, + }) + stringRecord: Record; + + @ValidateNested() + mySubClass: MySubClass; + + @ValidateNested({ + objectLiteral: true, + }) + mySubClasses: Record; + } + + const model = new MyClass(); + model.title = 'helo world'; + model.stringRecord = { + foo: 'bar', + bar: 123, + }; + model.mySubClass = new MySubClass(); + model.mySubClass.name = 'my'; + model.mySubClasses = {}; + + const subsubmodel1 = new MySubClass(); + subsubmodel1.name = 'shor'; + + const submodel1 = new MySubClass(); + submodel1.name = 'my'; + model.mySubClasses.key1 = submodel1; + + const submodel2 = new MySubClass(); + submodel2.name = 'not-short'; + model.mySubClasses.key2 = submodel2; + + return validator.validate(model).then(errors => { + expect(errors.length).toEqual(4); + + expect(errors[0].target).toEqual(model); + expect(errors[0].property).toEqual('title'); + expect(errors[0].constraints).toEqual({ contains: 'title must contain a hello string' }); + expect(errors[0].value).toEqual('helo world'); + + expect(errors[1].target).toEqual(model); + expect(errors[1].property).toEqual('stringRecord'); + expect(errors[1].constraints).toEqual({ isString: 'each value in stringRecord must be a string' }); + expect(errors[1].value).toEqual({ foo: 'bar', bar: 123 }); + + expect(errors[2].target).toEqual(model); + expect(errors[2].property).toEqual('mySubClass'); + expect(errors[2].value).toEqual(model.mySubClass); + expect(errors[2].constraints).toBeUndefined(); + const subError1 = errors[2].children[0]; + expect(subError1.target).toEqual(model.mySubClass); + expect(subError1.property).toEqual('name'); + expect(subError1.constraints).toEqual({ minLength: 'name must be longer than or equal to 5 characters' }); + expect(subError1.value).toEqual('my'); + + expect(errors[3].target).toEqual(model); + expect(errors[3].property).toEqual('mySubClasses'); + expect(errors[3].value).toEqual(model.mySubClasses); + expect(errors[3].constraints).toBeUndefined(); + const subError2 = errors[3].children[0]; + expect(subError2.target).toEqual(model.mySubClasses); + expect(subError2.value).toEqual(submodel1); + expect(subError2.property).toEqual('key1'); + const subSubError = subError2.children[0]; + expect(subSubError.target).toEqual(submodel1); + expect(subSubError.property).toEqual('name'); + expect(subSubError.constraints).toEqual({ minLength: 'name must be longer than or equal to 5 characters' }); + expect(subSubError.value).toEqual('my'); + }); + }); + it('nestedValidation should be defined as an error for the property specifying the decorator when validation fails.', () => { class MySubClass { @MinLength(5)