Skip to content

Commit ae56f48

Browse files
committed
perf: series of perf optimisations
1 parent 977d2c7 commit ae56f48

5 files changed

Lines changed: 282 additions & 59 deletions

File tree

benchmark.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import 'reflect-metadata';
2+
import { validate, IsString, IsInt, IsBoolean, IsEmail, IsOptional, MinLength, MaxLength, Min, Max, IsNotEmpty, ValidateNested } from './src';
3+
4+
// --- Classes with inheritance and nesting ---
5+
class BaseEntity {
6+
@IsString()
7+
id!: string;
8+
9+
@IsString()
10+
@MinLength(1)
11+
createdBy!: string;
12+
}
13+
14+
class Address {
15+
@IsString()
16+
@IsNotEmpty()
17+
street!: string;
18+
19+
@IsString()
20+
@IsNotEmpty()
21+
city!: string;
22+
23+
@IsString()
24+
@MinLength(2)
25+
@MaxLength(10)
26+
zip!: string;
27+
}
28+
29+
class User extends BaseEntity {
30+
@IsString()
31+
@MinLength(2)
32+
@MaxLength(50)
33+
firstName!: string;
34+
35+
@IsString()
36+
@MinLength(2)
37+
@MaxLength(50)
38+
lastName!: string;
39+
40+
@IsEmail()
41+
email!: string;
42+
43+
@IsInt()
44+
@Min(0)
45+
@Max(150)
46+
age!: number;
47+
48+
@IsBoolean()
49+
isActive!: boolean;
50+
51+
@IsOptional()
52+
@IsString()
53+
nickname?: string;
54+
55+
@IsOptional()
56+
@IsString()
57+
bio?: string;
58+
59+
@IsOptional()
60+
@IsString()
61+
phone?: string;
62+
63+
@IsOptional()
64+
@IsInt()
65+
@Min(0)
66+
score?: number;
67+
68+
@IsOptional()
69+
@IsString()
70+
website?: string;
71+
72+
@ValidateNested()
73+
address!: Address;
74+
}
75+
76+
// --- Benchmark helpers ---
77+
78+
function createValidUser(): User {
79+
const user = new User();
80+
user.id = 'abc-123';
81+
user.createdBy = 'system';
82+
user.firstName = 'John';
83+
user.lastName = 'Doe';
84+
user.email = 'john@example.com';
85+
user.age = 30;
86+
user.isActive = true;
87+
user.nickname = 'johnd';
88+
user.bio = 'A developer';
89+
user.phone = '555-1234';
90+
user.score = 100;
91+
user.website = 'https://example.com';
92+
const address = new Address();
93+
address.street = '123 Main St';
94+
address.city = 'Springfield';
95+
address.zip = '62704';
96+
user.address = address;
97+
return user;
98+
}
99+
100+
async function bench(label: string, iterations: number, fn: () => Promise<void>): Promise<void> {
101+
// Warmup
102+
for (let i = 0; i < 100; i++) await fn();
103+
104+
const start = performance.now();
105+
for (let i = 0; i < iterations; i++) await fn();
106+
const elapsed = performance.now() - start;
107+
108+
const opsPerSec = Math.round((iterations / elapsed) * 1000);
109+
console.log(`${label}: ${iterations} iterations in ${elapsed.toFixed(1)}ms (${opsPerSec.toLocaleString()} ops/sec)`);
110+
}
111+
112+
async function main(): Promise<void> {
113+
const iterations = 10_000;
114+
const user = createValidUser();
115+
116+
console.log('class-validator benchmark');
117+
console.log('========================\n');
118+
119+
await bench('Valid object (13 props, inheritance + nested)', iterations, async () => {
120+
await validate(user);
121+
});
122+
123+
const invalidUser = createValidUser();
124+
invalidUser.email = 'not-an-email';
125+
invalidUser.age = -5;
126+
invalidUser.firstName = 'X';
127+
128+
await bench('Invalid object (3 errors)', iterations, async () => {
129+
await validate(invalidUser);
130+
});
131+
132+
await bench('Valid object with groups', iterations, async () => {
133+
await validate(user, { groups: ['admin', 'user'] });
134+
});
135+
136+
await bench('Valid object with strictGroups', iterations, async () => {
137+
await validate(user, { strictGroups: true });
138+
});
139+
}
140+
141+
main().catch(console.error);

