typestack / class-validator

Decorator-based property validation for classes.
MIT License
11.03k stars 800 forks source link

[Feature] @IsOptionaIf() #651

Open ibox4real opened 4 years ago

ibox4real commented 4 years ago

It would be nice if we could have conditional decorator for required/non-required properties.

Example use case:

I want to insert or update a record. When inserting a new row the title is required, but when updating the releaseYear on existing row the title column is not required.

I could get around this by creating two different classes InsertRecordParameters and UpdateRecordParamaeters but it would be nice to have eveything in one class when insert and update do not differ in terms of the parameters used.

class SaveRecordParameters {
@IsOptional() 
@IsPositive()
 id?: number;

@IsOptionaIf( self => !!self.id )
@MaxLength(5)
@MinLength(50)
 title?: string;

@IsOptional()
@IsNumber()
 releaseYear?: number;
}
carlocorradini commented 4 years ago

@ibox4real you can use Validation groups. They are meant exactly for what you are looking for.

Example class: A class that I used in a project here


export enum RecordParametersValidationGroup {
  CREATION = 'creation',
  UPDATE = 'update',
};

export defaul class SaveRecordParameters {
@IsOptional() 
@IsPositive()
 id?: number;

@Length(5, 50, { groups: [RecordParametersValidationGroup.UPDATE] }])
 title?: string;

@IsOptional({ groups: [RecordParametersValidationGroup.UPDATE] })
@IsNumber()
 releaseYear?: number;
}

Example usage: The usage here

import {validate} from "class-validator";
import { RecordParametersValidationGroup } from '@entity/SaveRecordParameters ';

// ...

validate(an_example_instance_of_record_parameters, {
    groups: [RecordParametersValidationGroup.UPDATE]
});
ekrismer commented 4 years ago

There's a pull request for a conditional @IsOptional decorator here: https://github.com/typestack/class-validator/pull/196 But it hasn't been merged in over two years now.

c0mradeuc commented 4 years ago

Looks way simpler when you have an IsOptionalIf

NickKelly1 commented 4 years ago

You can achieve this by creating your own decorator that wraps @ValidateIf.

/**
 * Mark the property as optional if the function returns truthy
 * 
 * @param optionalIfPropertyIsSet
 */
function IsOptionalIf(allowOptional: (obj: any, value: any) => boolean, options?: ValidationOptions) {
  // If required, do validate. Otherwise if null|undefined, don't validate
  return ValidateIf((obj, value) => !allowOptional(obj, value) || value != null), options)
}

class SaveRecordParameters {
  @IsOptional() 
  @IsPositive()
  id?: number;

  @IsOptionalIf(self => !!self.id)
  @MaxLength(5)
  @MinLength(50)
  title?: string;

  @IsOptional()
  @IsNumber()
  releaseYear?: number;
}
yitzchak-ben-ezra-ecoplant commented 3 years ago

Can someone make this PR merged?? this will be so helpful!!

TimurKastemirov commented 6 months ago

I made my version of IsOptionalIf run validators is condition is false if condition is true, don't run validators if value is null or undefined. else run validators

import {
  registerDecorator,
  ValidationOptions,
  ValidationTypes,
} from 'class-validator';
import { isNil } from 'lodash';

export function IsOptionalIf(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  isOptionalCondition: (object: any) => boolean,
  validationOptions?: ValidationOptions,
): PropertyDecorator {
  return function (object, propertyName: string) {
    const constraint = (obj, val) => {
      const conditionResult = isOptionalCondition(obj);
      if (conditionResult) {
        // OPTIONAL
        return !isNil(val); // validate if value is defined and not validate when value is undefined
      }

      return true; // validate anyway
    };

    registerDecorator({
      name: ValidationTypes.CONDITIONAL_VALIDATION, // name must be exactly this value to use only constraint
      target: object.constructor,
      propertyName: propertyName,
      constraints: [constraint],
      options: validationOptions,
      validator: {
        validate() {
          return false; // can be true or false, because it's not used
        },
      },
    });
  };
}

may be it will help someone

Alfagun74 commented 3 months ago

heres a really simple solution:

Validator:

import { IsOptional } from "class-validator";

export function IsOptionalIf(condition: boolean) {
    if (!condition) {
        return () => {};
    }
    return IsOptional()
  }

Usage:

@IsOptionalIf(configuration.USERS.REQUIRE_BIRTH_DATE === false)
@IsDateString()
@IsNotEmpty()
birth_date?: string;

This will result in the exact desired behaviour and only add IsOptional if the condition is true.

TimurKastemirov commented 3 months ago

heres a really simple solution:

Validator:

import { IsOptional } from "class-validator";

export function IsOptionalIf(condition: boolean) {
    if (!condition) {
        return () => {};
    }
    return IsOptional()
  }

