Repository: typestack/class-validator (ts)

Issue:
feature: Make `minDate` and `maxDate` support specifying dynamic date
### Description
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->

Sometime we need to check if the value is a date that's after now or a dynamic date.

### Proposed solution
<!-- A clear and concise description of how this feature should work -->
<!-- If you had multiple approaches please list any alternative solutions or features you've considered. -->

I have an idea. Make `minDate` and `MaxDate` accept a function as the `date` parameter. The function return a date and will be executed when validate. Like the following code:

```ts
// MinDate(date: Date | (() => Date))
@MinDate(() => new Date())
someProperty
```



Pull request:
feat: allow passing dynamic date to `MinDate` and `MaxDate` decorators
## Description
<!-- A clear and concise description what these changes does. -->

Make minDate and MaxDate accept a function as the date parameter. The function return a date and will be executed when validate.

## 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
- [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 #1691 


Patch:
diff --git a/README.md b/README.md
index 4d1cccc..6024a7f 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ Class-validator works on both browser and node.js platforms.
   - [Samples](#samples)
   - [Extensions](#extensions)
   - [Release notes](#release-notes)
+  - [Contributing](#contributing)
 
 ## Installation
 
@@ -815,8 +816,8 @@ isBoolean(value);
 | `@Min(min: number)`                             | Checks if the given number is greater than or equal to given number. |
 | `@Max(max: number)`                             | Checks if the given number is less than or equal to given number. |
 | **Date validation decorators**                  |
-| `@MinDate(date: Date)`                          | Checks if the value is a date that's after the specified date. |
-| `@MaxDate(date: Date)`                          | Checks if the value is a date that's before the specified date. |  
+| `@MinDate(date: Date | (() => Date))`           | Checks if the value is a date that's after the specified date. |
+| `@MaxDate(date: Date | (() => Date))`           | Checks if the value is a date that's before the specified date. |  
 | **String-type validation decorators**           | |
 | `@IsBooleanString()`                            | Checks if a string is a boolean (e.g. is "true" or "false"). |
 | `@IsDateString()`                               | Alias for `@IsISO8601()`. |
@@ -885,6 +886,7 @@ isBoolean(value);
 | `@MaxLength(max: number)`                       | Checks if the string's length is not more than given number. |
 | `@Matches(pattern: RegExp, modifiers?: string)` | Checks if string matches the pattern. Either matches('foo', /foo/i) or matches('foo', 'foo', 'i'). |
 | `@IsMilitaryTime()`                             | Checks if the string is a valid representation of military time in the format HH:MM. |
+| `@IsTimeZone()`                                 | Checks if the string represents a valid IANA time-zone. |
 | `@IsHash(algorithm: string)`                    | Checks if the string is a hash The following types are supported:`md4`, `md5`, `sha1`, `sha256`, `sha384`, `sha512`, `ripemd128`, `ripemd160`, `tiger128`, `tiger160`, `tiger192`, `crc32`, `crc32b`. |
 | `@IsMimeType()`                                 | Checks if the string matches to a valid [MIME type](https://en.wikipedia.org/wiki/Media_type) format |
 | `@IsSemVer()`                                   | Checks if the string is a Semantic Versioning Specification (SemVer). |
@@ -932,3 +934,7 @@ See information about breaking changes and release notes [here][3].
 [1]: https://github.com/chriso/validator.js
 [2]: https://github.com/pleerock/typedi
 [3]: CHANGELOG.md
+
+## Contributing
+
+For information about how to contribute to this project, see [TypeStack's general contribution guide](https://github.com/typestack/.github/blob/master/CONTRIBUTING.md).
diff --git a/src/decorator/date/MaxDate.ts b/src/decorator/date/MaxDate.ts
index 1dfa1df..2bd0625 100644
--- a/src/decorator/date/MaxDate.ts
+++ b/src/decorator/date/MaxDate.ts
@@ -6,14 +6,14 @@ export const MAX_DATE = 'maxDate';
 /**
  * Checks if the value is a date that's before the specified date.
  */
-export function maxDate(date: unknown, maxDate: Date): boolean {
-  return date instanceof Date && date.getTime() <= maxDate.getTime();
+export function maxDate(date: unknown, maxDate: Date | (() => Date)): boolean {
+  return date instanceof Date && date.getTime() <= (maxDate instanceof Date ? maxDate : maxDate()).getTime();
 }
 
 /**
  * Checks if the value is a date that's after the specified date.
  */
-export function MaxDate(date: Date, validationOptions?: ValidationOptions): PropertyDecorator {
+export function MaxDate(date: Date | (() => Date), validationOptions?: ValidationOptions): PropertyDecorator {
   return ValidateBy(
     {
       name: MAX_DATE,
diff --git a/src/decorator/date/MinDate.ts b/src/decorator/date/MinDate.ts
index 20b1928..a0855e7 100644
--- a/src/decorator/date/MinDate.ts
+++ b/src/decorator/date/MinDate.ts
@@ -6,14 +6,14 @@ export const MIN_DATE = 'minDate';
 /**
  * Checks if the value is a date that's after the specified date.
  */
-export function minDate(date: unknown, minDate: Date): boolean {
-  return date instanceof Date && date.getTime() >= minDate.getTime();
+export function minDate(date: unknown, minDate: Date | (() => Date)): boolean {
+  return date instanceof Date && date.getTime() >= (minDate instanceof Date ? minDate : minDate()).getTime();
 }
 
 /**
  * Checks if the value is a date that's after the specified date.
  */
-export function MinDate(date: Date, validationOptions?: ValidationOptions): PropertyDecorator {
+export function MinDate(date: Date | (() => Date), validationOptions?: ValidationOptions): PropertyDecorator {
   return ValidateBy(
     {
       name: MIN_DATE,
diff --git a/src/decorator/decorators.ts b/src/decorator/decorators.ts
index 41b843f..d8e9b56 100644
--- a/src/decorator/decorators.ts
+++ b/src/decorator/decorators.ts
@@ -111,6 +111,7 @@ export * from './string/IsPostalCode';
 export * from './string/IsRFC3339';
 export * from './string/IsRgbColor';
 export * from './string/IsSemVer';
+export * from './string/IsTimeZone';
 
 // -------------------------------------------------------------------------
 // Type checkers
diff --git a/src/decorator/string/IsTimeZone.ts b/src/decorator/string/IsTimeZone.ts
new file mode 100644
index 0000000..3504d0a
--- /dev/null
+++ b/src/decorator/string/IsTimeZone.ts
@@ -0,0 +1,43 @@
+import { ValidationOptions } from '../ValidationOptions';
+import { buildMessage, ValidateBy } from '../common/ValidateBy';
+
+export const IS_TIMEZONE = 'isTimeZone';
+
+/**
+ * Checks if the string represents a valid IANA timezone
+ * If the given value is not a valid IANA timezone, then it returns false.
+ */
+export function isTimeZone(value: unknown): boolean {
+  try {
+    if (typeof value !== 'string') {
+      return false;
+    }
+
+    /** Specifying an invalid time-zone will raise a `RangeError: Invalid time zone specified` error. */
+    Intl.DateTimeFormat(undefined, { timeZone: value });
+
+    return true;
+  } catch (exception) {
+    return false;
+  }
+}
+
+/**
+ * Checks if the string represents a valid IANA timezone
+ * If the given value is not a valid IANA timezone, then it returns false.
+ */
+export function IsTimeZone(validationOptions?: ValidationOptions): PropertyDecorator {
+  return ValidateBy(
+    {
+      name: IS_TIMEZONE,
+      validator: {
+        validate: (value, args): boolean => isTimeZone(value),
+        defaultMessage: buildMessage(
+          eachPrefix => eachPrefix + '$property must be a valid IANA time-zone',
+          validationOptions
+        ),
+      },
+    },
+    validationOptions
+  );
+}
diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts
index 5134a80..844bd20 100644
--- a/src/validation/ValidationExecutor.ts
+++ b/src/validation/ValidationExecutor.ts
@@ -205,7 +205,7 @@ export class ValidationExecutor {
     }
 
     this.customValidations(object, value, customValidationMetadatas, validationError);
-    this.nestedValidations(value, nestedValidationMetadatas, validationError.children);
+    this.nestedValidations(value, nestedValidationMetadatas, validationError);
 
     this.mapContexts(object, value, metadatas, validationError);
     this.mapContexts(object, value, customValidationMetadatas, validationError);
@@ -333,7 +333,7 @@ export class ValidationExecutor {
     });
   }
 
-  private nestedValidations(value: any, metadatas: ValidationMetadata[], errors: ValidationError[]): void {
+  private nestedValidations(value: any, metadatas: ValidationMetadata[], error: ValidationError): void {
     if (value === void 0) {
       return;
     }
@@ -341,27 +341,26 @@ export class ValidationExecutor {
     metadatas.forEach(metadata => {
       if (metadata.type !== ValidationTypes.NESTED_VALIDATION && metadata.type !== ValidationTypes.PROMISE_VALIDATION) {
         return;
+      } else if (
+        this.validatorOptions &&
+        this.validatorOptions.stopAtFirstError &&
+        Object.keys(error.constraints || {}).length > 0
+      ) {
+        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;
         arrayLikeValue.forEach((subValue: any, index: any) => {
-          this.performValidations(value, subValue, index.toString(), [], metadatas, errors);
+          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, errors);
+        this.execute(value, targetSchema, error.children);
       } else {
-        const error = new ValidationError();
-        error.value = value;
-        error.property = metadata.propertyName;
-        error.target = metadata.target as object;
         const [type, message] = this.createValidationError(metadata.target as object, value, metadata);
-        error.constraints = {
-          [type]: message,
-        };
-        errors.push(error);
+        error.constraints[type] = message;
       }
     });
   }


Test patch:
diff --git a/test/functional/nested-validation.spec.ts b/test/functional/nested-validation.spec.ts
index 862af95..b7c3eb1 100644
--- a/test/functional/nested-validation.spec.ts
+++ b/test/functional/nested-validation.spec.ts
@@ -165,10 +165,8 @@ describe('nested validation', () => {
     return validator.validate(model).then(errors => {
       expect(errors[0].target).toEqual(model);
       expect(errors[0].property).toEqual('mySubClass');
-      expect(errors[0].children.length).toEqual(1);
-
-      const subError = errors[0].children[0];
-      expect(subError.constraints).toEqual({
+      expect(errors[0].children.length).toEqual(0);
+      expect(errors[0].constraints).toEqual({
         [ValidationTypes.NESTED_VALIDATION]: 'nested property mySubClass must be either object or array',
       });
     });
@@ -307,4 +305,31 @@ describe('nested validation', () => {
       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)
+      name: string;
+    }
+
+    class MyClass {
+      @ValidateNested()
+      nestedWithClassValue: MySubClass;
+
+      @ValidateNested()
+      nestedWithPrimitiveValue: MySubClass;
+    }
+
+    const model = new MyClass();
+    model.nestedWithClassValue = new MySubClass();
+    model.nestedWithPrimitiveValue = 'invalid' as any;
+
+    return validator.validate(model, { stopAtFirstError: true }).then(errors => {
+      expect(errors[0].property).toEqual('nestedWithClassValue');
+      expect(errors[0].children.length).toEqual(1);
+      expect(errors[0].children[0].constraints).toHaveProperty('minLength');
+      expect(errors[1].property).toEqual('nestedWithPrimitiveValue');
+      expect(errors[1].constraints).toHaveProperty('nestedValidation');
+    });
+  });
 });
diff --git a/test/functional/promise-validation.spec.ts b/test/functional/promise-validation.spec.ts
index 0de1d1a..fdcb746 100644
--- a/test/functional/promise-validation.spec.ts
+++ b/test/functional/promise-validation.spec.ts
@@ -120,10 +120,8 @@ describe('promise validation', () => {
     return validator.validate(model).then(errors => {
       expect(errors[0].target).toEqual(model);
       expect(errors[0].property).toEqual('mySubClass');
-      expect(errors[0].children.length).toEqual(1);
-
-      const subError = errors[0].children[0];
-      expect(subError.constraints).toEqual({
+      expect(errors[0].children.length).toEqual(0);
+      expect(errors[0].constraints).toEqual({
         [ValidationTypes.NESTED_VALIDATION]: 'nested property mySubClass must be either object or array',
       });
     });
diff --git a/test/functional/validation-functions-and-decorators.spec.ts b/test/functional/validation-functions-and-decorators.spec.ts
index 49bc660..bde60a9 100644
--- a/test/functional/validation-functions-and-decorators.spec.ts
+++ b/test/functional/validation-functions-and-decorators.spec.ts
@@ -184,6 +184,7 @@ import {
   isPostalCode,
   IsSemVer,
   isSemVer,
+  IsTimeZone,
 } from '../../src/decorator/decorators';
 import { Validator } from '../../src/validation/Validator';
 import { ValidatorOptions } from '../../src/validation/ValidatorOptions';
@@ -1210,6 +1211,72 @@ describe('MaxDate', () => {
   });
 });
 
+describe('MinDate function constraint', () => {
+  const constraint = () => new Date(1995, 11, 17);
+  const validValues = [new Date()];
+  const invalidValues = [new Date(1994, 11, 17)];
+
+  class MyClass {
+    @MinDate(constraint)
+    someProperty: Date;
+  }
+
+  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(minDate(value, constraint)).toBeTruthy());
+  });
+
+  it('should fail if method in validator said that its invalid', () => {
+    invalidValues.forEach(value => expect(minDate(value, constraint)).toBeFalsy());
+  });
+
+  it('should return error object with proper data', () => {
+    const validationType = 'minDate';
+    const message = 'minimal allowed date for someProperty is ' + constraintToString(constraint);
+    return checkReturnedError(new MyClass(), invalidValues, validationType, message);
+  });
+});
+
+describe('MaxDate function constraint', () => {
+  const constraint = () => new Date(1995, 11, 17);
+  const validValues = [new Date(1994, 11, 17)];
+  const invalidValues = [new Date()];
+
+  class MyClass {
+    @MaxDate(constraint)
+    someProperty: Date;
+  }
+
+  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(maxDate(value, constraint)).toBeTruthy());
+  });
+
+  it('should fail if method in validator said that its invalid', () => {
+    invalidValues.forEach(value => expect(maxDate(value, constraint)).toBeFalsy());
+  });
+
+  it('should return error object with proper data', () => {
+    const validationType = 'maxDate';
+    const message = 'maximal allowed date for someProperty is ' + constraintToString(constraint);
+    return checkReturnedError(new MyClass(), invalidValues, validationType, message);
+  });
+});
+
 describe('IsBooleanString', () => {
   const validValues = ['1', '0', 'true', 'false'];
   const invalidValues = ['2', '3', 'falze'];
@@ -3829,6 +3896,28 @@ describe('IsMilitaryTime', () => {
   });
 });
 
+describe('IsTimeZone', () => {
+  class MyClass {
+    @IsTimeZone()
+    someProperty: string;
+  }
+
+  it('should not fail for a valid IANA timezones', () => {
+    const validValues = ['Asia/Kathmandu', 'America/New_York', 'Europe/Paris', 'Europe/Berlin'];
+    return checkValidValues(new MyClass(), validValues);
+  });
+
+  it('should fail for invalid timezone format', () => {
+    const invalidValues = ['Asia/Pokhara', 'America', 'New_York', '/Paris'];
+    return checkInvalidValues(new MyClass(), invalidValues);
+  });
+
+  it('should fail for invalid values', () => {
+    const invalidValues = [undefined, null, 'Asia-Kathmandu'];
+    return checkInvalidValues(new MyClass(), invalidValues);
+  });
+});
+
 describe('isPhoneNumber', () => {
   describe('with region', () => {
     const validValues = [
diff --git a/test/functional/validation-options.spec.ts b/test/functional/validation-options.spec.ts
index 9e57669..d363668 100644
--- a/test/functional/validation-options.spec.ts
+++ b/test/functional/validation-options.spec.ts
@@ -3,6 +3,7 @@ import {
   IsDefined,
   Matches,
   MinLength,
+  IsArray,
   Validate,
   ValidateNested,
   ValidatorConstraint,
@@ -1196,6 +1197,13 @@ describe('context', () => {
   });
 
   it('should stop at first error.', () => {
+    class MySubClass {
+      @IsDefined({
+        message: 'isDefined',
+      })
+      name: string;
+    }
+
     class MyClass {
       @IsDefined({
         message: 'isDefined',
@@ -1204,14 +1212,32 @@ describe('context', () => {
         message: 'String is not valid. You string must contain a hello word',
       })
       sameProperty: string;
+
+      @ValidateNested()
+      @IsArray()
+      nestedWithPrimitiveValue: MySubClass[];
     }
 
     const model = new MyClass();
-    return validator.validate(model, { stopAtFirstError: true }).then(errors => {
-      console.log();
-      expect(errors.length).toEqual(1);
+    model.nestedWithPrimitiveValue = 'invalid' as any;
+
+    const hasStopAtFirstError = validator.validate(model, { stopAtFirstError: true }).then(errors => {
+      expect(errors.length).toEqual(2);
       expect(Object.keys(errors[0].constraints).length).toBe(1);
       expect(errors[0].constraints['isDefined']).toBe('isDefined');
+      expect(Object.keys(errors[1].constraints).length).toBe(1);
+      expect(errors[1].constraints).toHaveProperty('isArray');
     });
+    const hasNotStopAtFirstError = validator.validate(model, { stopAtFirstError: false }).then(errors => {
+      expect(errors.length).toEqual(2);
+      expect(Object.keys(errors[0].constraints).length).toBe(2);
+      expect(errors[0].constraints).toHaveProperty('contains');
+      expect(errors[0].constraints).toHaveProperty('isDefined');
+      expect(Object.keys(errors[1].constraints).length).toBe(2);
+      expect(errors[1].constraints).toHaveProperty('isArray');
+      expect(errors[1].constraints).toHaveProperty('nestedValidation');
+    });
+
+    return Promise.all([hasStopAtFirstError, hasNotStopAtFirstError]);
   });
 });