Skip to content

Commit

Permalink
feat: allow validate plain object (typestack#1378)
Browse files Browse the repository at this point in the history
  • Loading branch information
Christian Forgács committed Apr 27, 2023
1 parent 6b326d5 commit f1bd4a9
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 6 deletions.
14 changes: 14 additions & 0 deletions sample/sample10-plain-object/Post.ts
@@ -0,0 +1,14 @@
import { Contains, IsInt, Length, IsEmail, IsFQDN, IsDate, ValidateNested } from '../../src/decorator/decorators';
import { Tag } from './Tag';

export class Post {
@Length(10, 20, {
message: 'Incorrect length!',
})
title: string;

@ValidateNested({
plain: true,
})
tags: Record<string, Tag>;
}
8 changes: 8 additions & 0 deletions sample/sample10-plain-object/Tag.ts
@@ -0,0 +1,8 @@
import { Contains, IsInt, Length, IsEmail, IsFQDN, IsDate } from '../../src/decorator/decorators';

export class Tag {
@Length(10, 20, {
message: 'Tag value is too short or long',
})
value: string;
}
22 changes: 22 additions & 0 deletions sample/sample10-plain-object/app.ts
@@ -0,0 +1,22 @@
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.tags = {
tag1,
tag2,
};

validator.validate(post1).then(result => {
console.log('1. should not pass: ', result);
});
13 changes: 12 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 a plain record.
*
* For an object-valued property marked as plain, 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 a "plain record", 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.
*/
plain?: boolean;

/**
* Specifies if validated value is an array and each of its items must be validated.
*/
Expand Down Expand Up @@ -35,5 +46,5 @@ 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 'plain' 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 a plain record.
*
* For an object-valued property marked as plain, 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 a "plain record", 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.
*/
plain: 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.plain = args.validationOptions.plain;
this.each = args.validationOptions.each;
this.context = args.validationOptions.context;
}
Expand Down
33 changes: 28 additions & 5 deletions src/validation/ValidationExecutor.ts
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 ||
(value instanceof Object && metadata.plain)
)
) {
const validatedValue = customConstraintMetadata.instance.validate(value, validationArguments);
if (isPromise(validatedValue)) {
const promise = validatedValue.then(isValid => {
Expand All @@ -293,8 +301,14 @@ export class ValidationExecutor {
return;
}

// convert set and map into array
const arrayValue = convertToArray(value);
let arrayValue: any[];
if (value instanceof Object && metadata.plain) {
arrayValue = Object.values(value);
} else {
// convert set and map into array
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 @@ -361,8 +375,17 @@ export class ValidationExecutor {
this.performValidations(value, subValue, index.toString(), [], metadatas, error.children);
});
} else if (value instanceof Object) {
const targetSchema = typeof metadata.target === 'string' ? metadata.target : metadata.target.name;
this.execute(value, targetSchema, error.children);
if (metadata.plain) {
// Here, we do essentially what would be done with a Map-typed property
// We must set plain back to false because meta is also used for the child objects
metadata.plain = false;
Object.entries(value).forEach(([index, subValue]) => {
this.performValidations(value, subValue, index.toString(), [], metadatas, error.children);
});
} else {
const targetSchema = typeof metadata.target === 'string' ? metadata.target : metadata.target.name;
this.execute(value, targetSchema, error.children);
}
} else {
const [type, message] = this.createValidationError(metadata.target as object, value, metadata);
error.constraints[type] = message;
Expand Down
72 changes: 72 additions & 0 deletions test/functional/nested-validation.spec.ts
Expand Up @@ -306,6 +306,78 @@ describe('nested validation', () => {
});
});

it('should validate nested plain object', () => {
expect.assertions(24);

class MySubClass {
@MinLength(5)
name: string;
}

class MyClass {
@Contains('hello')
title: string;

@ValidateNested()
mySubClass: MySubClass;

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

const model = new MyClass();
model.title = 'helo world';
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(3);

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('mySubClass');
expect(errors[1].value).toEqual(model.mySubClass);
expect(errors[1].constraints).toBeUndefined();
const subError1 = errors[1].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[2].target).toEqual(model);
expect(errors[2].property).toEqual('mySubClasses');
expect(errors[2].value).toEqual(model.mySubClasses);
expect(errors[2].constraints).toBeUndefined();
const subError2 = errors[2].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 f1bd4a9

Please sign in to comment.