Repository: typestack/class-validator
Pull request #620 (2020-05-27 14:34:25)

feat: add option to stop validation at first error
## Description

Added an option to not detect more than one Error for each item.

## Checklist
<!-- Replace  the [ ] with [x] to check the boxes. -->
- [x] the pull request title describes what this PR does (not a vague title like `Update index.md`)
- [x] the pull request targets the *default* branch of the repository (`develop`)
- [x] the code follows the established code style of the repository
  - `npm run prettier:check` passes
  - `npm run lint:check` passes (An error occurs outside of this PR.)
- [x] tests are added for the changes I made (if any source code was modified)
- [x] documentation added or updated
- [x] I have run the project locally and verified that there are no errors

### Fixes
<!-- If there is no issue being resolved, open one before creating this pull request. -->
<!-- If the PR doesn't fully resolve the issue, replace 'fixes' with 'references'. -->

references #148, references #399, references #188


molinab297-unisys: If we could get this merged that would be awesome... #188 shows how much demand there is for this feature.
ytetsuro: @NoNameProvided

A conflict was occurring, so I rebased it.

However, CI/CD is giving me an error at a point completely unrelated to this fix.
Does this need to be fixed in this PR?

Patch:
diff --git a/README.md b/README.md
index be0bebc..fbb627f 100644
--- a/README.md
+++ b/README.md
@@ -135,6 +135,7 @@ export interface ValidatorOptions {
   };
 
   forbidUnknownValues?: boolean;
