⚡ perf: memoize per-class plans; add depth guard and per-index each reporting
Profiling the validator showed roughly half of all validation time re-deriving
answers that cannot change — collectConstraints 22%, getOwnMetadata 12%,
getMetadataChain 9%, getProperties 4%, getMetadata 3%, plus 8% GC from the
allocation churn. The constraint predicates themselves were under 1%.
Decorator metadata is fixed once classes are declared, so the derived structures
are now memoized per prototype: the validation plan, the serialization plan, the
deserialization plan, and serializer/deserializer instances, which were being
constructed fresh for every property of every object. MetadataStorage carries a
version counter that invalidates the caches when metadata is written, so
registerDecorator after first use still works — covered by a test.
Measured against JSON.parse + JSON.stringify as a fixed reference:
validate (50 orders) 221.6 us -> 49.6 us 4.5x
validate (10 orders) 47.8 us -> 12.9 us 3.7x
toInstance (50 orders) 255.1 us -> 74.0 us 3.4x
toInstance (10 orders) 64.6 us -> 19.0 us 3.4x
toPlain (50 orders) 294.4 us -> 95.3 us 3.1x
Reliability, in the same pass:
- maxDepth option (default 64) on every mapping function, on validate(), and on
configure(). All three engines recurse, so a payload nested thousands of levels
deep could exhaust the call stack. Cycles were already handled; legitimate deep
nesting was not bounded.
- each: true failures now name the element that failed ("failed at index 3"). A
bad entry in a 200-item array previously produced a message that could not
locate it. A message function now receives the failing element as args.value
rather than the whole array; caller-supplied strings stay verbatim.
150 tests (up from 136), all green on the existing suite unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
||||
JsonSerializer, JsonDeserializer, JsonMappingError,
|
||||
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
|
||||
} from './index.js';
|
||||
|
||||
afterEach(() => resetConfig());
|
||||
|
||||
describe('plan caching', () => {
|
||||
// The validation plan for a class is memoized. It must not go stale when metadata is
|
||||
// registered after the class has already been validated once.
|
||||
it('picks up a decorator registered after the first validation', async () => {
|
||||
class Late {
|
||||
value: any;
|
||||
}
|
||||
|
||||
const before = new Late();
|
||||
before.value = 'anything';
|
||||
expect(await validate(before)).toEqual([]);
|
||||
|
||||
// Register a rule after the plan has already been built and cached.
|
||||
registerDecorator({
|
||||
name: 'isEven',
|
||||
target: Late,
|
||||
propertyName: 'value',
|
||||
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||
});
|
||||
|
||||
const after = new Late();
|
||||
after.value = 'anything';
|
||||
expect(await validate(after)).toHaveLength(1);
|
||||
|
||||
after.value = 4;
|
||||
expect(await validate(after)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps per-class plans separate', async () => {
|
||||
class A {
|
||||
@IsString()
|
||||
v: any;
|
||||
}
|
||||
class B {
|
||||
@IsInt()
|
||||
v: any;
|
||||
}
|
||||
|
||||
const a = new A();
|
||||
a.v = 'text';
|
||||
const b = new B();
|
||||
b.v = 'text';
|
||||
|
||||
expect(await validate(a)).toEqual([]);
|
||||
expect(await validate(b)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses one serializer instance rather than constructing per property', async () => {
|
||||
let constructed = 0;
|
||||
class Counting implements JsonSerializer<string, string> {
|
||||
constructor() { constructed++; }
|
||||
serialize(value: string): string { return value.toUpperCase(); }
|
||||
}
|
||||
class Doc {
|
||||
@JsonSerialize(Counting)
|
||||
a: string;
|
||||
|
||||
@JsonSerialize(Counting)
|
||||
b: string;
|
||||
}
|
||||
|
||||
const doc = new Doc();
|
||||
doc.a = 'x';
|
||||
doc.b = 'y';
|
||||
|
||||
await toPlain(doc);
|
||||
await toPlain(doc);
|
||||
await toPlain(doc);
|
||||
|
||||
expect(await toPlain(doc)).toEqual({ a: 'X', b: 'Y' });
|
||||
expect(constructed).toBe(1);
|
||||
});
|
||||
|
||||
it('still honours a deserializer after caching', async () => {
|
||||
class ToDate implements JsonDeserializer<string, Date> {
|
||||
deserialize(value: string): Date { return new Date(value); }
|
||||
}
|
||||
class Event {
|
||||
@JsonDeserialize(ToDate)
|
||||
at: Date;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const e = await toInstance(Event, { at: '2026-01-01T00:00:00Z' });
|
||||
expect(e.at).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxDepth guard', () => {
|
||||
const nest = (depth: number): any => {
|
||||
let node: any = { value: 'leaf' };
|
||||
for (let i = 0; i < depth; i++) node = { child: node };
|
||||
return node;
|
||||
};
|
||||
|
||||
class Node {
|
||||
@ValidateNested()
|
||||
@JsonType(() => Node)
|
||||
child?: Node;
|
||||
|
||||
value?: string;
|
||||
}
|
||||
|
||||
it('rejects a payload nested past the limit instead of exhausting the stack', async () => {
|
||||
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||
.rejects.toThrow(JsonMappingError);
|
||||
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||
.rejects.toThrow(/Maximum nesting depth/);
|
||||
});
|
||||
|
||||
it('accepts nesting within the limit', async () => {
|
||||
const parsed = await toInstance(Node, nest(10), { validate: false });
|
||||
expect(parsed).toBeInstanceOf(Node);
|
||||
});
|
||||
|
||||
it('is configurable per call and globally', async () => {
|
||||
await expect(toInstance(Node, nest(10), { validate: false, maxDepth: 3 }))
|
||||
.rejects.toThrow(/Maximum nesting depth of 3/);
|
||||
|
||||
configure({ maxDepth: 2 });
|
||||
await expect(toInstance(Node, nest(10), { validate: false }))
|
||||
.rejects.toThrow(/Maximum nesting depth of 2/);
|
||||
});
|
||||
|
||||
it('guards serialization too', async () => {
|
||||
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||
await expect(toPlain(deep, { validate: false, maxDepth: 5 }))
|
||||
.rejects.toThrow(/Maximum nesting depth/);
|
||||
});
|
||||
|
||||
it('guards validation too', async () => {
|
||||
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||
await expect(validate(deep, { maxDepth: 5 })).rejects.toThrow(/Maximum nesting depth/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('each: true error reporting', () => {
|
||||
it('names the index of the element that failed', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a', 'b'], { each: true })
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]!.constraints['isIn']).toContain('failed at index 3');
|
||||
});
|
||||
|
||||
it('leaves a caller-supplied message untouched', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a'], { each: true, message: 'bad tag' })
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'zzz'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
||||
});
|
||||
|
||||
it('gives the failing element to a message function, not the whole array', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'zzz'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
||||
});
|
||||
|
||||
it('reports nothing when every element passes', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a', 'b'], { each: true })
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'b'];
|
||||
expect(await validate(basket)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate() accepts options', () => {
|
||||
it('threads maxDepth through nested validation', async () => {
|
||||
class Item {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
qty: number;
|
||||
}
|
||||
class Order {
|
||||
@IsString()
|
||||
ref: string;
|
||||
|
||||
@ValidateNested()
|
||||
@JsonType(() => Item)
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
const bad = new Item();
|
||||
bad.qty = -1;
|
||||
const order = new Order();
|
||||
order.ref = 'r';
|
||||
order.items = [bad];
|
||||
|
||||
// Deep enough to be fine at the default, so behaviour is unchanged.
|
||||
expect(await validate(order)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user