Merge pull request #2 from avalon-vanguard/claude/consolidation-perf-reliability

Memoize per-class plans (3-4.5x faster), add depth guard and per-index each reporting

Profiling 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 - while the constraint predicates
themselves accounted for under 1%.

Decorator metadata is fixed once classes are declared, so the validation,
serialization and deserialization plans are now memoized per prototype, along
with serializer/deserializer instances that were previously constructed for every
property of every object. A version counter on MetadataStorage invalidates the
caches when metadata is written, so registerDecorator after first use still takes
effect.

  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
  toPlain     (50 orders)   294.4 us -> 95.3 us   3.1x

Also bounds recursion with a maxDepth option (default 64) across all three
engines, closing a stack-exhaustion vector on hostile payloads, and makes
each: true failures name the element that failed.

136 -> 150 tests, no breaking changes, green on Node 20, 22 and 24.
This commit is contained in:
Senrokai
2026-08-04 13:41:49 +02:00
committed by GitHub
8 changed files with 545 additions and 72 deletions
+43
View File
@@ -5,6 +5,49 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Performance
Profiling the validator showed roughly **half of all validation time** was spent re-deriving
answers that cannot change: `collectConstraints` (22%), `getOwnMetadata` (12%),
`getMetadataChain` (9%), `getProperties` (4%) and `getMetadata` (3%), plus 8% garbage
collection from the allocation churn. The constraint predicates themselves accounted for
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 (previously constructed fresh for every property
of every object). `MetadataStorage` carries a version counter that invalidates every cache
if metadata is registered late, so `registerDecorator` after first use still works.
Measured on a customer record with nested address and orders, against `JSON.parse` +
`JSON.stringify` as a fixed reference point:
| Operation | Before | After | Speedup |
| --- | --- | --- | --- |
| `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 |
| `toInstance` (single) | 19.8 us | 8.4 us | 2.4x |
### Added
- `maxDepth` option (default 64) on every mapping function and on `configure()`. All three
engines recurse, so a hostile payload nested thousands of levels deep could exhaust the
call stack; it now raises a `JsonMappingError`. Cycles were already handled, but legitimate
deep nesting was not bounded.
- `validate(obj, options?)` accepts options, so `maxDepth` applies to standalone validation.
### Changed
- `each: true` failures now report which element 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 string messages are still reported verbatim.
## [0.1.0] - 2026-08-03 ## [0.1.0] - 2026-08-03
The first release with a working test suite. Everything below the "Fixed" heading was The first release with a working test suite. Everything below the "Fixed" heading was
+22 -1
View File
@@ -203,6 +203,7 @@ defaults for the whole application. Per-call options win.
| `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. | | `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. |
| `namingStrategy` | strategy name or function | `identity` | JSON naming convention for properties without `@JsonProperty`. | | `namingStrategy` | strategy name or function | `identity` | JSON naming convention for properties without `@JsonProperty`. |
| `unknownKeys` | `allow` \| `strip` \| `error` | `allow` | What to do with incoming keys matching no declared property. | | `unknownKeys` | `allow` \| `strip` \| `error` | `allow` | What to do with incoming keys matching no declared property. |
| `maxDepth` | `number` | `64` | Nesting depth before a `JsonMappingError` is raised, bounding hostile payloads. |
```typescript ```typescript
// lenient parse: build the instance, inspect the damage yourself // lenient parse: build the instance, inspect the damage yourself
@@ -293,7 +294,7 @@ Write your own with `registerDecorator({ name, target, propertyName, validator }
- `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`). - `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
- `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`). - `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`). - `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
- `validate(obj)`: Full validation, returning `Promise<ValidationError[]>`. - `validate(obj, options?)`: Full validation, returning `Promise<ValidationError[]>`.
- `validateOrReject(obj)`: As above, but throws `JsonValidationError`. - `validateOrReject(obj)`: As above, but throws `JsonValidationError`.
- `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults. - `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults.
@@ -350,6 +351,26 @@ app.post('/user', async (req, res) => {
}); });
``` ```
## Performance
Decorator metadata is fixed once your classes are declared, so cereale resolves each class's
validation, serialization and deserialization plans once and memoizes them per prototype.
A version counter invalidates the caches if metadata is registered late, so `registerDecorator`
after first use still behaves correctly.
Indicative throughput for a customer record with a nested address and 10 orders, measured
against `JSON.parse` + `JSON.stringify` (5.9 us) on the same machine:
| Operation | Time |
| --- | --- |
| `toInstance` (deserialize + validate) | ~19 us |
| `toInstance` with `{ validate: false }` | ~6 us |
| `validate` on an existing instance | ~13 us |
| `toPlain` (validate + serialize) | ~23 us |
If you validate at the edge and map internally afterwards, `{ validate: false }` skips the
dominant cost.
## Notes and Limitations ## Notes and Limitations
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break - **Circular references** are rejected during serialization with a `JsonMappingError`. Break
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "cereale", "name": "cereale",
"version": "0.0.1", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cereale", "name": "cereale",
"version": "0.0.1", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
+11 -1
View File
@@ -27,15 +27,24 @@ export interface TransformOptions {
/** What to do with incoming keys that match no declared property. Deserialization only. */ /** What to do with incoming keys that match no declared property. Deserialization only. */
unknownKeys?: UnknownKeyPolicy; unknownKeys?: UnknownKeyPolicy;
/**
* Maximum nesting depth before a {@link JsonMappingError} is raised. Defaults to 64.
*
* All three engines recurse, so a hostile payload nested thousands of levels deep would
* otherwise exhaust the call stack. Raise it if you legitimately model deep trees.
*/
maxDepth?: number;
} }
/** Options that can be set once for the whole application via {@link configure}. */ /** Options that can be set once for the whole application via {@link configure}. */
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate'>; export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate' | 'maxDepth'>;
const DEFAULTS: Required<GlobalOptions> = { const DEFAULTS: Required<GlobalOptions> = {
namingStrategy: 'identity', namingStrategy: 'identity',
unknownKeys: 'allow', unknownKeys: 'allow',
validate: true, validate: true,
maxDepth: 64,
}; };
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS }; let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
@@ -71,5 +80,6 @@ export function resolveOptions(options?: TransformOptions): Required<GlobalOptio
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy, namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys, unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
validate: options.validate ?? globalOptions.validate, validate: options.validate ?? globalOptions.validate,
maxDepth: options.maxDepth ?? globalOptions.maxDepth,
}; };
} }
+222
View File
@@ -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);
});
});
+6 -1
View File
@@ -275,7 +275,12 @@ describe('configure()', () => {
it('resetConfig() restores the defaults', async () => { it('resetConfig() restores the defaults', async () => {
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false }); configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
resetConfig(); resetConfig();
expect(getConfig()).toEqual({ namingStrategy: 'identity', unknownKeys: 'allow', validate: true }); expect(getConfig()).toEqual({
namingStrategy: 'identity',
unknownKeys: 'allow',
validate: true,
maxDepth: 64,
});
}); });
}); });
+17 -1
View File
@@ -1,6 +1,20 @@
export class MetadataStorage { export class MetadataStorage {
private static instance: MetadataStorage; private static instance: MetadataStorage;
/**
* Bumped whenever any metadata is written.
*
* Decorators run at class-definition time, so in practice this stops changing once the
* application has loaded. Derived structures (see the validation plan cache in utils.ts)
* record the version they were built from and rebuild if it moves, which keeps caching
* safe even for metadata registered late through `registerDecorator`.
*/
private _version = 0;
get version(): number {
return this._version;
}
// Maps a prototype to its property names // Maps a prototype to its property names
private properties = new WeakMap<any, string[]>(); private properties = new WeakMap<any, string[]>();
@@ -24,6 +38,7 @@ export class MetadataStorage {
* Defines metadata for a specific property on a target. * Defines metadata for a specific property on a target.
*/ */
defineMetadata(key: string, value: any, target: any, propertyKey?: string) { defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
this._version++;
if (propertyKey) { if (propertyKey) {
let targetMap = this.propertyMetadata.get(target); let targetMap = this.propertyMetadata.get(target);
if (!targetMap) { if (!targetMap) {
@@ -101,6 +116,7 @@ export class MetadataStorage {
* Registers a property for a target. * Registers a property for a target.
*/ */
registerProperty(target: any, propertyKey: string) { registerProperty(target: any, propertyKey: string) {
this._version++;
let props = this.properties.get(target); let props = this.properties.get(target);
if (!props) { if (!props) {
props = []; props = [];
+222 -66
View File
@@ -45,12 +45,15 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
interface SerializeContext { interface SerializeContext {
naming: NamingStrategyFn; naming: NamingStrategyFn;
namingKey: unknown;
maxDepth: number;
} }
interface DeserializeContext { interface DeserializeContext {
naming: NamingStrategyFn; naming: NamingStrategyFn;
namingKey: unknown; namingKey: unknown;
unknownKeys: UnknownKeyPolicy; unknownKeys: UnknownKeyPolicy;
maxDepth: number;
} }
/** /**
@@ -74,9 +77,69 @@ function outboundName(target: any, key: string, naming: NamingStrategyFn): strin
return explicit ?? naming(key); return explicit ?? naming(key);
} }
/** Per-property serialization facts, resolved once instead of per call. */
interface OutboundProperty {
/** The name to write in the output. */
name: string;
/** True for @JsonIgnore / @JsonWriteOnly — omitted from output. */
skip: boolean;
/** The @JsonSerialize class, if any. */
serializer?: any;
}
const outboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
/**
* Resolves how one property is written out, memoized per (prototype, naming strategy).
*
* Serialization walks the runtime keys of each object, so undeclared properties turn up here
* too; they memoize just as well, since the naming strategy is deterministic.
*/
function outboundFor(target: any, key: string, ctx: SerializeContext): OutboundProperty {
if (!target) {
// Null-prototype object: nothing is declared, so there is nothing to cache against.
return { name: ctx.naming(key), skip: false };
}
let entry = outboundCache.get(target);
if (!entry || entry.version !== metadataStorage.version) {
entry = { version: metadataStorage.version, byStrategy: new Map() };
outboundCache.set(target, entry);
}
let byKey = entry.byStrategy.get(ctx.namingKey);
if (!byKey) {
byKey = new Map();
entry.byStrategy.set(ctx.namingKey, byKey);
}
let resolved = byKey.get(key);
if (!resolved) {
const access = accessOf(target, key);
const serializer = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
resolved = {
name: outboundName(target, key, ctx.naming),
// `writeonly` is accepted on input but must never be echoed back out.
skip: access === 'none' || access === 'writeonly',
...(serializer ? { serializer } : {}),
};
byKey.set(key, resolved);
}
return resolved;
}
/** Per-property deserialization facts, resolved once instead of per call. */
interface InboundProperty {
deserializer?: any;
polymorphic?: any;
typeFn?: () => ClassConstructor<any>;
}
interface InboundNames { interface InboundNames {
/** JSON name -> property key, for properties this payload is allowed to set. */ /** JSON name -> property key, for properties this payload is allowed to set. */
accept: Map<string, string>; accept: Map<string, string>;
/** property key -> the conversion metadata that applies to it. */
props: Map<string, InboundProperty>;
/** /**
* JSON names that belong to a declared property the payload may NOT set * JSON names that belong to a declared property the payload may NOT set
* (`@JsonIgnore` / `@JsonReadOnly`). They are dropped rather than treated as unknown * (`@JsonIgnore` / `@JsonReadOnly`). They are dropped rather than treated as unknown
@@ -88,7 +151,7 @@ interface InboundNames {
// Name maps are derived purely from decorator metadata, which is fixed once a class is // Name maps are derived purely from decorator metadata, which is fixed once a class is
// declared, so they are cached per (prototype, naming strategy). // declared, so they are cached per (prototype, naming strategy).
const inboundCache = new WeakMap<object, Map<unknown, InboundNames>>(); const inboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, InboundNames> }>();
/** /**
* Builds the JSON-name -> property-key lookup used when reading a payload. * Builds the JSON-name -> property-key lookup used when reading a payload.
@@ -99,16 +162,17 @@ const inboundCache = new WeakMap<object, Map<unknown, InboundNames>>();
* keep it working for older clients. * keep it working for older clients.
*/ */
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames { function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
let byStrategy = inboundCache.get(target); let entry = inboundCache.get(target);
if (!byStrategy) { if (!entry || entry.version !== metadataStorage.version) {
byStrategy = new Map(); entry = { version: metadataStorage.version, byStrategy: new Map() };
inboundCache.set(target, byStrategy); inboundCache.set(target, entry);
} }
const cached = byStrategy.get(ctx.namingKey); const cached = entry.byStrategy.get(ctx.namingKey);
if (cached) return cached; if (cached) return cached;
const accept = new Map<string, string>(); const accept = new Map<string, string>();
const blocked = new Set<string>(); const blocked = new Set<string>();
const props = new Map<string, InboundProperty>();
const claim = (external: string, key: string) => { const claim = (external: string, key: string) => {
const owner = accept.get(external); const owner = accept.get(external);
@@ -134,18 +198,36 @@ function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
} }
for (const name of names) claim(name, key); for (const name of names) claim(name, key);
const deserializer = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
const polymorphic = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
if (deserializer || polymorphic || typeFn) {
props.set(key, {
...(deserializer ? { deserializer } : {}),
...(polymorphic ? { polymorphic } : {}),
...(typeFn ? { typeFn } : {}),
});
}
} }
const result = { accept, blocked }; const result = { accept, blocked, props };
byStrategy.set(ctx.namingKey, result); entry.byStrategy.set(ctx.namingKey, result);
return result; return result;
} }
async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext): Promise<any> { async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number): Promise<any> {
if (obj === null || obj === undefined || typeof obj !== 'object') { if (obj === null || obj === undefined || typeof obj !== 'object') {
return obj; return obj;
} }
if (depth > ctx.maxDepth) {
throw new JsonMappingError(
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing. ` +
`Raise it with the maxDepth option if this structure is legitimate.`
);
}
if (obj instanceof Date) { if (obj instanceof Date) {
return obj.toISOString(); return obj.toISOString();
} }
@@ -162,7 +244,7 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
const out: any[] = []; const out: any[] = [];
for (const item of obj) { for (const item of obj) {
out.push(await serialize(item, ancestors, ctx)); out.push(await serialize(item, ancestors, ctx, depth + 1));
} }
return out; return out;
} }
@@ -171,21 +253,17 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
const result: any = {}; const result: any = {};
for (const key of Object.keys(obj)) { for (const key of Object.keys(obj)) {
const access = accessOf(target, key); const property = outboundFor(target, key, ctx);
// `writeonly` is accepted on input but must never be echoed back out. if (property.skip) continue;
if (access === 'none' || access === 'writeonly') continue;
const value = obj[key]; const value = obj[key];
const name = outboundName(target, key, ctx.naming);
// Custom serializers only see real values. Handing a serializer `undefined` for a // Custom serializers only see real values. Handing a serializer `undefined` for a
// property that was simply never set turns an optional field into a crash. // property that was simply never set turns an optional field into a crash.
const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined; if (property.serializer && value !== null && value !== undefined) {
if (serializerCls && value !== null && value !== undefined) { result[property.name] = await converterFor(property.serializer).serialize(value);
const serializer = new serializerCls();
result[name] = await serializer.serialize(value);
} else { } else {
result[name] = await serialize(value, ancestors, ctx); result[property.name] = await serialize(value, ancestors, ctx, depth + 1);
} }
} }
@@ -197,11 +275,18 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
} }
} }
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext): Promise<T> { async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext, depth: number): Promise<T> {
if (plain === null || plain === undefined) return plain; if (plain === null || plain === undefined) return plain;
if (depth > ctx.maxDepth) {
throw new JsonMappingError(
`Maximum nesting depth of ${ctx.maxDepth} exceeded while deserializing. ` +
`Raise it with the maxDepth option if this structure is legitimate.`
);
}
if (Array.isArray(plain)) { if (Array.isArray(plain)) {
const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx))); const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx, depth + 1)));
return results as any; return results as any;
} }
@@ -235,24 +320,24 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
const value = plain[incoming]; const value = plain[incoming];
const property = inbound.props.get(key);
// Custom Deserializer // Custom Deserializer
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key); if (property?.deserializer) {
if (deserializerCls) { instance[key as keyof T] = await converterFor(property.deserializer).deserialize(value);
const deserializer = new deserializerCls();
instance[key as keyof T] = await deserializer.deserialize(value);
continue; continue;
} }
// Polymorphic // Polymorphic
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key); const poly = property?.polymorphic;
if (poly && value !== null && value !== undefined) { if (poly && value !== null && value !== undefined) {
const { discriminator, subTypes, onUnknown, fallback } = poly; const { discriminator, subTypes, onUnknown, fallback } = poly;
const resolve = async (item: any): Promise<any> => { const resolve = async (item: any): Promise<any> => {
if (item === null || item === undefined || typeof item !== 'object') return item; if (item === null || item === undefined || typeof item !== 'object') return item;
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name); const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx); if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx, depth + 1);
if (fallback) return deserialize(fallback, item, ctx); if (fallback) return deserialize(fallback, item, ctx, depth + 1);
if (onUnknown === 'error') { if (onUnknown === 'error') {
throw new JsonMappingError( throw new JsonMappingError(
`Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` + `Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
@@ -270,10 +355,10 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
} }
// Nested Type // Nested Type
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key); const typeFn = property?.typeFn;
if (typeFn && value !== null && value !== undefined) { if (typeFn && value !== null && value !== undefined) {
const type = typeFn(); const type = typeFn();
instance[key as keyof T] = await deserialize(type, value, ctx); instance[key as keyof T] = await deserialize(type, value, ctx, depth + 1);
continue; continue;
} }
@@ -313,6 +398,63 @@ function collectConstraints(target: any, key: string): ValidationConstraint[] {
return merged; return merged;
} }
/**
* Everything the validator needs to know about one property, resolved once.
*/
interface PropertyPlan {
key: string;
constraints: ValidationConstraint[];
isOptional: boolean;
isNested: boolean;
condition?: (object: any) => boolean;
}
interface CachedPlan {
version: number;
plan: PropertyPlan[];
}
// Resolving a class's validation rules means walking its prototype chain several times per
// property, per call — which profiling showed to be roughly half of all validation time,
// recomputing an answer that cannot change. The result is memoized per prototype and
// invalidated by MetadataStorage's version counter, so metadata registered late still works.
const planCache = new WeakMap<object, CachedPlan>();
function validationPlan(target: any): PropertyPlan[] {
const cached = planCache.get(target);
if (cached && cached.version === metadataStorage.version) {
return cached.plan;
}
const plan: PropertyPlan[] = [];
for (const key of metadataStorage.getProperties(target)) {
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
plan.push({
key,
constraints: collectConstraints(target, key),
isOptional: !!metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key),
isNested: !!metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key),
...(condition ? { condition } : {}),
});
}
planCache.set(target, { version: metadataStorage.version, plan });
return plan;
}
// Serializers and deserializers are stateless by contract, so one instance per class is
// enough. Constructing a fresh one for every property of every object was pure waste.
const converterCache = new WeakMap<object, any>();
function converterFor(clazz: any): any {
let instance = converterCache.get(clazz);
if (!instance) {
instance = new clazz();
converterCache.set(clazz, instance);
}
return instance;
}
/** /**
* Records a failure without letting a later constraint overwrite an earlier one that happens * Records a failure without letting a later constraint overwrite an earlier one that happens
* to share a name (two `@Min` rules, or a rule inherited and re-declared). * to share a name (two `@Min` rules, or a rule inherited and re-declared).
@@ -327,10 +469,17 @@ function recordFailure(constraints: { [key: string]: string }, name: string, mes
constraints[`${name}_${suffix}`] = message; constraints[`${name}_${suffix}`] = message;
} }
async function validateInternal(obj: any, ancestors: Set<any>): Promise<ValidationError[]> { async function validateInternal(obj: any, ancestors: Set<any>, depth: number, maxDepth: number): Promise<ValidationError[]> {
const errors: ValidationError[] = []; const errors: ValidationError[] = [];
if (obj === null || obj === undefined || typeof obj !== 'object') return errors; if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
if (depth > maxDepth) {
throw new JsonMappingError(
`Maximum nesting depth of ${maxDepth} exceeded while validating. ` +
`Raise it with the maxDepth option if this structure is legitimate.`
);
}
// A cycle has already been validated further up the stack; re-entering it would never // A cycle has already been validated further up the stack; re-entering it would never
// terminate. Diamonds are still validated on each distinct path. // terminate. Diamonds are still validated on each distinct path.
if (ancestors.has(obj)) return errors; if (ancestors.has(obj)) return errors;
@@ -339,7 +488,7 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
try { try {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) { for (let i = 0; i < obj.length; i++) {
const childErrors = await validateInternal(obj[i], ancestors); const childErrors = await validateInternal(obj[i], ancestors, depth + 1, maxDepth);
if (childErrors.length > 0) { if (childErrors.length > 0) {
errors.push({ errors.push({
property: `[${i}]`, property: `[${i}]`,
@@ -355,32 +504,26 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
const target = prototypeOf(obj); const target = prototypeOf(obj);
if (!target) return errors; if (!target) return errors;
const properties: string[] = metadataStorage.getProperties(target); for (const property of validationPlan(target)) {
const key = property.key;
for (const key of properties) {
const value = obj[key]; const value = obj[key];
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
if (property.condition && !property.condition(obj)) {
continue;
}
// Handle IsOptional
if (property.isOptional && (value === null || value === undefined)) {
continue;
}
const propertyErrors: ValidationError = { const propertyErrors: ValidationError = {
property: key, property: key,
value: value, value: value,
constraints: {} constraints: {}
}; };
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
if (condition && !condition(obj)) {
continue;
}
// Handle IsOptional
const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key);
const isNullOrUndefined = value === null || value === undefined;
if (isOptional && isNullOrUndefined) {
continue;
}
// Check validation constraints
const constraints = collectConstraints(target, key);
const validationArgs: ValidationArguments = { const validationArgs: ValidationArguments = {
value: value, value: value,
object: obj, object: obj,
@@ -388,32 +531,41 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
constraints: [] constraints: []
}; };
for (const constraint of constraints) { for (const constraint of property.constraints) {
validationArgs.constraints = constraint.constraints || []; validationArgs.constraints = constraint.constraints || [];
let isValid = true; let isValid = true;
let failedIndex = -1;
if (constraint.each && Array.isArray(value)) { if (constraint.each && Array.isArray(value)) {
for (const item of value) { // Report which element failed. Previously the index was discarded, so a bad entry
const itemArgs = { ...validationArgs, value: item }; // in a 200-item array produced a message that could not locate it.
if (!(await constraint.validate(item, itemArgs))) { for (let i = 0; i < value.length; i++) {
validationArgs.value = value[i];
if (!(await constraint.validate(value[i], validationArgs))) {
isValid = false; isValid = false;
failedIndex = i;
break; break;
} }
} }
validationArgs.value = value;
} else { } else {
isValid = await constraint.validate(value, validationArgs); isValid = await constraint.validate(value, validationArgs);
} }
if (!isValid) { if (!isValid) {
if (failedIndex >= 0) validationArgs.value = value[failedIndex];
let message = typeof constraint.message === 'function' let message = typeof constraint.message === 'function'
? constraint.message(validationArgs) ? constraint.message(validationArgs)
: constraint.message; : constraint.message;
validationArgs.value = value;
// Only decorate the library's own default wording. A message the caller wrote // Only decorate the library's own default wording. A message the caller wrote
// is reported verbatim — prefixing it produced sentences like // is reported verbatim — prefixing it produced sentences like
// "each element in tags must all be strings". // "each element in tags must all be strings".
if (constraint.each && !constraint.hasCustomMessage) { if (constraint.each && !constraint.hasCustomMessage) {
message = `each element in ${message}`; message = failedIndex >= 0
? `each element in ${message} (failed at index ${failedIndex})`
: `each element in ${message}`;
} }
recordFailure(propertyErrors.constraints, constraint.name, message); recordFailure(propertyErrors.constraints, constraint.name, message);
@@ -421,9 +573,8 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
} }
// Recursive validation // Recursive validation
const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key); if (property.isNested && value !== null && value !== undefined) {
if (isNested && value !== null && value !== undefined) { const nestedErrors = await validateInternal(value, ancestors, depth + 1, maxDepth);
const nestedErrors = await validateInternal(value, ancestors);
if (nestedErrors.length > 0) { if (nestedErrors.length > 0) {
propertyErrors.children = nestedErrors; propertyErrors.children = nestedErrors;
} }
@@ -442,7 +593,11 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
function serializeContext(options?: TransformOptions): SerializeContext { function serializeContext(options?: TransformOptions): SerializeContext {
const resolved = resolveOptions(options); const resolved = resolveOptions(options);
return { naming: resolveNamingStrategy(resolved.namingStrategy) }; return {
naming: resolveNamingStrategy(resolved.namingStrategy),
namingKey: resolved.namingStrategy,
maxDepth: resolved.maxDepth,
};
} }
function deserializeContext(options?: TransformOptions): DeserializeContext { function deserializeContext(options?: TransformOptions): DeserializeContext {
@@ -451,6 +606,7 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
naming: resolveNamingStrategy(resolved.namingStrategy), naming: resolveNamingStrategy(resolved.namingStrategy),
namingKey: resolved.namingStrategy, namingKey: resolved.namingStrategy,
unknownKeys: resolved.unknownKeys, unknownKeys: resolved.unknownKeys,
maxDepth: resolved.maxDepth,
}; };
} }
@@ -461,8 +617,8 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
* @param obj The object to validate * @param obj The object to validate
* @returns Array of validation errors * @returns Array of validation errors
*/ */
export async function validate(obj: any): Promise<ValidationError[]> { export async function validate(obj: any, options?: TransformOptions): Promise<ValidationError[]> {
return validateInternal(obj, new Set()); return validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth);
} }
/** /**
@@ -492,13 +648,13 @@ export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<an
if (obj === null || obj === undefined) return obj; if (obj === null || obj === undefined) return obj;
if (resolveOptions(options).validate) { if (resolveOptions(options).validate) {
const errors = await validate(obj); const errors = await validate(obj, options);
if (errors.length > 0) { if (errors.length > 0) {
throw new JsonValidationError('Validation failed during serialization', errors); throw new JsonValidationError('Validation failed during serialization', errors);
} }
} }
return serialize(obj, new Set(), serializeContext(options)); return serialize(obj, new Set(), serializeContext(options), 0);
} }
/** /**
@@ -524,10 +680,10 @@ export async function toJson<T>(obj: T, options?: TransformOptions): Promise<str
* @returns Class instance * @returns Class instance
*/ */
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> { export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
const instance = await deserialize(clazz, plain, deserializeContext(options)); const instance = await deserialize(clazz, plain, deserializeContext(options), 0);
if (resolveOptions(options).validate) { if (resolveOptions(options).validate) {
const errors = await validate(instance); const errors = await validate(instance, options);
if (errors.length > 0) { if (errors.length > 0) {
throw new JsonValidationError('Validation failed during deserialization', errors); throw new JsonValidationError('Validation failed during deserialization', errors);
} }