From 5ab456d62c558f7460a7d313725d7b37f973dc70 Mon Sep 17 00:00:00 2001 From: Matthew Bell <33056264+matthew2564@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:30:23 +0000 Subject: [PATCH 1/5] perf: series of perf optimisations --- benchmark.ts | 141 +++++++++++++++++++++++++ src/container.ts | 10 +- src/metadata/ConstraintMetadata.ts | 7 +- src/metadata/MetadataStorage.ts | 42 +++++++- src/validation/ValidationExecutor.ts | 148 ++++++++++++++++----------- 5 files changed, 282 insertions(+), 66 deletions(-) create mode 100644 benchmark.ts diff --git a/benchmark.ts b/benchmark.ts new file mode 100644 index 0000000000..677bdf2d9a --- /dev/null +++ b/benchmark.ts @@ -0,0 +1,141 @@ +import 'reflect-metadata'; +import { validate, IsString, IsInt, IsBoolean, IsEmail, IsOptional, MinLength, MaxLength, Min, Max, IsNotEmpty, ValidateNested } from './src'; + +// --- Classes with inheritance and nesting --- +class BaseEntity { + @IsString() + id!: string; + + @IsString() + @MinLength(1) + createdBy!: string; +} + +class Address { + @IsString() + @IsNotEmpty() + street!: string; + + @IsString() + @IsNotEmpty() + city!: string; + + @IsString() + @MinLength(2) + @MaxLength(10) + zip!: string; +} + +class User extends BaseEntity { + @IsString() + @MinLength(2) + @MaxLength(50) + firstName!: string; + + @IsString() + @MinLength(2) + @MaxLength(50) + lastName!: string; + + @IsEmail() + email!: string; + + @IsInt() + @Min(0) + @Max(150) + age!: number; + + @IsBoolean() + isActive!: boolean; + + @IsOptional() + @IsString() + nickname?: string; + + @IsOptional() + @IsString() + bio?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsInt() + @Min(0) + score?: number; + + @IsOptional() + @IsString() + website?: string; + + @ValidateNested() + address!: Address; +} + +// --- Benchmark helpers --- + +function createValidUser(): User { + const user = new User(); + user.id = 'abc-123'; + user.createdBy = 'system'; + user.firstName = 'John'; + user.lastName = 'Doe'; + user.email = 'john@example.com'; + user.age = 30; + user.isActive = true; + user.nickname = 'johnd'; + user.bio = 'A developer'; + user.phone = '555-1234'; + user.score = 100; + user.website = 'https://example.com'; + const address = new Address(); + address.street = '123 Main St'; + address.city = 'Springfield'; + address.zip = '62704'; + user.address = address; + return user; +} + +async function bench(label: string, iterations: number, fn: () => Promise): Promise { + // Warmup + for (let i = 0; i < 100; i++) await fn(); + + const start = performance.now(); + for (let i = 0; i < iterations; i++) await fn(); + const elapsed = performance.now() - start; + + const opsPerSec = Math.round((iterations / elapsed) * 1000); + console.log(`${label}: ${iterations} iterations in ${elapsed.toFixed(1)}ms (${opsPerSec.toLocaleString()} ops/sec)`); +} + +async function main(): Promise { + const iterations = 10_000; + const user = createValidUser(); + + console.log('class-validator benchmark'); + console.log('========================\n'); + + await bench('Valid object (13 props, inheritance + nested)', iterations, async () => { + await validate(user); + }); + + const invalidUser = createValidUser(); + invalidUser.email = 'not-an-email'; + invalidUser.age = -5; + invalidUser.firstName = 'X'; + + await bench('Invalid object (3 errors)', iterations, async () => { + await validate(invalidUser); + }); + + await bench('Valid object with groups', iterations, async () => { + await validate(user, { groups: ['admin', 'user'] }); + }); + + await bench('Valid object with strictGroups', iterations, async () => { + await validate(user, { strictGroups: true }); + }); +} + +main().catch(console.error); diff --git a/src/container.ts b/src/container.ts index 2a288bde81..f0ae803414 100644 --- a/src/container.ts +++ b/src/container.ts @@ -18,15 +18,15 @@ export interface UseContainerOptions { * container simply creates a new instance of the given class. */ const defaultContainer: { get(someClass: { new (...args: any[]): T } | Function): T } = new (class { - private instances: { type: Function; object: any }[] = []; + private instances = new Map(); get(someClass: { new (...args: any[]): T }): T { - let instance = this.instances.find(instance => instance.type === someClass); + let instance = this.instances.get(someClass); if (!instance) { - instance = { type: someClass, object: new someClass() }; - this.instances.push(instance); + instance = new someClass(); + this.instances.set(someClass, instance); } - return instance.object; + return instance; } })(); diff --git a/src/metadata/ConstraintMetadata.ts b/src/metadata/ConstraintMetadata.ts index 61ffcecc13..c2bad80d14 100644 --- a/src/metadata/ConstraintMetadata.ts +++ b/src/metadata/ConstraintMetadata.ts @@ -28,6 +28,8 @@ export class ConstraintMetadata { // Constructor // ------------------------------------------------------------------------- + private _instance!: ValidatorConstraintInterface; + constructor(target: Function, name?: string, async: boolean = false) { this.target = target; this.name = name; @@ -42,6 +44,9 @@ export class ConstraintMetadata { * Instance of the target custom validation class which performs validation. */ get instance(): ValidatorConstraintInterface { - return getFromContainer(this.target); + if (!this._instance) { + this._instance = getFromContainer(this.target); + } + return this._instance; } } diff --git a/src/metadata/MetadataStorage.ts b/src/metadata/MetadataStorage.ts index 286346ac90..ce29496490 100644 --- a/src/metadata/MetadataStorage.ts +++ b/src/metadata/MetadataStorage.ts @@ -12,8 +12,12 @@ export class MetadataStorage { // Private properties // ------------------------------------------------------------------------- + private static _nextId = 0; + private validationMetadatas: Map = new Map(); private constraintMetadatas: Map = new Map(); + private targetMetadataCache: Map = new Map(); + private groupedMetadataCache: Map> = new Map(); get hasValidationMetaData(): boolean { return !!this.validationMetadatas.size; @@ -35,6 +39,9 @@ export class MetadataStorage { * Adds a new validation metadata. */ addValidationMetadata(metadata: ValidationMetadata): void { + this.targetMetadataCache.clear(); + this.groupedMetadataCache.clear(); + const existingMetadata = this.validationMetadatas.get(metadata.target); if (existingMetadata) { @@ -60,18 +67,43 @@ export class MetadataStorage { /** * Groups metadata by their property names. */ - groupByPropertyName(metadata: ValidationMetadata[]): { [propertyName: string]: ValidationMetadata[] } { + groupByPropertyName( + metadata: ValidationMetadata[], + cacheKey?: string + ): { [propertyName: string]: ValidationMetadata[] } { + if (cacheKey) { + const cached = this.groupedMetadataCache.get(cacheKey); + if (cached) return cached; + } + const grouped: { [propertyName: string]: ValidationMetadata[] } = {}; metadata.forEach(metadata => { if (!grouped[metadata.propertyName]) grouped[metadata.propertyName] = []; grouped[metadata.propertyName].push(metadata); }); + + if (cacheKey) { + this.groupedMetadataCache.set(cacheKey, grouped); + } + return grouped; } /** * Gets all validation metadatas for the given object with the given groups. */ + buildCacheKey( + target: Function, + schema: string, + always: boolean, + strictGroups: boolean, + groups?: string[] + ): string { + const targetId = (target as any).__cv_id ?? ((target as any).__cv_id = ++MetadataStorage._nextId); + const groupKey = groups?.length ? groups.slice().sort().join(',') : ''; + return `${targetId}|${schema || ''}|${always ? 1 : 0}|${strictGroups ? 1 : 0}|${groupKey}`; + } + getTargetValidationMetadatas( targetConstructor: Function, targetSchema: string, @@ -79,6 +111,10 @@ export class MetadataStorage { strictGroups: boolean, groups?: string[] ): ValidationMetadata[] { + const cacheKey = this.buildCacheKey(targetConstructor, targetSchema, always, strictGroups, groups); + const cached = this.targetMetadataCache.get(cacheKey); + if (cached) return cached; + const includeMetadataBecauseOfAlwaysOption = (metadata: ValidationMetadata): boolean => { // `metadata.always` overrides global default. if (typeof metadata.always !== 'undefined') return metadata.always; @@ -145,7 +181,9 @@ export class MetadataStorage { }); }); - return originalMetadatas.concat(uniqueInheritedMetadatas); + const result = originalMetadatas.concat(uniqueInheritedMetadatas); + this.targetMetadataCache.set(cacheKey, result); + return result; } /** diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index c86a7896db..d1e6e4fc00 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -58,6 +58,7 @@ export class ValidationExecutor { const forbidUnknownValues = this.validatorOptions?.forbidUnknownValues === undefined || this.validatorOptions.forbidUnknownValues !== false; + const cacheKey = this.metadataStorage.buildCacheKey(object.constructor, targetSchema, always, strictGroups, groups); const targetMetadatas = this.metadataStorage.getTargetValidationMetadatas( object.constructor, targetSchema, @@ -65,7 +66,7 @@ export class ValidationExecutor { strictGroups, groups ); - const groupedMetadatas = this.metadataStorage.groupByPropertyName(targetMetadatas); + const groupedMetadatas = this.metadataStorage.groupByPropertyName(targetMetadatas, cacheKey); if (forbidUnknownValues && !targetMetadatas.length) { const validationError = new ValidationError(); @@ -92,19 +93,24 @@ export class ValidationExecutor { this.whitelist(object, groupedMetadatas, validationErrors); // General validation - Object.keys(groupedMetadatas).forEach(propertyName => { + for (const propertyName in groupedMetadatas) { const value = (object as any)[propertyName]; - const definedMetadatas = groupedMetadatas[propertyName].filter( - metadata => metadata.type === ValidationTypes.IS_DEFINED - ); - const metadatas = groupedMetadatas[propertyName].filter( - metadata => metadata.type !== ValidationTypes.IS_DEFINED && metadata.type !== ValidationTypes.WHITELIST - ); + const allMetadatas = groupedMetadatas[propertyName]; + const definedMetadatas: ValidationMetadata[] = []; + const metadatas: ValidationMetadata[] = []; + let hasPromiseValidation = false; + for (const metadata of allMetadatas) { + if (metadata.type === ValidationTypes.IS_DEFINED) { + definedMetadatas.push(metadata); + } else if (metadata.type !== ValidationTypes.WHITELIST) { + metadatas.push(metadata); + if (metadata.type === ValidationTypes.PROMISE_VALIDATION) { + hasPromiseValidation = true; + } + } + } - if ( - value instanceof Promise && - metadatas.find(metadata => metadata.type === ValidationTypes.PROMISE_VALIDATION) - ) { + if (value instanceof Promise && hasPromiseValidation) { this.awaitingPromises.push( value.then(resolvedValue => { this.performValidations(object, resolvedValue, propertyName, definedMetadatas, metadatas, validationErrors); @@ -113,7 +119,7 @@ export class ValidationExecutor { } else { this.performValidations(object, value, propertyName, definedMetadatas, metadatas, validationErrors); } - }); + } } whitelist( @@ -175,29 +181,43 @@ export class ValidationExecutor { metadatas: ValidationMetadata[], validationErrors: ValidationError[] ): void { - const customValidationMetadatas = metadatas.filter(metadata => metadata.type === ValidationTypes.CUSTOM_VALIDATION); - const nestedValidationMetadatas = metadatas.filter(metadata => metadata.type === ValidationTypes.NESTED_VALIDATION); - const conditionalValidationMetadatas = metadatas.filter( - metadata => metadata.type === ValidationTypes.CONDITIONAL_VALIDATION - ); + const customValidationMetadatas: ValidationMetadata[] = []; + const nestedValidationMetadatas: ValidationMetadata[] = []; + const conditionalValidationMetadatas: ValidationMetadata[] = []; + for (const metadata of metadatas) { + switch (metadata.type) { + case ValidationTypes.CUSTOM_VALIDATION: + customValidationMetadatas.push(metadata); + break; + case ValidationTypes.NESTED_VALIDATION: + nestedValidationMetadatas.push(metadata); + break; + case ValidationTypes.CONDITIONAL_VALIDATION: + conditionalValidationMetadatas.push(metadata); + break; + } + } const validationError = this.generateValidationError(object, value, propertyName); - validationErrors.push(validationError); const canValidate = this.conditionalValidations(object, value, conditionalValidationMetadatas); if (!canValidate) { return; } + const asyncCountBefore = this.awaitingPromises.length; + // handle IS_DEFINED validation type the special way - it should work no matter skipUndefinedProperties/skipMissingProperties is set or not this.customValidations(object, value, definedMetadatas, validationError); this.mapContexts(object, value, definedMetadatas, validationError); if (value === undefined && this.validatorOptions && this.validatorOptions.skipUndefinedProperties === true) { + this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); return; } if (value === null && this.validatorOptions && this.validatorOptions.skipNullProperties === true) { + this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); return; } @@ -206,6 +226,7 @@ export class ValidationExecutor { this.validatorOptions && this.validatorOptions.skipMissingProperties === true ) { + this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); return; } @@ -213,7 +234,22 @@ export class ValidationExecutor { this.nestedValidations(value, nestedValidationMetadatas, validationError); this.mapContexts(object, value, metadatas, validationError); - this.mapContexts(object, value, customValidationMetadatas, validationError); + + this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); + } + + private pushErrorIfNeeded( + error: ValidationError, + asyncCountBefore: number, + validationErrors: ValidationError[] + ): void { + const hasConstraints = error.constraints && Object.keys(error.constraints).length > 0; + const hasChildren = error.children && error.children.length > 0; + const hasAsyncPending = this.awaitingPromises.length > asyncCountBefore; + + if (hasConstraints || hasChildren || hasAsyncPending) { + validationErrors.push(error); + } } private generateValidationError(object: object, value: any, propertyName: string): ValidationError { @@ -242,38 +278,34 @@ export class ValidationExecutor { return validationError; } - private conditionalValidations(object: object, value: any, metadatas: ValidationMetadata[]): ValidationMetadata[] { - return metadatas - .map(metadata => metadata.constraints[0](object, value)) - .reduce((resultA, resultB) => resultA && resultB, true); + private conditionalValidations(object: object, value: any, metadatas: ValidationMetadata[]): boolean { + for (const metadata of metadatas) { + if (!metadata.constraints[0](object, value)) return false; + } + return true; } private customValidations(object: object, value: any, metadatas: ValidationMetadata[], error: ValidationError): void { - metadatas.forEach(metadata => { - const getValidationArguments = () => { - const validationArguments: ValidationArguments = { - targetName: object.constructor ? (object.constructor as any).name : undefined, - property: metadata.propertyName, - object: object, - value: value, - constraints: metadata.constraints, - }; - return validationArguments; - }; - if (metadata.validateIf) { - const shouldValidate = metadata.validateIf(object, value); - if (!shouldValidate) return; - } - 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; + if (metadatas.length === 0) return; + + const targetName = object.constructor ? (object.constructor as any).name : undefined; + const stopAtFirstError = this.validatorOptions && this.validatorOptions.stopAtFirstError; + const validationArguments: ValidationArguments = { + targetName: targetName, + property: metadatas[0].propertyName, + object: object, + value: value, + constraints: undefined as any, + }; + + for (const metadata of metadatas) { + validationArguments.constraints = metadata.constraints; + if (metadata.validateIf && !metadata.validateIf(object, value)) continue; - const validationArguments = getValidationArguments(); + const constraintMetadatas = this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls); + for (const customConstraintMetadata of constraintMetadatas) { + if (customConstraintMetadata.async && this.ignoreAsyncValidations) continue; + if (stopAtFirstError && Object.keys(error.constraints || {}).length > 0) continue; if (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map)) { const validatedValue = customConstraintMetadata.instance.validate(value, validationArguments); @@ -298,7 +330,7 @@ export class ValidationExecutor { } } - return; + continue; } // convert set and map into array @@ -334,7 +366,7 @@ export class ValidationExecutor { this.awaitingPromises.push(asyncValidationIsFinishedPromise); - return; + continue; } const validationResult = validatedSubValues.every((isValid: boolean) => isValid); @@ -342,8 +374,8 @@ export class ValidationExecutor { const [type, message] = this.createValidationError(object, value, metadata, customConstraintMetadata); error.constraints[type] = message; } - }); - }); + } + } } private nestedValidations(value: any, metadatas: ValidationMetadata[], error: ValidationError): void { @@ -351,15 +383,15 @@ export class ValidationExecutor { return; } - metadatas.forEach(metadata => { + for (const metadata of metadatas) { if (metadata.type !== ValidationTypes.NESTED_VALIDATION && metadata.type !== ValidationTypes.PROMISE_VALIDATION) { - return; + continue; } else if ( this.validatorOptions && this.validatorOptions.stopAtFirstError && Object.keys(error.constraints || {}).length > 0 ) { - return; + continue; } if (Array.isArray(value) || value instanceof Set || value instanceof Map) { @@ -375,11 +407,11 @@ export class ValidationExecutor { const [type, message] = this.createValidationError(metadata.target as object, value, metadata); error.constraints[type] = message; } - }); + } } private mapContexts(object: object, value: any, metadatas: ValidationMetadata[], error: ValidationError): void { - return metadatas.forEach(metadata => { + for (const metadata of metadatas) { if (metadata.context) { let customConstraint; if (metadata.type === ValidationTypes.CUSTOM_VALIDATION) { @@ -397,7 +429,7 @@ export class ValidationExecutor { error.contexts[type] = Object.assign(error.contexts[type] || {}, metadata.context); } } - }); + } } private createValidationError( From 2f5154be4d5c4a4b0d81086a6f10e9461bd310b6 Mon Sep 17 00:00:00 2001 From: Matthew Bell <33056264+matthew2564@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:44:51 +0000 Subject: [PATCH 2/5] perf: series of validation execution optimisations --- benchmark.ts | 28 +- src/decorator/array/ArrayContains.ts | 3 +- src/decorator/array/ArrayNotContains.ts | 3 +- src/decorator/array/ArrayUnique.ts | 3 +- src/decorator/common/IsIn.ts | 2 +- src/decorator/common/IsNotIn.ts | 2 +- src/decorator/object/IsNotEmptyObject.ts | 10 +- src/decorator/typechecker/IsEnum.ts | 5 +- src/metadata/MetadataStorage.ts | 76 ++++- src/metadata/ValidationMetadata.ts | 17 + src/register-decorator.ts | 13 +- src/validation/ValidationExecutor.ts | 387 ++++++++++++++++------- src/validation/Validator.ts | 10 +- 13 files changed, 423 insertions(+), 136 deletions(-) diff --git a/benchmark.ts b/benchmark.ts index 677bdf2d9a..e920ce7856 100644 --- a/benchmark.ts +++ b/benchmark.ts @@ -1,5 +1,5 @@ import 'reflect-metadata'; -import { validate, IsString, IsInt, IsBoolean, IsEmail, IsOptional, MinLength, MaxLength, Min, Max, IsNotEmpty, ValidateNested } from './src'; +import { validate, validateSync, IsString, IsInt, IsBoolean, IsEmail, IsOptional, MinLength, MaxLength, Min, Max, IsNotEmpty, ValidateNested } from './src'; // --- Classes with inheritance and nesting --- class BaseEntity { @@ -109,6 +109,18 @@ async function bench(label: string, iterations: number, fn: () => Promise) console.log(`${label}: ${iterations} iterations in ${elapsed.toFixed(1)}ms (${opsPerSec.toLocaleString()} ops/sec)`); } +function benchSync(label: string, iterations: number, fn: () => void): void { + // Warmup + for (let i = 0; i < 100; i++) fn(); + + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + const elapsed = performance.now() - start; + + const opsPerSec = Math.round((iterations / elapsed) * 1000); + console.log(`${label}: ${iterations} iterations in ${elapsed.toFixed(1)}ms (${opsPerSec.toLocaleString()} ops/sec)`); +} + async function main(): Promise { const iterations = 10_000; const user = createValidUser(); @@ -136,6 +148,20 @@ async function main(): Promise { await bench('Valid object with strictGroups', iterations, async () => { await validate(user, { strictGroups: true }); }); + + console.log('\n--- validateSync (no Promise overhead) ---\n'); + + benchSync('Valid object (sync)', iterations, () => { + validateSync(user); + }); + + benchSync('Invalid object (sync)', iterations, () => { + validateSync(invalidUser); + }); + + benchSync('Valid object with strictGroups (sync)', iterations, () => { + validateSync(user, { strictGroups: true }); + }); } main().catch(console.error); diff --git a/src/decorator/array/ArrayContains.ts b/src/decorator/array/ArrayContains.ts index b2242c15d2..574cf12144 100644 --- a/src/decorator/array/ArrayContains.ts +++ b/src/decorator/array/ArrayContains.ts @@ -10,7 +10,8 @@ export const ARRAY_CONTAINS = 'arrayContains'; export function arrayContains(array: unknown, values: any[]): boolean { if (!Array.isArray(array)) return false; - return values.every(value => array.indexOf(value) !== -1); + const arraySet = new Set(array); + return values.every(value => arraySet.has(value)); } /** diff --git a/src/decorator/array/ArrayNotContains.ts b/src/decorator/array/ArrayNotContains.ts index fca6c4ccb5..421a53a22a 100644 --- a/src/decorator/array/ArrayNotContains.ts +++ b/src/decorator/array/ArrayNotContains.ts @@ -10,7 +10,8 @@ export const ARRAY_NOT_CONTAINS = 'arrayNotContains'; export function arrayNotContains(array: unknown, values: any[]): boolean { if (!Array.isArray(array)) return false; - return values.every(value => array.indexOf(value) === -1); + const arraySet = new Set(array); + return values.every(value => !arraySet.has(value)); } /** diff --git a/src/decorator/array/ArrayUnique.ts b/src/decorator/array/ArrayUnique.ts index 0979aeefc0..48bf06f70a 100644 --- a/src/decorator/array/ArrayUnique.ts +++ b/src/decorator/array/ArrayUnique.ts @@ -15,8 +15,7 @@ export function arrayUnique(array: unknown[], identifier?: ArrayUniqueIdentifier array = array.map(o => (o != null ? identifier(o) : o)); } - const uniqueItems = array.filter((a, b, c) => c.indexOf(a) === b); - return array.length === uniqueItems.length; + return new Set(array).size === array.length; } /** diff --git a/src/decorator/common/IsIn.ts b/src/decorator/common/IsIn.ts index d074dcca5e..c9e97ef47f 100644 --- a/src/decorator/common/IsIn.ts +++ b/src/decorator/common/IsIn.ts @@ -7,7 +7,7 @@ export const IS_IN = 'isIn'; * Checks if given value is in a array of allowed values. */ export function isIn(value: unknown, possibleValues: readonly unknown[]): boolean { - return Array.isArray(possibleValues) && possibleValues.some(possibleValue => possibleValue === value); + return Array.isArray(possibleValues) && possibleValues.includes(value); } /** diff --git a/src/decorator/common/IsNotIn.ts b/src/decorator/common/IsNotIn.ts index 187e91d96a..35e0d20980 100644 --- a/src/decorator/common/IsNotIn.ts +++ b/src/decorator/common/IsNotIn.ts @@ -7,7 +7,7 @@ export const IS_NOT_IN = 'isNotIn'; * Checks if given value not in a array of allowed values. */ export function isNotIn(value: unknown, possibleValues: readonly unknown[]): boolean { - return !Array.isArray(possibleValues) || !possibleValues.some(possibleValue => possibleValue === value); + return !Array.isArray(possibleValues) || !possibleValues.includes(value); } /** diff --git a/src/decorator/object/IsNotEmptyObject.ts b/src/decorator/object/IsNotEmptyObject.ts index 64aada8014..58602033a7 100644 --- a/src/decorator/object/IsNotEmptyObject.ts +++ b/src/decorator/object/IsNotEmptyObject.ts @@ -14,7 +14,15 @@ export function isNotEmptyObject(value: unknown, options?: { nullable?: boolean } if (options?.nullable === false) { - return !Object.values(value).every(propertyValue => propertyValue === null || propertyValue === undefined); + for (const key in value as object) { + if ((value as object).hasOwnProperty(key)) { + const propertyValue = (value as any)[key]; + if (propertyValue !== null && propertyValue !== undefined) { + return true; + } + } + } + return false; } for (const key in value) { diff --git a/src/decorator/typechecker/IsEnum.ts b/src/decorator/typechecker/IsEnum.ts index eb3d6b064b..af32b370a4 100644 --- a/src/decorator/typechecker/IsEnum.ts +++ b/src/decorator/typechecker/IsEnum.ts @@ -7,7 +7,7 @@ export const IS_ENUM = 'isEnum'; * Checks if a given value is the member of the provided enum. */ export function isEnum(value: unknown, entity: any): boolean { - const enumValues = Object.keys(entity).map(k => entity[k]); + const enumValues = Object.values(entity); return enumValues.includes(value); } @@ -24,12 +24,13 @@ function validEnumValues(entity: any): string[] { * Checks if a given value is the member of the provided enum. */ export function IsEnum(entity: object, validationOptions?: ValidationOptions): PropertyDecorator { + const enumValuesSet = new Set(Object.values(entity)); return ValidateBy( { name: IS_ENUM, constraints: [entity, validEnumValues(entity)], validator: { - validate: (value, args): boolean => isEnum(value, args?.constraints[0]), + validate: (value): boolean => enumValuesSet.has(value), defaultMessage: buildMessage( eachPrefix => eachPrefix + '$property must be one of the following values: $constraint2', validationOptions diff --git a/src/metadata/MetadataStorage.ts b/src/metadata/MetadataStorage.ts index ce29496490..d77b5438f4 100644 --- a/src/metadata/MetadataStorage.ts +++ b/src/metadata/MetadataStorage.ts @@ -3,6 +3,20 @@ import { ConstraintMetadata } from './ConstraintMetadata'; import { ValidationSchema } from '../validation-schema/ValidationSchema'; import { ValidationSchemaToMetadataTransformer } from '../validation-schema/ValidationSchemaToMetadataTransformer'; import { getGlobal } from '../utils'; +import { ValidationTypes } from '../validation/ValidationTypes'; + +export interface PartitionedPropertyMetadata { + defined: ValidationMetadata[]; + custom: ValidationMetadata[]; + nested: ValidationMetadata[]; + conditional: ValidationMetadata[]; + all: ValidationMetadata[]; + hasPromiseValidation: boolean; + /** True when only custom validators exist (no defined/nested/conditional) — enables fast path */ + customOnly: boolean; +} + +export type PartitionedMetadata = Record; /** * Storage all metadatas. @@ -18,6 +32,7 @@ export class MetadataStorage { private constraintMetadatas: Map = new Map(); private targetMetadataCache: Map = new Map(); private groupedMetadataCache: Map> = new Map(); + private partitionedMetadataCache: Map = new Map(); get hasValidationMetaData(): boolean { return !!this.validationMetadatas.size; @@ -41,6 +56,7 @@ export class MetadataStorage { addValidationMetadata(metadata: ValidationMetadata): void { this.targetMetadataCache.clear(); this.groupedMetadataCache.clear(); + this.partitionedMetadataCache.clear(); const existingMetadata = this.validationMetadatas.get(metadata.target); @@ -89,6 +105,57 @@ export class MetadataStorage { return grouped; } + /** + * Returns pre-partitioned metadata grouped by property name, with each property's + * metadata split by type. Cached for repeated validations of the same class. + */ + getPartitionedMetadata( + groupedMetadatas: Record, + cacheKey: string + ): PartitionedMetadata { + const cached = this.partitionedMetadataCache.get(cacheKey); + if (cached) return cached; + + const result: PartitionedMetadata = {}; + for (const propertyName in groupedMetadatas) { + const allMetadatas = groupedMetadatas[propertyName]; + const defined: ValidationMetadata[] = []; + const custom: ValidationMetadata[] = []; + const nested: ValidationMetadata[] = []; + const conditional: ValidationMetadata[] = []; + const all: ValidationMetadata[] = []; + let hasPromiseValidation = false; + + for (const metadata of allMetadatas) { + if (metadata.type === ValidationTypes.IS_DEFINED) { + defined.push(metadata); + } else if (metadata.type !== ValidationTypes.WHITELIST) { + all.push(metadata); + switch (metadata.type) { + case ValidationTypes.CUSTOM_VALIDATION: + custom.push(metadata); + break; + case ValidationTypes.NESTED_VALIDATION: + nested.push(metadata); + break; + case ValidationTypes.CONDITIONAL_VALIDATION: + conditional.push(metadata); + break; + case ValidationTypes.PROMISE_VALIDATION: + hasPromiseValidation = true; + break; + } + } + } + + const customOnly = defined.length === 0 && nested.length === 0 && conditional.length === 0 && !hasPromiseValidation; + result[propertyName] = { defined, custom, nested, conditional, all, hasPromiseValidation, customOnly }; + } + + this.partitionedMetadataCache.set(cacheKey, result); + return result; + } + /** * Gets all validation metadatas for the given object with the given groups. */ @@ -109,10 +176,11 @@ export class MetadataStorage { targetSchema: string, always: boolean, strictGroups: boolean, - groups?: string[] + groups?: string[], + cacheKey?: string ): ValidationMetadata[] { - const cacheKey = this.buildCacheKey(targetConstructor, targetSchema, always, strictGroups, groups); - const cached = this.targetMetadataCache.get(cacheKey); + const key = cacheKey ?? this.buildCacheKey(targetConstructor, targetSchema, always, strictGroups, groups); + const cached = this.targetMetadataCache.get(key); if (cached) return cached; const includeMetadataBecauseOfAlwaysOption = (metadata: ValidationMetadata): boolean => { @@ -182,7 +250,7 @@ export class MetadataStorage { }); const result = originalMetadatas.concat(uniqueInheritedMetadatas); - this.targetMetadataCache.set(cacheKey, result); + this.targetMetadataCache.set(key, result); return result; } diff --git a/src/metadata/ValidationMetadata.ts b/src/metadata/ValidationMetadata.ts index 93d590759e..8e0628ad2a 100644 --- a/src/metadata/ValidationMetadata.ts +++ b/src/metadata/ValidationMetadata.ts @@ -74,6 +74,23 @@ export class ValidationMetadata { */ validationTypeOptions: any; + /** + * Cached resolved constraint metadatas for this validation. + * Populated on first access to avoid repeated Map lookups. + */ + resolvedConstraints: any[] | undefined = undefined; + + /** + * Inline validate function for built-in validators, bypassing constraint metadata dispatch. + */ + inlineValidate?: (value: any, args?: any) => Promise | boolean; + + /** + * Inline defaultMessage function for built-in validators. + */ + inlineDefaultMessage?: (args?: any) => string; + + // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- diff --git a/src/register-decorator.ts b/src/register-decorator.ts index ffe4bf86a5..53244e90f4 100644 --- a/src/register-decorator.ts +++ b/src/register-decorator.ts @@ -83,5 +83,16 @@ export function registerDecorator(options: ValidationDecoratorOptions): void { constraintCls: constraintCls, constraints: options.constraints, }; - getMetadataStorage().addValidationMetadata(new ValidationMetadata(validationMetadataArgs)); + const validationMetadata = new ValidationMetadata(validationMetadataArgs); + + // For inline object validators (all built-in decorators), store the validate/defaultMessage + // functions directly to bypass constraint metadata lookup and wrapper class dispatch. + if (!(options.validator instanceof Function)) { + validationMetadata.inlineValidate = options.validator.validate.bind(options.validator); + if (options.validator.defaultMessage) { + validationMetadata.inlineDefaultMessage = options.validator.defaultMessage.bind(options.validator); + } + } + + getMetadataStorage().addValidationMetadata(validationMetadata); } diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index d1e6e4fc00..f38b20a0f3 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -7,7 +7,14 @@ import { ConstraintMetadata } from '../metadata/ConstraintMetadata'; import { ValidationArguments } from './ValidationArguments'; import { ValidationUtils } from './ValidationUtils'; import { isPromise, convertToArray } from '../utils'; -import { getMetadataStorage } from '../metadata/MetadataStorage'; +import { getMetadataStorage, PartitionedPropertyMetadata } from '../metadata/MetadataStorage'; + +/** Checks if an object has any own enumerable properties without allocating an array. */ +function hasConstraints(constraints: Record | undefined): boolean { + if (!constraints) return false; + for (const _ in constraints) return true; + return false; +} /** * Executes validation over given object. @@ -25,12 +32,49 @@ export class ValidationExecutor { // ------------------------------------------------------------------------- private metadataStorage = getMetadataStorage(); + private readonly skipUndefined: boolean; + private readonly skipNull: boolean; + private readonly skipMissing: boolean; + private readonly includeTarget: boolean; + private readonly includeValue: boolean; + private readonly stopAtFirstError: boolean; + private readonly dismissDefaultMessages: boolean; + private readonly groups: string[] | undefined; + private readonly strictGroups: boolean; + private readonly always: boolean; + private readonly forbidUnknownValues: boolean; + private readonly whitelist: boolean; + private readonly forbidNonWhitelisted: boolean; + private currentTargetName: string | undefined; // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- - constructor(private validator: Validator, private validatorOptions?: ValidatorOptions) {} + constructor(private validator: Validator, private validatorOptions?: ValidatorOptions) { + this.skipUndefined = (validatorOptions && validatorOptions.skipUndefinedProperties === true) || false; + this.skipNull = (validatorOptions && validatorOptions.skipNullProperties === true) || false; + this.skipMissing = (validatorOptions && validatorOptions.skipMissingProperties === true) || false; + this.includeTarget = + !validatorOptions || + !validatorOptions.validationError || + validatorOptions.validationError.target === undefined || + validatorOptions.validationError.target === true; + this.includeValue = + !validatorOptions || + !validatorOptions.validationError || + validatorOptions.validationError.value === undefined || + validatorOptions.validationError.value === true; + this.stopAtFirstError = (validatorOptions && validatorOptions.stopAtFirstError) || false; + this.dismissDefaultMessages = (validatorOptions && validatorOptions.dismissDefaultMessages) || false; + this.groups = validatorOptions ? validatorOptions.groups : undefined; + this.strictGroups = (validatorOptions && validatorOptions.strictGroups) || false; + this.always = (validatorOptions && validatorOptions.always) || false; + this.forbidUnknownValues = + !validatorOptions || validatorOptions.forbidUnknownValues === undefined || validatorOptions.forbidUnknownValues !== false; + this.whitelist = (validatorOptions && validatorOptions.whitelist) || false; + this.forbidNonWhitelisted = (validatorOptions && validatorOptions.forbidNonWhitelisted) || false; + } // ------------------------------------------------------------------------- // Public Methods @@ -51,33 +95,21 @@ export class ValidationExecutor { ); } - const groups = this.validatorOptions ? this.validatorOptions.groups : undefined; - const strictGroups = (this.validatorOptions && this.validatorOptions.strictGroups) || false; - const always = (this.validatorOptions && this.validatorOptions.always) || false; - /** Forbid unknown values are turned on by default and any other value than false will enable it. */ - const forbidUnknownValues = - this.validatorOptions?.forbidUnknownValues === undefined || this.validatorOptions.forbidUnknownValues !== false; - - const cacheKey = this.metadataStorage.buildCacheKey(object.constructor, targetSchema, always, strictGroups, groups); + const cacheKey = this.metadataStorage.buildCacheKey(object.constructor, targetSchema, this.always, this.strictGroups, this.groups); const targetMetadatas = this.metadataStorage.getTargetValidationMetadatas( object.constructor, targetSchema, - always, - strictGroups, - groups + this.always, + this.strictGroups, + this.groups, + cacheKey ); const groupedMetadatas = this.metadataStorage.groupByPropertyName(targetMetadatas, cacheKey); - if (forbidUnknownValues && !targetMetadatas.length) { + if (this.forbidUnknownValues && !targetMetadatas.length) { const validationError = new ValidationError(); - if ( - !this.validatorOptions || - !this.validatorOptions.validationError || - this.validatorOptions.validationError.target === undefined || - this.validatorOptions.validationError.target === true - ) - validationError.target = object; + if (this.includeTarget) validationError.target = object; validationError.value = undefined; validationError.property = undefined; @@ -89,54 +121,41 @@ export class ValidationExecutor { return; } - if (this.validatorOptions && this.validatorOptions.whitelist) - this.whitelist(object, groupedMetadatas, validationErrors); + if (this.whitelist) this.whitelistValidation(object, groupedMetadatas, validationErrors); // General validation - for (const propertyName in groupedMetadatas) { + this.currentTargetName = object.constructor ? (object.constructor as any).name : undefined; + const partitioned = this.metadataStorage.getPartitionedMetadata(groupedMetadatas, cacheKey); + for (const propertyName in partitioned) { const value = (object as any)[propertyName]; - const allMetadatas = groupedMetadatas[propertyName]; - const definedMetadatas: ValidationMetadata[] = []; - const metadatas: ValidationMetadata[] = []; - let hasPromiseValidation = false; - for (const metadata of allMetadatas) { - if (metadata.type === ValidationTypes.IS_DEFINED) { - definedMetadatas.push(metadata); - } else if (metadata.type !== ValidationTypes.WHITELIST) { - metadatas.push(metadata); - if (metadata.type === ValidationTypes.PROMISE_VALIDATION) { - hasPromiseValidation = true; - } - } - } + const partition = partitioned[propertyName]; - if (value instanceof Promise && hasPromiseValidation) { + if (partition.hasPromiseValidation && value instanceof Promise) { this.awaitingPromises.push( value.then(resolvedValue => { - this.performValidations(object, resolvedValue, propertyName, definedMetadatas, metadatas, validationErrors); + this.performValidations(object, resolvedValue, propertyName, partition, validationErrors); }) ); } else { - this.performValidations(object, value, propertyName, definedMetadatas, metadatas, validationErrors); + this.performValidations(object, value, propertyName, partition, validationErrors); } } } - whitelist( + whitelistValidation( object: any, groupedMetadatas: { [propertyName: string]: ValidationMetadata[] }, validationErrors: ValidationError[] ): void { const notAllowedProperties: string[] = []; - Object.keys(object).forEach(propertyName => { - // does this property have no metadata? + for (const propertyName in object) { if (!groupedMetadatas[propertyName] || groupedMetadatas[propertyName].length === 0) notAllowedProperties.push(propertyName); - }); + } if (notAllowedProperties.length > 0) { - if (this.validatorOptions && this.validatorOptions.forbidNonWhitelisted) { + if (this.forbidNonWhitelisted) { // throw errors notAllowedProperties.forEach(property => { const validationError: ValidationError = this.generateValidationError(object, object[property], property); @@ -157,7 +176,7 @@ export class ValidationExecutor { error.children = this.stripEmptyErrors(error.children); } - if (Object.keys(error.constraints).length === 0) { + if (!hasConstraints(error.constraints)) { if (error.children.length === 0) { return false; } else { @@ -177,65 +196,134 @@ export class ValidationExecutor { object: any, value: any, propertyName: string, - definedMetadatas: ValidationMetadata[], - metadatas: ValidationMetadata[], + partition: PartitionedPropertyMetadata, validationErrors: ValidationError[] ): void { - const customValidationMetadatas: ValidationMetadata[] = []; - const nestedValidationMetadatas: ValidationMetadata[] = []; - const conditionalValidationMetadatas: ValidationMetadata[] = []; - for (const metadata of metadatas) { - switch (metadata.type) { - case ValidationTypes.CUSTOM_VALIDATION: - customValidationMetadatas.push(metadata); - break; - case ValidationTypes.NESTED_VALIDATION: - nestedValidationMetadatas.push(metadata); - break; - case ValidationTypes.CONDITIONAL_VALIDATION: - conditionalValidationMetadatas.push(metadata); - break; + // Fast path: most properties have only custom validators with no conditionals/defined/nested + if (partition.customOnly) { + const metadatas = partition.custom; + const validationArguments: ValidationArguments = { + targetName: this.currentTargetName, + property: propertyName, + object: object, + value: value, + constraints: undefined as any, + }; + + let validationError: ValidationError | undefined; + const asyncCountBefore = this.awaitingPromises.length; + + for (const metadata of metadatas) { + validationArguments.constraints = metadata.constraints; + + if (metadata.inlineValidate && (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map))) { + if (this.stopAtFirstError && validationError && hasConstraints(validationError.constraints)) continue; + + const validatedValue = metadata.inlineValidate(value, validationArguments); + if (validatedValue !== true && validatedValue !== false) { + const promise = (validatedValue as Promise).then(isValid => { + if (!isValid) { + if (!validationError) validationError = this.generateValidationError(object, value, propertyName); + const [type, message] = this.createValidationErrorInline(metadata, validationArguments); + validationError.constraints[type] = message; + if (metadata.context) { + if (!validationError.contexts) validationError.contexts = {}; + validationError.contexts[type] = Object.assign(validationError.contexts[type] || {}, metadata.context); + } + } + }); + this.awaitingPromises.push(promise); + } else if (!validatedValue) { + if (!validationError) validationError = this.generateValidationError(object, value, propertyName); + const [type, message] = this.createValidationErrorInline(metadata, validationArguments); + validationError.constraints[type] = message; + if (metadata.context) { + if (!validationError.contexts) validationError.contexts = {}; + validationError.contexts[type] = Object.assign(validationError.contexts[type] || {}, metadata.context); + } + } + continue; + } + + // Fallback for non-inline validators (user-defined class validators, each validators) + const getError = (): ValidationError => { + if (!validationError) validationError = this.generateValidationError(object, value, propertyName); + return validationError; + }; + this.customValidations(object, value, [metadata], getError); } - } - const validationError = this.generateValidationError(object, value, propertyName); + if (validationError) { + const hasAsyncPending = this.awaitingPromises.length > asyncCountBefore; + if (hasConstraints(validationError.constraints) || (validationError.children && validationError.children.length > 0) || hasAsyncPending) { + validationErrors.push(validationError); + } + } else if (this.awaitingPromises.length > asyncCountBefore) { + validationError = this.generateValidationError(object, value, propertyName); + validationErrors.push(validationError); + } + return; + } - const canValidate = this.conditionalValidations(object, value, conditionalValidationMetadatas); + const canValidate = this.conditionalValidations(object, value, partition.conditional); if (!canValidate) { return; } + // Lazily create error — only allocate when something needs it + let validationError: ValidationError | undefined; + const getError = (): ValidationError => { + if (!validationError) { + validationError = this.generateValidationError(object, value, propertyName); + } + return validationError; + }; + const asyncCountBefore = this.awaitingPromises.length; // handle IS_DEFINED validation type the special way - it should work no matter skipUndefinedProperties/skipMissingProperties is set or not - this.customValidations(object, value, definedMetadatas, validationError); - this.mapContexts(object, value, definedMetadatas, validationError); + if (partition.defined.length > 0) { + this.customValidations(object, value, partition.defined, getError); + if (validationError) this.mapContexts(object, value, partition.defined, validationError); + } - if (value === undefined && this.validatorOptions && this.validatorOptions.skipUndefinedProperties === true) { - this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); + if (value === undefined && this.skipUndefined) { + if (validationError) this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); return; } - if (value === null && this.validatorOptions && this.validatorOptions.skipNullProperties === true) { - this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); + if (value === null && this.skipNull) { + if (validationError) this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); return; } - if ( - (value === null || value === undefined) && - this.validatorOptions && - this.validatorOptions.skipMissingProperties === true - ) { - this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); + if ((value === null || value === undefined) && this.skipMissing) { + if (validationError) this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); return; } - this.customValidations(object, value, customValidationMetadatas, validationError); - this.nestedValidations(value, nestedValidationMetadatas, validationError); + if (partition.custom.length > 0) { + this.customValidations(object, value, partition.custom, getError); + } + + if (partition.nested.length > 0) { + this.nestedValidations(value, partition.nested, getError()); + } + + // If async validators were added, we must eagerly create and push the error + // since async callbacks will write to it after this method returns + const hasAsyncPending = this.awaitingPromises.length > asyncCountBefore; + if (hasAsyncPending && !validationError) { + validationError = getError(); + } - this.mapContexts(object, value, metadatas, validationError); + if (partition.all.length > 0 && validationError) { + this.mapContexts(object, value, partition.all, validationError); + } - this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); + if (validationError) { + this.pushErrorIfNeeded(validationError, asyncCountBefore, validationErrors); + } } private pushErrorIfNeeded( @@ -243,11 +331,10 @@ export class ValidationExecutor { asyncCountBefore: number, validationErrors: ValidationError[] ): void { - const hasConstraints = error.constraints && Object.keys(error.constraints).length > 0; const hasChildren = error.children && error.children.length > 0; const hasAsyncPending = this.awaitingPromises.length > asyncCountBefore; - if (hasConstraints || hasChildren || hasAsyncPending) { + if (hasConstraints(error.constraints) || hasChildren || hasAsyncPending) { validationErrors.push(error); } } @@ -255,21 +342,8 @@ export class ValidationExecutor { private generateValidationError(object: object, value: any, propertyName: string): ValidationError { const validationError = new ValidationError(); - if ( - !this.validatorOptions || - !this.validatorOptions.validationError || - this.validatorOptions.validationError.target === undefined || - this.validatorOptions.validationError.target === true - ) - validationError.target = object; - - if ( - !this.validatorOptions || - !this.validatorOptions.validationError || - this.validatorOptions.validationError.value === undefined || - this.validatorOptions.validationError.value === true - ) - validationError.value = value; + if (this.includeTarget) validationError.target = object; + if (this.includeValue) validationError.value = value; validationError.property = propertyName; validationError.children = []; @@ -285,13 +359,16 @@ export class ValidationExecutor { return true; } - private customValidations(object: object, value: any, metadatas: ValidationMetadata[], error: ValidationError): void { + private customValidations( + object: object, + value: any, + metadatas: ValidationMetadata[], + getError: () => ValidationError + ): void { if (metadatas.length === 0) return; - const targetName = object.constructor ? (object.constructor as any).name : undefined; - const stopAtFirstError = this.validatorOptions && this.validatorOptions.stopAtFirstError; const validationArguments: ValidationArguments = { - targetName: targetName, + targetName: this.currentTargetName, property: metadatas[0].propertyName, object: object, value: value, @@ -302,16 +379,59 @@ export class ValidationExecutor { validationArguments.constraints = metadata.constraints; if (metadata.validateIf && !metadata.validateIf(object, value)) continue; - const constraintMetadatas = this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls); + // Fast path: inline validators (all built-in decorators) bypass constraint metadata dispatch + if (metadata.inlineValidate && (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map))) { + if (this.stopAtFirstError) { + const error = getError(); + if (hasConstraints(error.constraints)) continue; + } + + const validatedValue = metadata.inlineValidate(value, validationArguments); + if (validatedValue !== true && validatedValue !== false) { + // Async result (Promise) + const promise = (validatedValue as Promise).then(isValid => { + if (!isValid) { + const error = getError(); + const [type, message] = this.createValidationErrorInline(metadata, validationArguments); + error.constraints[type] = message; + if (metadata.context) { + if (!error.contexts) { + error.contexts = {}; + } + error.contexts[type] = Object.assign(error.contexts[type] || {}, metadata.context); + } + } + }); + this.awaitingPromises.push(promise); + } else if (!validatedValue) { + const error = getError(); + const [type, message] = this.createValidationErrorInline(metadata, validationArguments); + error.constraints[type] = message; + if (metadata.context) { + if (!error.contexts) { + error.contexts = {}; + } + error.contexts[type] = Object.assign(error.contexts[type] || {}, metadata.context); + } + } + continue; + } + + const constraintMetadatas = metadata.resolvedConstraints ?? + (metadata.resolvedConstraints = this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls)); for (const customConstraintMetadata of constraintMetadatas) { if (customConstraintMetadata.async && this.ignoreAsyncValidations) continue; - if (stopAtFirstError && Object.keys(error.constraints || {}).length > 0) continue; + if (this.stopAtFirstError) { + const error = getError(); + if (hasConstraints(error.constraints)) continue; + } if (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map)) { const validatedValue = customConstraintMetadata.instance.validate(value, validationArguments); if (isPromise(validatedValue)) { const promise = validatedValue.then(isValid => { if (!isValid) { + const error = getError(); const [type, message] = this.createValidationError(object, value, metadata, customConstraintMetadata); error.constraints[type] = message; if (metadata.context) { @@ -325,6 +445,7 @@ export class ValidationExecutor { this.awaitingPromises.push(promise); } else { if (!validatedValue) { + const error = getError(); const [type, message] = this.createValidationError(object, value, metadata, customConstraintMetadata); error.constraints[type] = message; } @@ -352,6 +473,7 @@ export class ValidationExecutor { (flatValidatedValues: boolean[]) => { const validationResult = flatValidatedValues.every((isValid: boolean) => isValid); if (!validationResult) { + const error = getError(); const [type, message] = this.createValidationError(object, value, metadata, customConstraintMetadata); error.constraints[type] = message; if (metadata.context) { @@ -371,6 +493,7 @@ export class ValidationExecutor { const validationResult = validatedSubValues.every((isValid: boolean) => isValid); if (!validationResult) { + const error = getError(); const [type, message] = this.createValidationError(object, value, metadata, customConstraintMetadata); error.constraints[type] = message; } @@ -387,8 +510,7 @@ export class ValidationExecutor { if (metadata.type !== ValidationTypes.NESTED_VALIDATION && metadata.type !== ValidationTypes.PROMISE_VALIDATION) { continue; } else if ( - this.validatorOptions && - this.validatorOptions.stopAtFirstError && + this.stopAtFirstError && Object.keys(error.constraints || {}).length > 0 ) { continue; @@ -397,8 +519,17 @@ export class ValidationExecutor { 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 arrayPartition: PartitionedPropertyMetadata = { + defined: [], + custom: [], + nested: metadatas, + conditional: [], + all: metadatas, + hasPromiseValidation: false, + customOnly: false, + }; arrayLikeValue.forEach((subValue: any, index: any) => { - this.performValidations(value, subValue, index.toString(), [], metadatas, error.children); + this.performValidations(value, subValue, index.toString(), arrayPartition, error.children); }); } else if (value instanceof Object) { const targetSchema = typeof metadata.target === 'string' ? metadata.target : metadata.target.name; @@ -413,14 +544,20 @@ export class ValidationExecutor { private mapContexts(object: object, value: any, metadatas: ValidationMetadata[], error: ValidationError): void { for (const metadata of metadatas) { if (metadata.context) { - let customConstraint; + let type: string; if (metadata.type === ValidationTypes.CUSTOM_VALIDATION) { - const customConstraints = this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls); - customConstraint = customConstraints[0]; + if (metadata.inlineValidate) { + // Inline validators: use metadata.name directly + type = metadata.name || metadata.type; + } else { + const customConstraints = metadata.resolvedConstraints ?? + (metadata.resolvedConstraints = this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls)); + type = (customConstraints[0] && customConstraints[0].name) ? customConstraints[0].name : metadata.type; + } + } else { + type = metadata.type; } - const type = this.getConstraintType(metadata, customConstraint); - if (error.constraints[type]) { if (!error.contexts) { error.contexts = {}; @@ -438,10 +575,9 @@ export class ValidationExecutor { metadata: ValidationMetadata, customValidatorMetadata?: ConstraintMetadata ): [string, string] { - const targetName = object.constructor ? (object.constructor as any).name : undefined; const type = this.getConstraintType(metadata, customValidatorMetadata); const validationArguments: ValidationArguments = { - targetName: targetName, + targetName: this.currentTargetName, property: metadata.propertyName, object: object, value: value, @@ -449,10 +585,7 @@ export class ValidationExecutor { }; let message = metadata.message || ''; - if ( - !metadata.message && - (!this.validatorOptions || (this.validatorOptions && !this.validatorOptions.dismissDefaultMessages)) - ) { + if (!metadata.message && !this.dismissDefaultMessages) { if (customValidatorMetadata && customValidatorMetadata.instance.defaultMessage instanceof Function) { message = customValidatorMetadata.instance.defaultMessage(validationArguments); } @@ -462,6 +595,22 @@ export class ValidationExecutor { return [type, messageString]; } + private createValidationErrorInline( + metadata: ValidationMetadata, + validationArguments: ValidationArguments + ): [string, string] { + const type = metadata.name || metadata.type; + let message = metadata.message || ''; + if (!metadata.message && !this.dismissDefaultMessages) { + if (metadata.inlineDefaultMessage) { + message = metadata.inlineDefaultMessage(validationArguments); + } + } + + const messageString = ValidationUtils.replaceMessageSpecialTokens(message, validationArguments); + return [type, messageString]; + } + private getConstraintType(metadata: ValidationMetadata, customValidatorMetadata?: ConstraintMetadata): string { const type = customValidatorMetadata && customValidatorMetadata.name ? customValidatorMetadata.name : metadata.type; return type; diff --git a/src/validation/Validator.ts b/src/validation/Validator.ts index 77e46ef2b0..085be6602c 100644 --- a/src/validation/Validator.ts +++ b/src/validation/Validator.ts @@ -82,7 +82,7 @@ export class Validator { executor.ignoreAsyncValidations = true; const validationErrors: ValidationError[] = []; executor.execute(object, schema, validationErrors); - return executor.stripEmptyErrors(validationErrors); + return validationErrors.length === 0 ? validationErrors : executor.stripEmptyErrors(validationErrors); } // ------------------------------------------------------------------------- @@ -106,8 +106,14 @@ export class Validator { const validationErrors: ValidationError[] = []; executor.execute(object, schema, validationErrors); + if (executor.awaitingPromises.length === 0) { + return Promise.resolve( + validationErrors.length === 0 ? validationErrors : executor.stripEmptyErrors(validationErrors) + ); + } + return Promise.all(executor.awaitingPromises).then(() => { - return executor.stripEmptyErrors(validationErrors); + return validationErrors.length === 0 ? validationErrors : executor.stripEmptyErrors(validationErrors); }); } } From cc7a3db05745507c8fe346a642da13928e410c1e Mon Sep 17 00:00:00 2001 From: Matthew Bell <33056264+matthew2564@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:31:31 +0000 Subject: [PATCH 3/5] perf: fix issues detected in pipeline --- package.json | 1 + src/decorator/object/IsNotEmptyObject.ts | 4 +- src/metadata/MetadataStorage.ts | 11 +- src/metadata/ValidationMetadata.ts | 1 - src/validation/ValidationExecutor.ts | 54 +++- benchmark.ts => test/benchmark.ts | 18 +- test/functional/custom-decorators.spec.ts | 58 ++++ test/functional/metadata-storage.spec.ts | 97 ++++++ .../validation-executor-pr-2665.spec.ts | 282 ++++++++++++++++++ ...alidation-functions-and-decorators.spec.ts | 5 + 10 files changed, 503 insertions(+), 28 deletions(-) rename benchmark.ts => test/benchmark.ts (93%) create mode 100644 test/functional/metadata-storage.spec.ts create mode 100644 test/functional/validation-executor-pr-2665.spec.ts diff --git a/package.json b/package.json index f98f3251f4..d3c8586c1a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "typescript" ], "scripts": { + "benchmark": "ts-node --transpile-only test/benchmark.ts", "build": "npm run build:cjs", "build:clean": "rimraf build", "build:es2015": "tsc --project tsconfig.prod.esm2015.json", diff --git a/src/decorator/object/IsNotEmptyObject.ts b/src/decorator/object/IsNotEmptyObject.ts index 58602033a7..3a6d8779d0 100644 --- a/src/decorator/object/IsNotEmptyObject.ts +++ b/src/decorator/object/IsNotEmptyObject.ts @@ -14,8 +14,8 @@ export function isNotEmptyObject(value: unknown, options?: { nullable?: boolean } if (options?.nullable === false) { - for (const key in value as object) { - if ((value as object).hasOwnProperty(key)) { + for (const key in value ) { + if ((value ).hasOwnProperty(key)) { const propertyValue = (value as any)[key]; if (propertyValue !== null && propertyValue !== undefined) { return true; diff --git a/src/metadata/MetadataStorage.ts b/src/metadata/MetadataStorage.ts index d77b5438f4..b49ea7a735 100644 --- a/src/metadata/MetadataStorage.ts +++ b/src/metadata/MetadataStorage.ts @@ -148,7 +148,8 @@ export class MetadataStorage { } } - const customOnly = defined.length === 0 && nested.length === 0 && conditional.length === 0 && !hasPromiseValidation; + const customOnly = + defined.length === 0 && nested.length === 0 && conditional.length === 0 && !hasPromiseValidation; result[propertyName] = { defined, custom, nested, conditional, all, hasPromiseValidation, customOnly }; } @@ -159,13 +160,7 @@ export class MetadataStorage { /** * Gets all validation metadatas for the given object with the given groups. */ - buildCacheKey( - target: Function, - schema: string, - always: boolean, - strictGroups: boolean, - groups?: string[] - ): string { + buildCacheKey(target: Function, schema: string, always: boolean, strictGroups: boolean, groups?: string[]): string { const targetId = (target as any).__cv_id ?? ((target as any).__cv_id = ++MetadataStorage._nextId); const groupKey = groups?.length ? groups.slice().sort().join(',') : ''; return `${targetId}|${schema || ''}|${always ? 1 : 0}|${strictGroups ? 1 : 0}|${groupKey}`; diff --git a/src/metadata/ValidationMetadata.ts b/src/metadata/ValidationMetadata.ts index 8e0628ad2a..ec1b87967d 100644 --- a/src/metadata/ValidationMetadata.ts +++ b/src/metadata/ValidationMetadata.ts @@ -90,7 +90,6 @@ export class ValidationMetadata { */ inlineDefaultMessage?: (args?: any) => string; - // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index f38b20a0f3..4c659b8e58 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -71,7 +71,9 @@ export class ValidationExecutor { this.strictGroups = (validatorOptions && validatorOptions.strictGroups) || false; this.always = (validatorOptions && validatorOptions.always) || false; this.forbidUnknownValues = - !validatorOptions || validatorOptions.forbidUnknownValues === undefined || validatorOptions.forbidUnknownValues !== false; + !validatorOptions || + validatorOptions.forbidUnknownValues === undefined || + validatorOptions.forbidUnknownValues !== false; this.whitelist = (validatorOptions && validatorOptions.whitelist) || false; this.forbidNonWhitelisted = (validatorOptions && validatorOptions.forbidNonWhitelisted) || false; } @@ -95,7 +97,13 @@ export class ValidationExecutor { ); } - const cacheKey = this.metadataStorage.buildCacheKey(object.constructor, targetSchema, this.always, this.strictGroups, this.groups); + const cacheKey = this.metadataStorage.buildCacheKey( + object.constructor, + targetSchema, + this.always, + this.strictGroups, + this.groups + ); const targetMetadatas = this.metadataStorage.getTargetValidationMetadatas( object.constructor, targetSchema, @@ -216,19 +224,25 @@ export class ValidationExecutor { for (const metadata of metadatas) { validationArguments.constraints = metadata.constraints; - if (metadata.inlineValidate && (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map))) { + if ( + metadata.inlineValidate && + (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map)) + ) { if (this.stopAtFirstError && validationError && hasConstraints(validationError.constraints)) continue; const validatedValue = metadata.inlineValidate(value, validationArguments); if (validatedValue !== true && validatedValue !== false) { - const promise = (validatedValue as Promise).then(isValid => { + const promise = (validatedValue ).then(isValid => { if (!isValid) { if (!validationError) validationError = this.generateValidationError(object, value, propertyName); const [type, message] = this.createValidationErrorInline(metadata, validationArguments); validationError.constraints[type] = message; if (metadata.context) { if (!validationError.contexts) validationError.contexts = {}; - validationError.contexts[type] = Object.assign(validationError.contexts[type] || {}, metadata.context); + validationError.contexts[type] = Object.assign( + validationError.contexts[type] || {}, + metadata.context + ); } } }); @@ -255,7 +269,11 @@ export class ValidationExecutor { if (validationError) { const hasAsyncPending = this.awaitingPromises.length > asyncCountBefore; - if (hasConstraints(validationError.constraints) || (validationError.children && validationError.children.length > 0) || hasAsyncPending) { + if ( + hasConstraints(validationError.constraints) || + (validationError.children && validationError.children.length > 0) || + hasAsyncPending + ) { validationErrors.push(validationError); } } else if (this.awaitingPromises.length > asyncCountBefore) { @@ -380,7 +398,10 @@ export class ValidationExecutor { if (metadata.validateIf && !metadata.validateIf(object, value)) continue; // Fast path: inline validators (all built-in decorators) bypass constraint metadata dispatch - if (metadata.inlineValidate && (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map))) { + if ( + metadata.inlineValidate && + (!metadata.each || !(Array.isArray(value) || value instanceof Set || value instanceof Map)) + ) { if (this.stopAtFirstError) { const error = getError(); if (hasConstraints(error.constraints)) continue; @@ -389,7 +410,7 @@ export class ValidationExecutor { const validatedValue = metadata.inlineValidate(value, validationArguments); if (validatedValue !== true && validatedValue !== false) { // Async result (Promise) - const promise = (validatedValue as Promise).then(isValid => { + const promise = (validatedValue ).then(isValid => { if (!isValid) { const error = getError(); const [type, message] = this.createValidationErrorInline(metadata, validationArguments); @@ -417,7 +438,8 @@ export class ValidationExecutor { continue; } - const constraintMetadatas = metadata.resolvedConstraints ?? + const constraintMetadatas = + metadata.resolvedConstraints ?? (metadata.resolvedConstraints = this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls)); for (const customConstraintMetadata of constraintMetadatas) { if (customConstraintMetadata.async && this.ignoreAsyncValidations) continue; @@ -509,10 +531,7 @@ export class ValidationExecutor { for (const metadata of metadatas) { if (metadata.type !== ValidationTypes.NESTED_VALIDATION && metadata.type !== ValidationTypes.PROMISE_VALIDATION) { continue; - } else if ( - this.stopAtFirstError && - Object.keys(error.constraints || {}).length > 0 - ) { + } else if (this.stopAtFirstError && Object.keys(error.constraints || {}).length > 0) { continue; } @@ -550,9 +569,12 @@ export class ValidationExecutor { // Inline validators: use metadata.name directly type = metadata.name || metadata.type; } else { - const customConstraints = metadata.resolvedConstraints ?? - (metadata.resolvedConstraints = this.metadataStorage.getTargetValidatorConstraints(metadata.constraintCls)); - type = (customConstraints[0] && customConstraints[0].name) ? customConstraints[0].name : metadata.type; + const customConstraints = + metadata.resolvedConstraints ?? + (metadata.resolvedConstraints = this.metadataStorage.getTargetValidatorConstraints( + metadata.constraintCls + )); + type = customConstraints[0] && customConstraints[0].name ? customConstraints[0].name : metadata.type; } } else { type = metadata.type; diff --git a/benchmark.ts b/test/benchmark.ts similarity index 93% rename from benchmark.ts rename to test/benchmark.ts index e920ce7856..61a164ae5c 100644 --- a/benchmark.ts +++ b/test/benchmark.ts @@ -1,5 +1,21 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck import 'reflect-metadata'; -import { validate, validateSync, IsString, IsInt, IsBoolean, IsEmail, IsOptional, MinLength, MaxLength, Min, Max, IsNotEmpty, ValidateNested } from './src'; +import { + validate, + validateSync, + IsString, + IsInt, + IsBoolean, + IsEmail, + IsOptional, + MinLength, + MaxLength, + Min, + Max, + IsNotEmpty, + ValidateNested, +} from '../src'; // --- Classes with inheritance and nesting --- class BaseEntity { diff --git a/test/functional/custom-decorators.spec.ts b/test/functional/custom-decorators.spec.ts index 39204addb8..c768f9faa0 100644 --- a/test/functional/custom-decorators.spec.ts +++ b/test/functional/custom-decorators.spec.ts @@ -274,3 +274,61 @@ describe('decorator with symbol constraint', () => { }); }); }); + +describe('inline custom decorator fast-path behavior', () => { + function InlineFailing(name: string, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string): void { + registerDecorator({ + target: object.constructor, + propertyName, + name, + options: validationOptions, + validator: { + validate(): boolean { + return false; + }, + }, + }); + }; + } + + function InlineAsyncPassing(name: string, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string): void { + registerDecorator({ + target: object.constructor, + propertyName, + name, + options: validationOptions, + validator: { + validate(): Promise { + return Promise.resolve(true); + }, + }, + }); + }; + } + + it('should stop after first inline custom validator failure when stopAtFirstError is enabled', () => { + class StopAtFirstErrorModel { + @InlineFailing('firstError', { message: 'first message' }) + @InlineFailing('secondError', { message: 'second message' }) + value: string = 'x'; + } + + return validator.validate(new StopAtFirstErrorModel(), { stopAtFirstError: true }).then(errors => { + expect(errors.length).toEqual(1); + expect(Object.keys(errors[0].constraints).length).toEqual(1); + }); + }); + + it('should not create a visible error when inline async validator resolves true', () => { + class AsyncPassModel { + @InlineAsyncPassing('asyncPass') + value: string = 'x'; + } + + return validator.validate(new AsyncPassModel()).then(errors => { + expect(errors.length).toEqual(0); + }); + }); +}); diff --git a/test/functional/metadata-storage.spec.ts b/test/functional/metadata-storage.spec.ts new file mode 100644 index 0000000000..231c82ffe0 --- /dev/null +++ b/test/functional/metadata-storage.spec.ts @@ -0,0 +1,97 @@ +import { MetadataStorage } from '../../src/metadata/MetadataStorage'; +import { ValidationMetadata } from '../../src/metadata/ValidationMetadata'; +import { ValidationTypes } from '../../src/validation/ValidationTypes'; + +describe('MetadataStorage PR-2665 coverage', () => { + class TestTarget {} + + function createMetadata(type: string, propertyName: string): ValidationMetadata { + return new ValidationMetadata({ + type, + target: TestTarget, + propertyName, + }); + } + + it('should build cache keys independent from group order', () => { + const storage = new MetadataStorage(); + + const first = storage.buildCacheKey(TestTarget, '', false, false, ['beta', 'alpha']); + const second = storage.buildCacheKey(TestTarget, '', false, false, ['alpha', 'beta']); + const strict = storage.buildCacheKey(TestTarget, '', false, true, ['alpha', 'beta']); + + expect(first).toEqual(second); + expect(first).not.toEqual(strict); + }); + + it('should partition metadata and compute customOnly accurately', () => { + const storage = new MetadataStorage(); + const cacheKey = storage.buildCacheKey(TestTarget, '', false, false, undefined); + const grouped = { + onlyCustom: [createMetadata(ValidationTypes.CUSTOM_VALIDATION, 'onlyCustom')], + withPromise: [ + createMetadata(ValidationTypes.CUSTOM_VALIDATION, 'withPromise'), + createMetadata(ValidationTypes.PROMISE_VALIDATION, 'withPromise'), + ], + withNestedAndConditional: [ + createMetadata(ValidationTypes.CUSTOM_VALIDATION, 'withNestedAndConditional'), + createMetadata(ValidationTypes.NESTED_VALIDATION, 'withNestedAndConditional'), + createMetadata(ValidationTypes.CONDITIONAL_VALIDATION, 'withNestedAndConditional'), + ], + withDefined: [ + createMetadata(ValidationTypes.IS_DEFINED, 'withDefined'), + createMetadata(ValidationTypes.CUSTOM_VALIDATION, 'withDefined'), + ], + withWhitelist: [ + createMetadata(ValidationTypes.WHITELIST, 'withWhitelist'), + createMetadata(ValidationTypes.CUSTOM_VALIDATION, 'withWhitelist'), + ], + }; + + const partitioned = storage.getPartitionedMetadata(grouped, cacheKey); + const cached = storage.getPartitionedMetadata(grouped, cacheKey); + + expect(cached).toBe(partitioned); + expect(partitioned.onlyCustom.customOnly).toEqual(true); + expect(partitioned.withPromise.customOnly).toEqual(false); + expect(partitioned.withPromise.hasPromiseValidation).toEqual(true); + expect(partitioned.withNestedAndConditional.customOnly).toEqual(false); + expect(partitioned.withDefined.customOnly).toEqual(false); + expect(partitioned.withDefined.defined.length).toEqual(1); + expect(partitioned.withWhitelist.all.length).toEqual(1); + }); + + it('should apply always/strictGroups metadata filtering consistently', () => { + class GroupedTarget {} + const storage = new MetadataStorage(); + const plain = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: GroupedTarget, + propertyName: 'plain', + }); + const grouped = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: GroupedTarget, + propertyName: 'grouped', + }); + grouped.groups = ['g1']; + const explicitAlways = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: GroupedTarget, + propertyName: 'always', + }); + explicitAlways.groups = ['g2']; + explicitAlways.always = true; + + storage.addValidationMetadata(plain); + storage.addValidationMetadata(grouped); + storage.addValidationMetadata(explicitAlways); + + const strictNoGroups = storage.getTargetValidationMetadatas(GroupedTarget, '', false, true); + const strictProperties = strictNoGroups.map(metadata => metadata.propertyName); + + expect(strictProperties).toContain('plain'); + expect(strictProperties).toContain('always'); + expect(strictProperties).not.toContain('grouped'); + }); +}); diff --git a/test/functional/validation-executor-pr-2665.spec.ts b/test/functional/validation-executor-pr-2665.spec.ts new file mode 100644 index 0000000000..9d64d0dd8e --- /dev/null +++ b/test/functional/validation-executor-pr-2665.spec.ts @@ -0,0 +1,282 @@ +import { ValidationMetadata } from '../../src/metadata/ValidationMetadata'; +import { ValidationExecutor } from '../../src/validation/ValidationExecutor'; +import { ValidationTypes } from '../../src/validation/ValidationTypes'; +import { Validator } from '../../src/validation/Validator'; +import { ValidationError } from '../../src/validation/ValidationError'; + +describe('ValidationExecutor PR-2665 coverage', () => { + class TestTarget { + prop: any; + } + + function inlineMetadata(name: string, validateFn: (value: any) => boolean | Promise): ValidationMetadata { + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + name, + }); + metadata.inlineValidate = validateFn; + metadata.constraints = []; + metadata.message = `${name} failed`; + return metadata; + } + + it('should validate custom and nested metadata when partition is not customOnly', () => { + const executor = new ValidationExecutor(new Validator()); + const custom = inlineMetadata('inlineFalse', () => false); + const nested = new ValidationMetadata({ + type: ValidationTypes.NESTED_VALIDATION, + target: TestTarget, + propertyName: 'prop', + }); + const object = { prop: 1 }; + const errors: ValidationError[] = []; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).performValidations( + object, + object.prop, + 'prop', + { + defined: [], + custom: [custom], + nested: [nested], + conditional: [], + all: [custom, nested], + hasPromiseValidation: false, + customOnly: false, + }, + errors + ); + + expect(errors.length).toEqual(1); + expect(errors[0].constraints).toHaveProperty('inlineFalse'); + expect(errors[0].constraints).toHaveProperty(ValidationTypes.NESTED_VALIDATION); + }); + + it('should short-circuit inline validators when stopAtFirstError is enabled in fallback custom path', () => { + const executor = new ValidationExecutor(new Validator(), { stopAtFirstError: true }); + const first = inlineMetadata('firstInline', () => false); + const second = inlineMetadata('secondInline', () => false); + const object = { prop: 'x' }; + let error: ValidationError | undefined; + const getError = (): ValidationError => { + if (!error) { + error = (executor as any).generateValidationError(object, object.prop, 'prop'); + } + return error; + }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).customValidations(object, object.prop, [first, second], getError); + + expect(error).toBeDefined(); + expect(Object.keys(error!.constraints)).toEqual(['firstInline']); + }); + + it('should handle async and sync custom constraint failures in fallback custom path', () => { + const metadataAsync = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class AsyncConstraint {}, + }); + metadataAsync.constraints = []; + + const metadataSync = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class SyncConstraint {}, + }); + metadataSync.constraints = []; + + const asyncExecutor = new ValidationExecutor(new Validator()); + (asyncExecutor as any).metadataStorage = { + getTargetValidatorConstraints: () => [ + { + name: 'asyncConstraint', + async: true, + instance: { + validate: () => Promise.resolve(false), + defaultMessage: () => 'async failed', + }, + }, + ], + }; + + const asyncObject = { prop: 'x' }; + let asyncError: ValidationError | undefined; + const getAsyncError = (): ValidationError => { + if (!asyncError) { + asyncError = (asyncExecutor as any).generateValidationError(asyncObject, asyncObject.prop, 'prop'); + } + return asyncError; + }; + (asyncExecutor as any).currentTargetName = 'TestTarget'; + (asyncExecutor as any).customValidations(asyncObject, asyncObject.prop, [metadataAsync], getAsyncError); + + const syncExecutor = new ValidationExecutor(new Validator()); + (syncExecutor as any).metadataStorage = { + getTargetValidatorConstraints: () => [ + { + name: 'syncConstraint', + async: false, + instance: { + validate: () => false, + defaultMessage: () => 'sync failed', + }, + }, + ], + }; + + const syncObject = { prop: 'x' }; + let syncError: ValidationError | undefined; + const getSyncError = (): ValidationError => { + if (!syncError) { + syncError = (syncExecutor as any).generateValidationError(syncObject, syncObject.prop, 'prop'); + } + return syncError; + }; + (syncExecutor as any).currentTargetName = 'TestTarget'; + (syncExecutor as any).customValidations(syncObject, syncObject.prop, [metadataSync], getSyncError); + + return Promise.all((asyncExecutor as any).awaitingPromises).then(() => { + expect(asyncError).toBeDefined(); + expect(asyncError!.constraints).toHaveProperty('asyncConstraint'); + expect(syncError).toBeDefined(); + expect(syncError!.constraints).toHaveProperty('syncConstraint'); + }); + }); + + it('should eagerly create and push an error when async validation is pending without immediate failures', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class AsyncPassConstraint {}, + }); + metadata.constraints = []; + (executor as any).metadataStorage = { + getTargetValidatorConstraints: () => [ + { + name: 'asyncPassConstraint', + async: true, + instance: { + validate: () => Promise.resolve(true), + defaultMessage: () => 'unused', + }, + }, + ], + }; + + const object = { prop: 'x' }; + const errors: ValidationError[] = []; + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).performValidations( + object, + object.prop, + 'prop', + { + defined: [], + custom: [metadata], + nested: [], + conditional: [], + all: [metadata], + hasPromiseValidation: false, + customOnly: false, + }, + errors + ); + + expect(errors.length).toEqual(1); + expect(errors[0].constraints).toEqual({}); + + return Promise.all((executor as any).awaitingPromises).then(() => { + expect(errors[0].constraints).toEqual({}); + }); + }); + + it('should apply each custom validation by converting Set values to array', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class EachConstraint {}, + }); + metadata.constraints = []; + metadata.each = true; + (executor as any).metadataStorage = { + getTargetValidatorConstraints: () => [ + { + name: 'eachConstraint', + async: false, + instance: { + validate: (value: string) => value === 'ok', + defaultMessage: () => 'each failed', + }, + }, + ], + }; + + const object = { prop: new Set(['ok', 'bad']) }; + let error: ValidationError | undefined; + const getError = (): ValidationError => { + if (!error) { + error = (executor as any).generateValidationError(object, object.prop, 'prop'); + } + return error; + }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).customValidations(object, object.prop, [metadata], getError); + + expect(error).toBeDefined(); + expect(error!.constraints).toHaveProperty('eachConstraint'); + }); + + it('should map context for async custom constraint failures', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class AsyncContextConstraint {}, + }); + metadata.constraints = []; + metadata.context = { key: 'value' }; + (executor as any).metadataStorage = { + getTargetValidatorConstraints: () => [ + { + name: 'asyncContextConstraint', + async: true, + instance: { + validate: () => Promise.resolve(false), + defaultMessage: () => 'async failed', + }, + }, + ], + }; + + const object = { prop: 'x' }; + let error: ValidationError | undefined; + const getError = (): ValidationError => { + if (!error) { + error = (executor as any).generateValidationError(object, object.prop, 'prop'); + } + return error; + }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).customValidations(object, object.prop, [metadata], getError); + + return Promise.all((executor as any).awaitingPromises).then(() => { + expect(error).toBeDefined(); + expect(error!.constraints).toHaveProperty('asyncContextConstraint'); + expect(error!.contexts).toEqual({ asyncContextConstraint: { key: 'value' } }); + }); + }); +}); diff --git a/test/functional/validation-functions-and-decorators.spec.ts b/test/functional/validation-functions-and-decorators.spec.ts index 4c266f02ee..08ceeedb24 100644 --- a/test/functional/validation-functions-and-decorators.spec.ts +++ b/test/functional/validation-functions-and-decorators.spec.ts @@ -5069,6 +5069,11 @@ describe('ArrayUnique with identifier', () => { invalidValues.forEach(value => expect(arrayUnique(value, identifier)).toBeFalsy()); }); + it('should handle null and undefined entries when identifier is provided', () => { + expect(arrayUnique([{ name: 'world' }, null, { name: 'hello' }] as any, identifier)).toBeTruthy(); + expect(arrayUnique([{ name: 'world' }, null, undefined, null] as any, identifier)).toBeFalsy(); + }); + it('should return error object with proper data', () => { const validationType = 'arrayUnique'; const message = "All someProperty's elements must be unique"; From f4555aa646b786c7f4977520a771d487a60c55e9 Mon Sep 17 00:00:00 2001 From: Matthew Bell <33056264+matthew2564@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:21:16 +0000 Subject: [PATCH 4/5] perf: prettier formatting --- src/decorator/object/IsNotEmptyObject.ts | 4 ++-- src/validation/ValidationExecutor.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/decorator/object/IsNotEmptyObject.ts b/src/decorator/object/IsNotEmptyObject.ts index 3a6d8779d0..5aa2f6f266 100644 --- a/src/decorator/object/IsNotEmptyObject.ts +++ b/src/decorator/object/IsNotEmptyObject.ts @@ -14,8 +14,8 @@ export function isNotEmptyObject(value: unknown, options?: { nullable?: boolean } if (options?.nullable === false) { - for (const key in value ) { - if ((value ).hasOwnProperty(key)) { + for (const key in value) { + if (value.hasOwnProperty(key)) { const propertyValue = (value as any)[key]; if (propertyValue !== null && propertyValue !== undefined) { return true; diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index 4c659b8e58..1f245b7c66 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -232,7 +232,7 @@ export class ValidationExecutor { const validatedValue = metadata.inlineValidate(value, validationArguments); if (validatedValue !== true && validatedValue !== false) { - const promise = (validatedValue ).then(isValid => { + const promise = validatedValue.then(isValid => { if (!isValid) { if (!validationError) validationError = this.generateValidationError(object, value, propertyName); const [type, message] = this.createValidationErrorInline(metadata, validationArguments); @@ -410,7 +410,7 @@ export class ValidationExecutor { const validatedValue = metadata.inlineValidate(value, validationArguments); if (validatedValue !== true && validatedValue !== false) { // Async result (Promise) - const promise = (validatedValue ).then(isValid => { + const promise = validatedValue.then(isValid => { if (!isValid) { const error = getError(); const [type, message] = this.createValidationErrorInline(metadata, validationArguments); From 7ee878a89c147edb5e1675cde81876f98dbbe6cd Mon Sep 17 00:00:00 2001 From: Matthew Bell <33056264+matthew2564@users.noreply.github.com> Date: Sat, 28 Feb 2026 13:04:12 +0000 Subject: [PATCH 5/5] perf: add more test coverage --- src/validation/ValidationExecutor.ts | 2 +- test/container.spec.ts | 65 +++ test/functional/custom-decorators.spec.ts | 68 +++ test/functional/metadata-storage.spec.ts | 173 ++++++ .../validation-executor-pr-2665.spec.ts | 541 +++++++++++++++++- 5 files changed, 842 insertions(+), 7 deletions(-) create mode 100644 test/container.spec.ts diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index 1f245b7c66..310e0f1a29 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -185,7 +185,7 @@ export class ValidationExecutor { } if (!hasConstraints(error.constraints)) { - if (error.children.length === 0) { + if (error.children?.length === 0) { return false; } else { delete error.constraints; diff --git a/test/container.spec.ts b/test/container.spec.ts new file mode 100644 index 0000000000..5a003cb12d --- /dev/null +++ b/test/container.spec.ts @@ -0,0 +1,65 @@ +import { getFromContainer, useContainer } from '../src/container'; + +describe('container', () => { + afterEach(() => { + useContainer( + { + get() { + return undefined; + }, + }, + { fallback: true } + ); + }); + + it('should reuse the same default instance when fallback is enabled', () => { + class Service {} + + useContainer( + { + get() { + return undefined; + }, + }, + { fallback: true } + ); + + const first = getFromContainer(Service); + const second = getFromContainer(Service); + + expect(first).toBeInstanceOf(Service); + expect(first).toBe(second); + }); + + it('should fall back to the default container when fallbackOnErrors is enabled', () => { + class ErrorService {} + + useContainer( + { + get() { + throw new Error('container failure'); + }, + }, + { fallbackOnErrors: true } + ); + + const first = getFromContainer(ErrorService); + const second = getFromContainer(ErrorService); + + expect(first).toBeInstanceOf(ErrorService); + expect(first).toBe(second); + }); + + it('should return the user container instance when one is provided', () => { + class ExternalService {} + const provided = { fromUserContainer: true }; + + useContainer({ + get() { + return provided; + }, + }); + + expect(getFromContainer(ExternalService)).toBe(provided); + }); +}); diff --git a/test/functional/custom-decorators.spec.ts b/test/functional/custom-decorators.spec.ts index c768f9faa0..9a7c0ca921 100644 --- a/test/functional/custom-decorators.spec.ts +++ b/test/functional/custom-decorators.spec.ts @@ -4,6 +4,9 @@ import { registerDecorator } from '../../src/register-decorator'; import { ValidationOptions } from '../../src/decorator/ValidationOptions'; import { buildMessage, ValidatorConstraint } from '../../src/decorator/decorators'; import { ValidatorConstraintInterface } from '../../src/validation/ValidatorConstraintInterface'; +import { useContainer } from '../../src/container'; +import { getMetadataStorage, MetadataStorage } from '../../src/metadata/MetadataStorage'; +import { ConstraintMetadata } from '../../src/metadata/ConstraintMetadata'; const validator = new Validator(); @@ -276,6 +279,17 @@ describe('decorator with symbol constraint', () => { }); describe('inline custom decorator fast-path behavior', () => { + afterEach(() => { + useContainer( + { + get() { + return undefined; + }, + }, + { fallback: true } + ); + }); + function InlineFailing(name: string, validationOptions?: ValidationOptions) { return function (object: object, propertyName: string): void { registerDecorator({ @@ -331,4 +345,58 @@ describe('inline custom decorator fast-path behavior', () => { expect(errors.length).toEqual(0); }); }); + + it('should use an empty default message when inline validator does not provide one', () => { + class NoDefaultMessageModel {} + + registerDecorator({ + target: NoDefaultMessageModel, + propertyName: 'value', + name: 'noDefaultMessageValidator', + validator: { + validate(): boolean { + return false; + }, + }, + }); + + const metadata = getMetadataStorage() + .getTargetValidationMetadatas(NoDefaultMessageModel, '', false, false) + .find(entry => entry.propertyName === 'value'); + const constraint = getMetadataStorage().getTargetValidatorConstraints(metadata!.constraintCls)[0]; + + expect(constraint.instance.defaultMessage()).toEqual(''); + expect(metadata!.inlineDefaultMessage).toBeUndefined(); + }); + + it('should throw when multiple constraint implementations are returned for a validator class', () => { + @ValidatorConstraint() + class DuplicateConstraint implements ValidatorConstraintInterface { + validate(): boolean { + return true; + } + } + + const metadataStorage = new MetadataStorage(); + metadataStorage.addConstraintMetadata(new ConstraintMetadata(DuplicateConstraint)); + metadataStorage.addConstraintMetadata(new ConstraintMetadata(DuplicateConstraint)); + + useContainer({ + get(target: Function) { + if (target === MetadataStorage) { + return metadataStorage; + } + + return new (target as any)(); + }, + }); + + expect(() => + registerDecorator({ + target: DuplicateConstraint, + propertyName: 'value', + validator: DuplicateConstraint, + }) + ).toThrow('More than one implementation of ValidatorConstraintInterface found'); + }); }); diff --git a/test/functional/metadata-storage.spec.ts b/test/functional/metadata-storage.spec.ts index 231c82ffe0..e511a9694a 100644 --- a/test/functional/metadata-storage.spec.ts +++ b/test/functional/metadata-storage.spec.ts @@ -1,5 +1,7 @@ import { MetadataStorage } from '../../src/metadata/MetadataStorage'; +import { ConstraintMetadata } from '../../src/metadata/ConstraintMetadata'; import { ValidationMetadata } from '../../src/metadata/ValidationMetadata'; +import { ValidationSchema } from '../../src/validation-schema/ValidationSchema'; import { ValidationTypes } from '../../src/validation/ValidationTypes'; describe('MetadataStorage PR-2665 coverage', () => { @@ -94,4 +96,175 @@ describe('MetadataStorage PR-2665 coverage', () => { expect(strictProperties).toContain('always'); expect(strictProperties).not.toContain('grouped'); }); + + it('should cache grouped metadata and reuse the same object for a cache key', () => { + const storage = new MetadataStorage(); + const metadata = [createMetadata(ValidationTypes.CUSTOM_VALIDATION, 'prop')]; + + const first = storage.groupByPropertyName(metadata, 'group-cache-key'); + const second = storage.groupByPropertyName([], 'group-cache-key'); + + expect(second).toBe(first); + expect(second.prop).toHaveLength(1); + }); + + it('should append constraint metadata for the same target', () => { + class TestConstraint {} + + const storage = new MetadataStorage(); + storage.addConstraintMetadata(new ConstraintMetadata(TestConstraint, 'first')); + storage.addConstraintMetadata(new ConstraintMetadata(TestConstraint, 'second')); + + expect(storage.getTargetValidatorConstraints(TestConstraint)).toHaveLength(2); + }); + + it('should cache target metadata results and include inherited grouped metadata', () => { + class ParentTarget {} + class ChildTarget extends ParentTarget {} + + const storage = new MetadataStorage(); + const inheritedGrouped = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: ParentTarget, + propertyName: 'inheritedGrouped', + }); + inheritedGrouped.groups = ['g1']; + + storage.addValidationMetadata(inheritedGrouped); + + const cacheKey = storage.buildCacheKey(ChildTarget, '', false, false, ['g1']); + const first = storage.getTargetValidationMetadatas(ChildTarget, '', false, false, ['g1'], cacheKey); + const second = storage.getTargetValidationMetadatas(ChildTarget, '', false, false, ['g1'], cacheKey); + + expect(second).toBe(first); + expect(first.map(metadata => metadata.propertyName)).toContain('inheritedGrouped'); + }); + + it('should ignore inherited metadata whose target is not actually in the prototype chain', () => { + class ParentTarget {} + class ChildTarget extends ParentTarget {} + class RogueTarget {} + + const storage = new MetadataStorage(); + const rogueMetadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: RogueTarget, + propertyName: 'rogue', + }); + + (storage as any).validationMetadatas = new Map([[ParentTarget, [rogueMetadata]]]); + + expect(storage.getTargetValidationMetadatas(ChildTarget, '', false, false)).toEqual([]); + }); + + it('should transform validation schemas into metadata entries', () => { + const storage = new MetadataStorage(); + const schema: ValidationSchema = { + name: 'SchemaTarget', + properties: { + field: [ + { + type: ValidationTypes.CUSTOM_VALIDATION, + name: 'schemaConstraint', + constraints: ['x'], + message: 'schema message', + }, + ], + }, + }; + + storage.addValidationSchema(schema); + + const metadatas = (storage as any).validationMetadatas.get('SchemaTarget'); + expect(metadatas).toHaveLength(1); + expect(metadatas[0].name).toEqual('schemaConstraint'); + expect(metadatas[0].propertyName).toEqual('field'); + }); + + it('should ignore original metadata entries whose target does not match the constructor or schema', () => { + class ActualTarget {} + class OtherTarget {} + + const storage = new MetadataStorage(); + const mismatched = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: OtherTarget, + propertyName: 'mismatch', + }); + + (storage as any).validationMetadatas = new Map([[ActualTarget, [mismatched]]]); + + expect(storage.getTargetValidationMetadatas(ActualTarget, '', false, false)).toEqual([]); + }); + + it('should exclude string and self-targeted inherited metadata', () => { + class ParentTarget {} + class ChildTarget extends ParentTarget {} + + const storage = new MetadataStorage(); + const schemaInherited = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: 'SchemaTarget', + propertyName: 'schemaInherited', + }); + const selfInherited = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: ChildTarget, + propertyName: 'selfInherited', + }); + const validInherited = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: ParentTarget, + propertyName: 'validInherited', + }); + validInherited.always = true; + + (storage as any).validationMetadatas = new Map([[ParentTarget, [schemaInherited, selfInherited, validInherited]]]); + + const metadatas = storage.getTargetValidationMetadatas(ChildTarget, '', false, true); + + expect(metadatas.map(metadata => metadata.propertyName)).toEqual(['validInherited']); + }); + + it('should prefer original metadata over inherited metadata with the same property and type', () => { + class ParentTarget {} + class ChildTarget extends ParentTarget {} + + const storage = new MetadataStorage(); + const original = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: ChildTarget, + propertyName: 'shared', + }); + const inheritedDuplicate = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: ParentTarget, + propertyName: 'shared', + }); + + storage.addValidationMetadata(original); + storage.addValidationMetadata(inheritedDuplicate); + + const metadatas = storage.getTargetValidationMetadatas(ChildTarget, '', false, false); + + expect(metadatas).toHaveLength(1); + expect(metadatas[0]).toBe(original); + }); + + it('should exclude inherited grouped metadata when strictGroups is enabled without groups', () => { + class ParentTarget {} + class ChildTarget extends ParentTarget {} + + const storage = new MetadataStorage(); + const inheritedGrouped = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: ParentTarget, + propertyName: 'inheritedGrouped', + }); + inheritedGrouped.groups = ['g1']; + + storage.addValidationMetadata(inheritedGrouped); + + expect(storage.getTargetValidationMetadatas(ChildTarget, '', false, true)).toEqual([]); + }); }); diff --git a/test/functional/validation-executor-pr-2665.spec.ts b/test/functional/validation-executor-pr-2665.spec.ts index 9d64d0dd8e..817ee3adaf 100644 --- a/test/functional/validation-executor-pr-2665.spec.ts +++ b/test/functional/validation-executor-pr-2665.spec.ts @@ -72,7 +72,7 @@ describe('ValidationExecutor PR-2665 coverage', () => { (executor as any).customValidations(object, object.prop, [first, second], getError); expect(error).toBeDefined(); - expect(Object.keys(error!.constraints)).toEqual(['firstInline']); + expect(Object.keys(error.constraints)).toEqual(['firstInline']); }); it('should handle async and sync custom constraint failures in fallback custom path', () => { @@ -144,9 +144,9 @@ describe('ValidationExecutor PR-2665 coverage', () => { return Promise.all((asyncExecutor as any).awaitingPromises).then(() => { expect(asyncError).toBeDefined(); - expect(asyncError!.constraints).toHaveProperty('asyncConstraint'); + expect(asyncError.constraints).toHaveProperty('asyncConstraint'); expect(syncError).toBeDefined(); - expect(syncError!.constraints).toHaveProperty('syncConstraint'); + expect(syncError.constraints).toHaveProperty('syncConstraint'); }); }); @@ -235,7 +235,536 @@ describe('ValidationExecutor PR-2665 coverage', () => { (executor as any).customValidations(object, object.prop, [metadata], getError); expect(error).toBeDefined(); - expect(error!.constraints).toHaveProperty('eachConstraint'); + expect(error.constraints).toHaveProperty('eachConstraint'); + }); + + it('should log a warning when enableDebugMessages is true and no validation metadata exists', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const executor = new ValidationExecutor(new Validator(), { + enableDebugMessages: true, + forbidUnknownValues: false, + }); + (executor as any).metadataStorage = { + hasValidationMetaData: false, + buildCacheKey: () => 'debugKey', + getTargetValidationMetadatas: () => [], + groupByPropertyName: () => ({}), + getPartitionedMetadata: () => ({}), + }; + + executor.execute(new TestTarget(), 'TestTarget', []); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No validation metadata found')); + warnSpy.mockRestore(); + }); + + it('should not include value in generated errors when validationError.value is false', () => { + const executor = new ValidationExecutor(new Validator(), { + validationError: { value: false }, + }); + + const error = (executor as any).generateValidationError({}, 'hidden', 'prop'); + + expect(error.value).toBeUndefined(); + }); + + it('should not log a warning when debug messages are disabled and no metadata exists', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const executor = new ValidationExecutor(new Validator()); + (executor as any).metadataStorage = { + hasValidationMetaData: false, + buildCacheKey: () => 'debugKey', + getTargetValidationMetadatas: () => [ + new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + }), + ], + groupByPropertyName: () => ({}), + getPartitionedMetadata: () => ({}), + }; + + executor.execute(new TestTarget(), 'TestTarget', []); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('should handle objects without a truthy constructor name during execute', () => { + const executor = new ValidationExecutor(new Validator(), { forbidUnknownValues: false }); + const object = { constructor: undefined as any, prop: 'x' }; + const performSpy = jest.spyOn(executor as any, 'performValidations').mockImplementation(() => {}); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + name: 'noCtor', + }); + + (executor as any).metadataStorage = { + hasValidationMetaData: true, + buildCacheKey: () => 'no-ctor', + getTargetValidationMetadatas: () => [metadata], + groupByPropertyName: () => ({ prop: [metadata] }), + getPartitionedMetadata: () => ({ + prop: { + defined: [], + custom: [metadata], + nested: [], + conditional: [], + all: [metadata], + hasPromiseValidation: false, + customOnly: false, + }, + }), + }; + + executor.execute(object as any, 'TestTarget', []); + + expect((executor as any).currentTargetName).toBeUndefined(); + expect(performSpy).toHaveBeenCalled(); + performSpy.mockRestore(); + }); + + it('should keep errors without constraints when children is undefined in stripEmptyErrors', () => { + const executor = new ValidationExecutor(new Validator()); + const errors = [ + { + target: {}, + property: 'prop', + value: 'x', + } as ValidationError, + ]; + + expect(executor.stripEmptyErrors(errors)).toEqual(errors); + }); + + it('should handle async inline validation failure with context in customValidations', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + name: 'asyncInline', + }); + metadata.inlineValidate = () => Promise.resolve(false); + metadata.constraints = []; + metadata.context = { key: 'asyncCtx' }; + + const object = { prop: 'x' }; + let error: ValidationError | undefined; + const getError = (): ValidationError => { + if (!error) error = (executor as any).generateValidationError(object, object.prop, 'prop'); + return error; + }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).customValidations(object, object.prop, [metadata], getError); + + return Promise.all((executor as any).awaitingPromises).then(() => { + expect(error).toBeDefined(); + expect(error.constraints).toHaveProperty('asyncInline'); + expect(error.contexts).toEqual({ asyncInline: { key: 'asyncCtx' } }); + }); + }); + + it('should create an error when async inline validation fails in the customOnly fast path', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + name: 'asyncInlineFastPath', + }); + metadata.inlineValidate = () => Promise.resolve(false); + metadata.constraints = []; + + const object = { prop: 'x' }; + const errors: ValidationError[] = []; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).performValidations( + object, + object.prop, + 'prop', + { + defined: [], + custom: [metadata], + nested: [], + conditional: [], + all: [metadata], + hasPromiseValidation: false, + customOnly: true, + }, + errors + ); + + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({}); + + return Promise.all((executor as any).awaitingPromises).then(() => { + expect(errors[0].constraints).toHaveProperty('asyncInlineFastPath'); + }); + }); + + it('should create the fast-path validation error inside a synchronous thenable callback', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + name: 'syncThenableInline', + }); + metadata.inlineValidate = () => + ({ + then(resolve: (isValid: boolean) => void) { + resolve(false); + return Promise.resolve(); + }, + } as any); + metadata.constraints = []; + + const errors: ValidationError[] = []; + const object = { prop: 'x' }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).performValidations( + object, + object.prop, + 'prop', + { + defined: [], + custom: [metadata], + nested: [], + conditional: [], + all: [metadata], + hasPromiseValidation: false, + customOnly: true, + }, + errors + ); + + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty('syncThenableInline'); + }); + + it('should push a fast-path error when async work is pending without constraints due to stopAtFirstError', () => { + const executor = new ValidationExecutor(new Validator(), { stopAtFirstError: true }); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class {}, + }); + metadata.constraints = []; + metadata.resolvedConstraints = [ + { + name: 'asyncPassConstraint', + async: true, + instance: { + validate: () => Promise.resolve(true), + defaultMessage: () => 'unused', + }, + }, + ]; + + const errors: ValidationError[] = []; + const object = { prop: 'x' }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).performValidations( + object, + object.prop, + 'prop', + { + defined: [], + custom: [metadata], + nested: [], + conditional: [], + all: [metadata], + hasPromiseValidation: false, + customOnly: true, + }, + errors + ); + + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({}); + }); + + it('should return immediately when customValidations receives no metadata', () => { + const executor = new ValidationExecutor(new Validator()); + const getError = jest.fn(() => { + throw new Error('should not be called'); + }); + + (executor as any).customValidations({}, 'value', [], getError); + + expect(getError).not.toHaveBeenCalled(); + }); + + it('should skip customValidations metadata when validateIf returns false', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + name: 'validateIfSkipped', + }); + metadata.inlineValidate = jest.fn(() => false); + metadata.validateIf = () => false; + metadata.constraints = []; + + const getError = jest.fn(() => (executor as any).generateValidationError({}, 'value', 'prop')); + + (executor as any).customValidations({}, 'value', [metadata], getError); + + expect(metadata.inlineValidate).not.toHaveBeenCalled(); + expect(getError).not.toHaveBeenCalled(); + }); + + it('should skip subsequent non-inline validators when stopAtFirstError and a constraint already exists', () => { + const secondValidateSpy = jest.fn(() => false); + const executor = new ValidationExecutor(new Validator(), { stopAtFirstError: true }); + + const metadata1 = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class {}, + }); + metadata1.constraints = []; + metadata1.resolvedConstraints = [ + { + name: 'firstConstraint', + async: false, + instance: { validate: () => false, defaultMessage: () => 'first failed' }, + }, + ]; + + const metadata2 = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class {}, + }); + metadata2.constraints = []; + metadata2.resolvedConstraints = [ + { + name: 'secondConstraint', + async: false, + instance: { validate: secondValidateSpy, defaultMessage: () => 'second failed' }, + }, + ]; + + const object = { prop: 'x' }; + let error: ValidationError | undefined; + const getError = (): ValidationError => { + if (!error) error = (executor as any).generateValidationError(object, object.prop, 'prop'); + return error; + }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).customValidations(object, object.prop, [metadata1, metadata2], getError); + + expect(error).toBeDefined(); + expect(error.constraints).toHaveProperty('firstConstraint'); + expect(error.constraints).not.toHaveProperty('secondConstraint'); + expect(secondValidateSpy).not.toHaveBeenCalled(); + }); + + it('should set context when async each-validation fails', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class {}, + }); + metadata.constraints = []; + metadata.each = true; + metadata.context = { tag: 'each-ctx' }; + metadata.resolvedConstraints = [ + { + name: 'asyncEachConstraint', + async: true, + instance: { validate: () => Promise.resolve(false), defaultMessage: () => 'each async failed' }, + }, + ]; + + const object = { prop: ['a', 'b'] }; + let error: ValidationError | undefined; + const getError = (): ValidationError => { + if (!error) error = (executor as any).generateValidationError(object, object.prop, 'prop'); + return error; + }; + + (executor as any).currentTargetName = 'TestTarget'; + (executor as any).customValidations(object, object.prop, [metadata], getError); + + return Promise.all((executor as any).awaitingPromises).then(() => { + expect(error).toBeDefined(); + expect(error.constraints).toHaveProperty('asyncEachConstraint'); + expect(error.contexts).toEqual({ asyncEachConstraint: { tag: 'each-ctx' } }); + }); + }); + + it('should skip non-nested and non-promise metadata types in nestedValidations', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + }); + metadata.constraints = []; + + const error = (executor as any).generateValidationError({}, 'value', 'prop'); + (executor as any).nestedValidations('value', [metadata], error); + + expect(error.constraints).toEqual({}); + }); + + it('should execute nested validations using a string schema target name', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.NESTED_VALIDATION, + target: 'SchemaTarget' as any, + propertyName: 'prop', + }); + metadata.constraints = []; + + const child = { nested: true }; + const error = (executor as any).generateValidationError({}, child, 'prop'); + const executeSpy = jest.spyOn(executor as any, 'execute').mockImplementation(() => {}); + + (executor as any).nestedValidations(child, [metadata], error); + + expect(executeSpy).toHaveBeenCalledWith(child, 'SchemaTarget', error.children); + executeSpy.mockRestore(); + }); + + it('should skip nested validations when stopAtFirstError is enabled and an error already exists', () => { + const executor = new ValidationExecutor(new Validator(), { stopAtFirstError: true }); + const metadata = new ValidationMetadata({ + type: ValidationTypes.NESTED_VALIDATION, + target: TestTarget, + propertyName: 'prop', + }); + metadata.constraints = []; + + const error = (executor as any).generateValidationError({}, { nested: true }, 'prop'); + error.constraints.existing = 'failed'; + const performSpy = jest.spyOn(executor as any, 'performValidations').mockImplementation(() => {}); + const executeSpy = jest.spyOn(executor as any, 'execute').mockImplementation(() => {}); + + (executor as any).nestedValidations([{ nested: true }], [metadata], error); + + expect(performSpy).not.toHaveBeenCalled(); + expect(executeSpy).not.toHaveBeenCalled(); + performSpy.mockRestore(); + executeSpy.mockRestore(); + }); + + it('should evaluate nested stopAtFirstError guard when constraints are undefined', () => { + const executor = new ValidationExecutor(new Validator(), { stopAtFirstError: true }); + const metadata = new ValidationMetadata({ + type: ValidationTypes.NESTED_VALIDATION, + target: 'SchemaTarget' as any, + propertyName: 'prop', + }); + metadata.constraints = []; + + const child = { nested: true }; + const error = (executor as any).generateValidationError({}, child, 'prop'); + error.constraints = undefined as any; + const executeSpy = jest.spyOn(executor as any, 'execute').mockImplementation(() => {}); + + (executor as any).nestedValidations(child, [metadata], error); + + expect(executeSpy).toHaveBeenCalledWith(child, 'SchemaTarget', error.children); + executeSpy.mockRestore(); + }); + + it('should use metadata.name for inline validators in mapContexts and initialise error.contexts', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + name: 'inlineCtxValidator', + }); + metadata.inlineValidate = () => false; + metadata.constraints = []; + metadata.context = { role: 'inline' }; + + const error = (executor as any).generateValidationError({}, 'value', 'prop'); + error.constraints['inlineCtxValidator'] = 'failed'; + + (executor as any).mapContexts({}, 'value', [metadata], error); + + expect(error.contexts).toEqual({ inlineCtxValidator: { role: 'inline' } }); + }); + + it('should use resolved constraint name for non-inline validators in mapContexts', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class {}, + }); + metadata.constraints = []; + metadata.context = { role: 'non-inline' }; + metadata.resolvedConstraints = [{ name: 'resolvedConstraintName' }]; + + const error = (executor as any).generateValidationError({}, 'value', 'prop'); + error.constraints['resolvedConstraintName'] = 'failed'; + + (executor as any).mapContexts({}, 'value', [metadata], error); + + expect(error.contexts).toEqual({ resolvedConstraintName: { role: 'non-inline' } }); + }); + + it('should fall back to metadata.type for inline validators without a name in mapContexts', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + }); + metadata.inlineValidate = () => false; + metadata.constraints = []; + metadata.context = { role: 'inline-fallback' }; + + const error = (executor as any).generateValidationError({}, 'value', 'prop'); + error.constraints[ValidationTypes.CUSTOM_VALIDATION] = 'failed'; + + (executor as any).mapContexts({}, 'value', [metadata], error); + + expect(error.contexts).toEqual({ [ValidationTypes.CUSTOM_VALIDATION]: { role: 'inline-fallback' } }); + }); + + it('should fall back to metadata.type when resolved constraints are fetched without a name in mapContexts', () => { + const executor = new ValidationExecutor(new Validator()); + const metadata = new ValidationMetadata({ + type: ValidationTypes.CUSTOM_VALIDATION, + target: TestTarget, + propertyName: 'prop', + constraintCls: class {}, + }); + metadata.constraints = []; + metadata.context = { role: 'resolved-fallback' }; + (executor as any).metadataStorage = { + getTargetValidatorConstraints: () => [{}], + }; + + const error = (executor as any).generateValidationError({}, 'value', 'prop'); + error.constraints[ValidationTypes.CUSTOM_VALIDATION] = 'failed'; + + (executor as any).mapContexts({}, 'value', [metadata], error); + + expect(error.contexts).toEqual({ [ValidationTypes.CUSTOM_VALIDATION]: { role: 'resolved-fallback' } }); }); it('should map context for async custom constraint failures', () => { @@ -275,8 +804,8 @@ describe('ValidationExecutor PR-2665 coverage', () => { return Promise.all((executor as any).awaitingPromises).then(() => { expect(error).toBeDefined(); - expect(error!.constraints).toHaveProperty('asyncContextConstraint'); - expect(error!.contexts).toEqual({ asyncContextConstraint: { key: 'value' } }); + expect(error.constraints).toHaveProperty('asyncContextConstraint'); + expect(error.contexts).toEqual({ asyncContextConstraint: { key: 'value' } }); }); }); });