Usage:

@IsOptionalIf(configuration.USERS.REQUIRE_BIRTH_DATE === false)
@IsDateString()
@IsNotEmpty()
birth_date?: string;

This will result in the exact desired behaviour and only add IsOptional if the condition is true.

Not exactly. your solution doesn't work with props inside the object which you want to check for example you have UserDTO with props firstName, lastName, role, email email is required only if role equals to 'Admin' here I don't know how to use your solution

samislam commented 3 months ago

I answered this on StackOverflow, the solution is straightforward. And I made this decorator by reading the source code of the @ValidateIf() and the @IsOptional() decorators.

Here is my thread: CLICK ME

Here is the solution:

I created my own IsOptionalIf() decorator. You can use the same concept to build your own IsRequiredIf().

New Answer

You can use the @ValidateIf() decorator to achieve that, but you have to create a new decorator around it. You can't use @ValidateIf() directly, because it disables all the validations which is not what you might want. You want to keep the validations but only make the property optional.

This is what this custom IsOptionalIf() decorator allows you to do, It behaves exactly like the normal @IsOptional() decorator but allows you to add a condition.

import { ValidateIf, type ValidationOptions } from 'class-validator'

/** Same as `@Optional()` decorator of class-validator, but adds a conditional layer on top of it */
export const IsOptionalIf: IsOptionalIf =
  (condition, options = {}) =>
  (target: object, propertyKey: string) => {
    const { allowNull = true, allowUndefined = true, ...validationOptions } = options
    ValidateIf((object: any, value: any): boolean => {
      // if condition was true, just disable the validation on the null & undefined fields
      const isOptional = Boolean(condition(object, value))
      const isNull = object[propertyKey] === null
      const isUndefined = object[propertyKey] === undefined
      let isDefined = !(isNull || isUndefined)
      if (!allowNull && allowUndefined) isDefined = !isUndefined
      if (!allowUndefined && allowNull) isDefined = !isNull

      const isRequired = isOptional && !isDefined ? false : true
      return isRequired
    }, validationOptions)(target, propertyKey)
  }

export interface OptionalIfOptions {
  allowNull?: boolean
  allowUndefined?: boolean
}

export type IsOptionalIf = <
  T extends Record<string, any> = any, // class instance
  Y extends keyof T = any, // propertyName
>(
  condition: (object: T, value: T[Y]) => boolean | void,
  validationOptions?: ValidationOptions & OptionalIfOptions
) => PropertyDecorator

Here is how you can use it:

class A {
  @IsString()
  @MaxLength(99)
  @IsOptionalIf((obj) => obj.prop2)
  prop1: string

  @IsBoolean()
  prop2: boolean
}

You can consider null as a real value instead of treating it as not filled by adding the options object as below:

{
// same as above
  @IsOptionalIf((obj) => obj.prop2, { allowNull: false })
  prop1: string
}

Old answer

import { type ValidationMetadataArgs } from 'class-validator/types/metadata/ValidationMetadataArgs'
import { type ValidationOptions, ValidationTypes, getMetadataStorage } from 'class-validator'
import { ValidationMetadata } from 'class-validator/cjs/metadata/ValidationMetadata'

export const IS_OPTIONAL_IF = 'isOptionalIf'

export function IsOptionalIf(
  condition: (object: any, value: any) => boolean,
  validationOptions?: ValidationOptions
): PropertyDecorator {
  return function (object: object, propertyName: string): void {
    const args: ValidationMetadataArgs = {
      type: ValidationTypes.CONDITIONAL_VALIDATION,
      name: IS_OPTIONAL_IF,
      target: object.constructor,
      propertyName: propertyName,
      constraints: [
        (object: any, value: any): boolean => {
          const performValidation = condition(object, value)
          if (!performValidation) return true
          else return object[propertyName] !== null && object[propertyName] !== undefined
        },
      ],
      validationOptions: validationOptions,
    }
    getMetadataStorage().addValidationMetadata(new ValidationMetadata(args))
  }
}

Regarding the ValidationMetadata import, I am using commonjs, so if you're using esm5 (es5) or es2015 (es6+) just replace cjs in the import to yours.

example:

import { ValidationMetadata } from 'class-validator/cjs/metadata/ValidationMetadata'
import { ValidationMetadata } from 'class-validator/esm5/metadata/ValidationMetadata'
import { ValidationMetadata } from 'class-validator/esm2015/metadata/ValidationMetadata'

The reason for this entire method is we want to use the ValidationTypes.CONDITIONAL_VALIDATION type of validation, there are some others, but class-validator gives you only the ValidationTypes.CUSTOM_VALIDATION when you create a custom validator using the registerDecorator() function. It assumes that by default. And that can not be changed. (as of the current version 2024).