Skip to content

Commit

Permalink
feat: allow validate object literal (#1378)
Browse files Browse the repository at this point in the history
  • Loading branch information
Christian Forgács committed Apr 28, 2023
1 parent 8fb9d10 commit 4b1c5d5
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 8 deletions.
20 changes: 20 additions & 0 deletions README.md
Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string>;
}
```

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
Expand Down
20 changes: 20 additions & 0 deletions 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<string, unknown>;

@ValidateNested({
objectLiteral: true,
})
tags: Record<string, Tag>;
}
8 changes: 8 additions & 0 deletions 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;
}
26 changes: 26 additions & 0 deletions 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);
});
20 changes: 19 additions & 1 deletion src/decorator/ValidationOptions.ts
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
);
}
12 changes: 12 additions & 0 deletions src/metadata/ValidationMetadata.ts
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}
Expand Down
43 changes: 37 additions & 6 deletions src/validation/ValidationExecutor.ts
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 => {
Expand All @@ -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)
Expand Down Expand Up @@ -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<any>[] | Map<any, any>;

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);
Expand Down
89 changes: 88 additions & 1 deletion 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';

Expand Down Expand Up @@ -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<string, any>;

@ValidateNested()
mySubClass: MySubClass;

@ValidateNested({
objectLiteral: true,
})
mySubClasses: Record<string, MySubClass>;
}

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)
Expand Down

0 comments on commit 4b1c5d5

Please sign in to comment.