src/container.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ export interface UseContainerOptions {
1818
* container simply creates a new instance of the given class.
1919
*/
2020
const defaultContainer: { get<T>(someClass: { new (...args: any[]): T } | Function): T } = new (class {
21-
private instances: { type: Function; object: any }[] = [];
21+
private instances = new Map<Function, any>();
2222
get<T>(someClass: { new (...args: any[]): T }): T {
23-
let instance = this.instances.find(instance => instance.type === someClass);
23+
let instance = this.instances.get(someClass);
2424
if (!instance) {
25-
instance = { type: someClass, object: new someClass() };
26-
this.instances.push(instance);
25+
instance = new someClass();
26+
this.instances.set(someClass, instance);
2727
}
2828

29-
return instance.object;
29+
return instance;
3030
}
3131
})();
3232

src/metadata/ConstraintMetadata.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export class ConstraintMetadata {
2828
// Constructor
2929
// -------------------------------------------------------------------------
3030

31+
private _instance!: ValidatorConstraintInterface;
32+
3133
constructor(target: Function, name?: string, async: boolean = false) {
3234
this.target = target;
3335
this.name = name;
@@ -42,6 +44,9 @@ export class ConstraintMetadata {
4244
* Instance of the target custom validation class which performs validation.
4345
*/
4446
get instance(): ValidatorConstraintInterface {
45-
return getFromContainer<ValidatorConstraintInterface>(this.target);
47+
if (!this._instance) {
48+
this._instance = getFromContainer<ValidatorConstraintInterface>(this.target);
49+
}
50+
return this._instance;
4651
}
4752
}

src/metadata/MetadataStorage.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ export class MetadataStorage {
1212
// Private properties
1313
// -------------------------------------------------------------------------
1414

15+
private static _nextId = 0;
16+
1517
private validationMetadatas: Map<any, ValidationMetadata[]> = new Map();
1618
private constraintMetadatas: Map<any, ConstraintMetadata[]> = new Map();
19+
private targetMetadataCache: Map<string, ValidationMetadata[]> = new Map();
20+
private groupedMetadataCache: Map<string, Record<string, ValidationMetadata[]>> = new Map();
1721

1822
get hasValidationMetaData(): boolean {
1923
return !!this.validationMetadatas.size;
@@ -35,6 +39,9 @@ export class MetadataStorage {
3539
* Adds a new validation metadata.
3640
*/
3741
addValidationMetadata(metadata: ValidationMetadata): void {
42+
this.targetMetadataCache.clear();
43+
this.groupedMetadataCache.clear();
44+
3845
const existingMetadata = this.validationMetadatas.get(metadata.target);
3946

4047
if (existingMetadata) {
@@ -60,25 +67,54 @@ export class MetadataStorage {
6067
/**
6168
* Groups metadata by their property names.
6269
*/
63-
groupByPropertyName(metadata: ValidationMetadata[]): { [propertyName: string]: ValidationMetadata[] } {
70+
groupByPropertyName(
71+
metadata: ValidationMetadata[],
72+
cacheKey?: string
73+
): { [propertyName: string]: ValidationMetadata[] } {
74+
if (cacheKey) {
75+
const cached = this.groupedMetadataCache.get(cacheKey);
76+
if (cached) return cached;
77+
}
78+
6479
const grouped: { [propertyName: string]: ValidationMetadata[] } = {};
6580
metadata.forEach(metadata => {
6681
if (!grouped[metadata.propertyName]) grouped[metadata.propertyName] = [];
6782
grouped[metadata.propertyName].push(metadata);
6883
});
84+
85+
if (cacheKey) {
86+
this.groupedMetadataCache.set(cacheKey, grouped);
87+
}
88+
6989
return grouped;
7090
}
7191

7292
/**
7393
* Gets all validation metadatas for the given object with the given groups.
7494
*/
95+
buildCacheKey(
96+
target: Function,
97+
schema: string,
98+
always: boolean,
99+
strictGroups: boolean,
100+
groups?: string[]
101+
): string {
102+
const targetId = (target as any).__cv_id ?? ((target as any).__cv_id = ++MetadataStorage._nextId);
103+
const groupKey = groups?.length ? groups.slice().sort().join(',') : '';
104+
return `${targetId}|${schema || ''}|${always ? 1 : 0}|${strictGroups ? 1 : 0}|${groupKey}`;
105+
}
106+
75107
getTargetValidationMetadatas(
76108
targetConstructor: Function,
77109
targetSchema: string,
78110
always: boolean,
79111
strictGroups: boolean,
80112
groups?: string[]
81113
): ValidationMetadata[] {
114+
const cacheKey = this.buildCacheKey(targetConstructor, targetSchema, always, strictGroups, groups);
115+
const cached = this.targetMetadataCache.get(cacheKey);
116+
if (cached) return cached;
117+
82118
const includeMetadataBecauseOfAlwaysOption = (metadata: ValidationMetadata): boolean => {
83119
// `metadata.always` overrides global default.
84120
if (typeof metadata.always !== 'undefined') return metadata.always;
@@ -145,7 +181,9 @@ export class MetadataStorage {
145181
});
146182
});
147183

148-
return originalMetadatas.concat(uniqueInheritedMetadatas);
184+
const result = originalMetadatas.concat(uniqueInheritedMetadatas);
185+
this.targetMetadataCache.set(cacheKey, result);
186+
return result;
149187
}
150188

151189
/**

0 commit comments

Comments
 (0)