+  stopAtFirstError?: boolean;
 }
 ```
 
diff --git a/src/decorator/object/IsNotEmptyObject.ts b/src/decorator/object/IsNotEmptyObject.ts
index c436bf8..1fe85b9 100644
--- a/src/decorator/object/IsNotEmptyObject.ts
+++ b/src/decorator/object/IsNotEmptyObject.ts
@@ -8,10 +8,15 @@ export const IS_NOT_EMPTY_OBJECT = 'isNotEmptyObject';
  * Checks if the value is valid Object & not empty.
  * Returns false if the value is not an object or an empty valid object.
  */
-export function isNotEmptyObject(value: unknown): boolean {
+export function isNotEmptyObject(value: unknown, options?: { nullable?: boolean }): boolean {
   if (!isObject(value)) {
     return false;
   }
+
+  if (options?.nullable === true) {
+    return !Object.values(value).every(propertyValue => propertyValue === null || propertyValue === undefined);
+  }
+
   for (const key in value) {
     if (value.hasOwnProperty(key)) {
       return true;
@@ -25,12 +30,16 @@ export function isNotEmptyObject(value: unknown): boolean {
  * Checks if the value is valid Object & not empty.
  * Returns false if the value is not an object or an empty valid object.
  */
-export function IsNotEmptyObject(validationOptions?: ValidationOptions): PropertyDecorator {
+export function IsNotEmptyObject(
+  options?: { nullable?: boolean },
+  validationOptions?: ValidationOptions
+): PropertyDecorator {
   return ValidateBy(
     {
       name: IS_NOT_EMPTY_OBJECT,
+      constraints: [options],
       validator: {
-        validate: (value, args): boolean => isNotEmptyObject(value),
+        validate: (value, args): boolean => isNotEmptyObject(value, args.constraints[0]),
         defaultMessage: buildMessage(
           eachPrefix => eachPrefix + '$property must be a non-empty object',
           validationOptions
diff --git a/src/decorator/string/Matches.ts b/src/decorator/string/Matches.ts
index db08659..fb16484 100644
--- a/src/decorator/string/Matches.ts
+++ b/src/decorator/string/Matches.ts
@@ -37,7 +37,7 @@ export function Matches(
       name: MATCHES,
       constraints: [pattern, modifiers],
       validator: {
-        validate: (value, args): boolean => matches(value, args.constraints[0], args.constraints[0]),
+        validate: (value, args): boolean => matches(value, args.constraints[0], args.constraints[1]),
         defaultMessage: buildMessage(
           (eachPrefix, args) => eachPrefix + '$property must match $constraint1 regular expression',
           validationOptions
diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts
index 33593a6..d2f3585 100644
--- a/src/validation/ValidationExecutor.ts
+++ b/src/validation/ValidationExecutor.ts
@@ -238,6 +238,12 @@ export class ValidationExecutor {
     metadatas.forEach(metadata => {
       this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls).forEach(customConstraintMetadata => {
         if (customConstraintMetadata.async && this.ignoreAsyncValidations) return;
+        if (
+          this.validatorOptions &&
+          this.validatorOptions.stopAtFirstError &&
+          Object.keys(error.constraints || {}).length > 0
+        )
+          return;
 
         const validationArguments: ValidationArguments = {
           targetName: object.constructor ? (object.constructor as any).name : undefined,
diff --git a/src/validation/ValidatorOptions.ts b/src/validation/ValidatorOptions.ts
index a97e243..b5bab2d 100644
--- a/src/validation/ValidatorOptions.ts
+++ b/src/validation/ValidatorOptions.ts
@@ -59,4 +59,9 @@ export interface ValidatorOptions {
    * Settings true will cause fail validation of unknown objects.
    */
   forbidUnknownValues?: boolean;
+
+  /**
+   * When set to true, validation of the given property will stop after encountering the first error. Defaults to false.
+   */
+  stopAtFirstError?: boolean;
 }


Test patch:
diff --git a/test/functional/validation-functions-and-decorators.spec.ts b/test/functional/validation-functions-and-decorators.spec.ts
index 48f5111..8e77b7f 100644
--- a/test/functional/validation-functions-and-decorators.spec.ts
+++ b/test/functional/validation-functions-and-decorators.spec.ts
@@ -3051,7 +3051,7 @@ describe('IsObject', () => {
 });
 
 describe('IsNotEmptyObject', () => {
-  const validValues = [{ key: 'value' }, { key: 'value' }];
+  const validValues = [{ key: 'value' }, { key: 'value' }, { key: undefined }, { key: null }];
   const invalidValues = [
     null,
     undefined,
@@ -3064,26 +3064,54 @@ describe('IsNotEmptyObject', () => {
     [],
     [{ key: 'value' }],
   ];
+  const nullableValidValues = [{ key: 'value' }, { key: 'value' }];
+  const nullableInvalidValues = [
+    null,
+    undefined,
+    '{ key: "value" }',
+    "{ 'key': 'value' }",
+    'string',
+    1234,
+    false,
+    {},
+    { key: undefined },
+    { key: null },
+    [],
+    [{ key: 'value' }],
+  ];
 
   class MyClass {
     @IsNotEmptyObject()
     someProperty: object;
   }
 
-  it('should not fail if validator.validate said that its valid', () => {
-    return checkValidValues(new MyClass(), validValues);
+  class NullableMyClass {
+    @IsNotEmptyObject({ nullable: true })
+    someProperty: object;
+  }
+
+  it.each([
+    [new MyClass(), validValues],
+    [new NullableMyClass(), nullableValidValues],
+  ])('should not fail if validator.validate said that its valid', (validationObject, values) => {
+    return checkValidValues(validationObject, values);
   });
 
-  it('should fail if validator.validate said that its invalid', () => {
-    return checkInvalidValues(new MyClass(), invalidValues);
+  it.each([
+    [new MyClass(), invalidValues],
+    [new NullableMyClass(), nullableInvalidValues],
+  ])('should fail if validator.validate said that its invalid', (validationObject, values) => {
+    return checkInvalidValues(validationObject, values);
   });
 
   it('should not fail if method in validator said that its valid', () => {
     validValues.forEach(value => expect(isNotEmptyObject(value)).toBeTruthy());
+    nullableValidValues.forEach(value => expect(isNotEmptyObject(value, { nullable: true })).toBeTruthy());
   });
 
   it('should fail if method in validator said that its invalid', () => {
     invalidValues.forEach(value => expect(isNotEmptyObject(value)).toBeFalsy());
+    nullableInvalidValues.forEach(value => expect(isNotEmptyObject(value, { nullable: true })).toBeFalsy());
   });
 
   it('should return error object with proper data', () => {
@@ -3706,7 +3734,7 @@ describe('MaxLength', () => {
   });
 });
 
-describe('Matches', () => {
+describe('Matches pattern RegExp', () => {
   const constraint = /abc/;
   const validValues = ['abc', 'abcdef', '123abc'];
   const invalidValues = [null, undefined, 'acb', 'Abc'];
@@ -3739,6 +3767,40 @@ describe('Matches', () => {
   });
 });
 
+describe('Matches pattern string with modifier', () => {
+  const constraint = 'abc';
+  const modifier = 'i';
+  const validValues = ['abc', 'abcdef', '123abc', 'AbC'];
+  const invalidValues = [null, undefined, 'acb'];
+
+  class MyClass {
+    @Matches(constraint, modifier)
+    someProperty: string;
+  }
+
+  it('should not fail if validator.validate said that its valid', () => {
+    return checkValidValues(new MyClass(), validValues);
+  });
+
+  it('should fail if validator.validate said that its invalid', () => {
+    return checkInvalidValues(new MyClass(), invalidValues);
+  });
+
+  it('should not fail if method in validator said that its valid', () => {
+    validValues.forEach(value => expect(matches(value, constraint, modifier)).toBeTruthy());
+  });
+
+  it('should fail if method in validator said that its invalid', () => {
+    invalidValues.forEach(value => expect(matches(value, constraint, modifier)).toBeFalsy());
+  });
+
+  it('should return error object with proper data', () => {
+    const validationType = 'matches';
+    const message = 'someProperty must match ' + constraint + ' regular expression';
+    return checkReturnedError(new MyClass(), invalidValues, validationType, message);
+  });
+});
+
 describe('IsMilitaryTime', () => {
   class MyClass {
     @IsMilitaryTime()
diff --git a/test/functional/validation-options.spec.ts b/test/functional/validation-options.spec.ts
index be0b9ed..56e4196 100644
--- a/test/functional/validation-options.spec.ts
+++ b/test/functional/validation-options.spec.ts
@@ -1086,4 +1086,24 @@ describe('context', () => {
       expect(errors[1].contexts['contains']).toEqual({ bye: 'now' });
     });
   });
+
+  it('should stop at first error.', () => {
+    class MyClass {
+      @IsDefined({
+        message: 'isDefined',
+      })
+      @Contains('hello', {
+        message: 'String is not valid. You string must contain a hello word',
+      })
+      sameProperty: string;
+    }
+
+    const model = new MyClass();
+    return validator.validate(model, { stopAtFirstError: true }).then(errors => {
+      console.log();
+      expect(errors.length).toEqual(1);
+      expect(Object.keys(errors[0].constraints).length).toBe(1);
+      expect(errors[0].constraints['isDefined']).toBe('isDefined');
+    });
+  });
 });