⚡ 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:
@@ -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/),
|
||||
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
|
||||
|
||||
The first release with a working test suite. Everything below the "Fixed" heading was
|
||||
|
||||
@@ -203,6 +203,7 @@ defaults for the whole application. Per-call options win.
|
||||
| `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. |
|
||||
| `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. |
|
||||
| `maxDepth` | `number` | `64` | Nesting depth before a `JsonMappingError` is raised, bounding hostile payloads. |
|
||||
|
||||
```typescript
|
||||
// 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>`).
|
||||
- `toInstanceArray(clazz, plain, options?)`: Same, for an array (`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`.
|
||||
- `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
|
||||
|
||||
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cereale",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cereale",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
+11
-1
@@ -27,15 +27,24 @@ export interface TransformOptions {
|
||||
|
||||
/** What to do with incoming keys that match no declared property. Deserialization only. */
|
||||
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}. */
|
||||
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate'>;
|
||||
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate' | 'maxDepth'>;
|
||||
|
||||
const DEFAULTS: Required<GlobalOptions> = {
|
||||
namingStrategy: 'identity',
|
||||
unknownKeys: 'allow',
|
||||
validate: true,
|
||||
maxDepth: 64,
|
||||
};
|
||||
|
||||
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
|
||||
@@ -71,5 +80,6 @@ export function resolveOptions(options?: TransformOptions): Required<GlobalOptio
|
||||
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
|
||||
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
|
||||
validate: options.validate ?? globalOptions.validate,
|
||||
maxDepth: options.maxDepth ?? globalOptions.maxDepth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -275,7 +275,12 @@ describe('configure()', () => {
|
||||
it('resetConfig() restores the defaults', async () => {
|
||||
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
|
||||
resetConfig();
|
||||
expect(getConfig()).toEqual({ namingStrategy: 'identity', unknownKeys: 'allow', validate: true });
|
||||
expect(getConfig()).toEqual({
|
||||
namingStrategy: 'identity',
|
||||
unknownKeys: 'allow',
|
||||
validate: true,
|
||||
maxDepth: 64,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+17
-1
@@ -1,6 +1,20 @@
|
||||
export class 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
|
||||
private properties = new WeakMap<any, string[]>();
|
||||
|
||||
@@ -24,6 +38,7 @@ export class MetadataStorage {
|
||||
* Defines metadata for a specific property on a target.
|
||||
*/
|
||||
defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
|
||||
this._version++;
|
||||
if (propertyKey) {
|
||||
let targetMap = this.propertyMetadata.get(target);
|
||||
if (!targetMap) {
|
||||
@@ -101,6 +116,7 @@ export class MetadataStorage {
|
||||
* Registers a property for a target.
|
||||
*/
|
||||
registerProperty(target: any, propertyKey: string) {
|
||||
this._version++;
|
||||
let props = this.properties.get(target);
|
||||
if (!props) {
|
||||
props = [];
|
||||
|
||||
+222
-66
@@ -45,12 +45,15 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
interface SerializeContext {
|
||||
naming: NamingStrategyFn;
|
||||
namingKey: unknown;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
interface DeserializeContext {
|
||||
naming: NamingStrategyFn;
|
||||
namingKey: unknown;
|
||||
unknownKeys: UnknownKeyPolicy;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +77,69 @@ function outboundName(target: any, key: string, naming: NamingStrategyFn): strin
|
||||
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 {
|
||||
/** JSON name -> property key, for properties this payload is allowed to set. */
|
||||
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
|
||||
* (`@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
|
||||
// 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.
|
||||
@@ -99,16 +162,17 @@ const inboundCache = new WeakMap<object, Map<unknown, InboundNames>>();
|
||||
* keep it working for older clients.
|
||||
*/
|
||||
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
||||
let byStrategy = inboundCache.get(target);
|
||||
if (!byStrategy) {
|
||||
byStrategy = new Map();
|
||||
inboundCache.set(target, byStrategy);
|
||||
let entry = inboundCache.get(target);
|
||||
if (!entry || entry.version !== metadataStorage.version) {
|
||||
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
||||
inboundCache.set(target, entry);
|
||||
}
|
||||
const cached = byStrategy.get(ctx.namingKey);
|
||||
const cached = entry.byStrategy.get(ctx.namingKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const accept = new Map<string, string>();
|
||||
const blocked = new Set<string>();
|
||||
const props = new Map<string, InboundProperty>();
|
||||
|
||||
const claim = (external: string, key: string) => {
|
||||
const owner = accept.get(external);
|
||||
@@ -134,18 +198,36 @@ function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
||||
}
|
||||
|
||||
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 };
|
||||
byStrategy.set(ctx.namingKey, result);
|
||||
const result = { accept, blocked, props };
|
||||
entry.byStrategy.set(ctx.namingKey, 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') {
|
||||
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) {
|
||||
return obj.toISOString();
|
||||
}
|
||||
@@ -162,7 +244,7 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
||||
if (Array.isArray(obj)) {
|
||||
const out: any[] = [];
|
||||
for (const item of obj) {
|
||||
out.push(await serialize(item, ancestors, ctx));
|
||||
out.push(await serialize(item, ancestors, ctx, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -171,21 +253,17 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
||||
|
||||
const result: any = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const access = accessOf(target, key);
|
||||
// `writeonly` is accepted on input but must never be echoed back out.
|
||||
if (access === 'none' || access === 'writeonly') continue;
|
||||
const property = outboundFor(target, key, ctx);
|
||||
if (property.skip) continue;
|
||||
|
||||
const value = obj[key];
|
||||
const name = outboundName(target, key, ctx.naming);
|
||||
|
||||
// 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.
|
||||
const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
|
||||
if (serializerCls && value !== null && value !== undefined) {
|
||||
const serializer = new serializerCls();
|
||||
result[name] = await serializer.serialize(value);
|
||||
if (property.serializer && value !== null && value !== undefined) {
|
||||
result[property.name] = await converterFor(property.serializer).serialize(value);
|
||||
} 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 (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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -235,24 +320,24 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
|
||||
|
||||
const value = plain[incoming];
|
||||
|
||||
const property = inbound.props.get(key);
|
||||
|
||||
// Custom Deserializer
|
||||
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
||||
if (deserializerCls) {
|
||||
const deserializer = new deserializerCls();
|
||||
instance[key as keyof T] = await deserializer.deserialize(value);
|
||||
if (property?.deserializer) {
|
||||
instance[key as keyof T] = await converterFor(property.deserializer).deserialize(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Polymorphic
|
||||
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
|
||||
const poly = property?.polymorphic;
|
||||
if (poly && value !== null && value !== undefined) {
|
||||
const { discriminator, subTypes, onUnknown, fallback } = poly;
|
||||
|
||||
const resolve = async (item: any): Promise<any> => {
|
||||
if (item === null || item === undefined || typeof item !== 'object') return item;
|
||||
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
|
||||
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx);
|
||||
if (fallback) return deserialize(fallback, item, ctx);
|
||||
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx, depth + 1);
|
||||
if (fallback) return deserialize(fallback, item, ctx, depth + 1);
|
||||
if (onUnknown === 'error') {
|
||||
throw new JsonMappingError(
|
||||
`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
|
||||
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
||||
const typeFn = property?.typeFn;
|
||||
if (typeFn && value !== null && value !== undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -313,6 +398,63 @@ function collectConstraints(target: any, key: string): ValidationConstraint[] {
|
||||
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
|
||||
* 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;
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
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
|
||||
// terminate. Diamonds are still validated on each distinct path.
|
||||
if (ancestors.has(obj)) return errors;
|
||||
@@ -339,7 +488,7 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
try {
|
||||
if (Array.isArray(obj)) {
|
||||
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) {
|
||||
errors.push({
|
||||
property: `[${i}]`,
|
||||
@@ -355,32 +504,26 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
const target = prototypeOf(obj);
|
||||
if (!target) return errors;
|
||||
|
||||
const properties: string[] = metadataStorage.getProperties(target);
|
||||
|
||||
for (const key of properties) {
|
||||
for (const property of validationPlan(target)) {
|
||||
const key = property.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 = {
|
||||
property: key,
|
||||
value: value,
|
||||
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 = {
|
||||
value: value,
|
||||
object: obj,
|
||||
@@ -388,32 +531,41 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
constraints: []
|
||||
};
|
||||
|
||||
for (const constraint of constraints) {
|
||||
for (const constraint of property.constraints) {
|
||||
validationArgs.constraints = constraint.constraints || [];
|
||||
|
||||
let isValid = true;
|
||||
let failedIndex = -1;
|
||||
if (constraint.each && Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const itemArgs = { ...validationArgs, value: item };
|
||||
if (!(await constraint.validate(item, itemArgs))) {
|
||||
// Report which element failed. Previously the index was discarded, so a bad entry
|
||||
// in a 200-item array produced a message that could not locate it.
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
validationArgs.value = value[i];
|
||||
if (!(await constraint.validate(value[i], validationArgs))) {
|
||||
isValid = false;
|
||||
failedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
validationArgs.value = value;
|
||||
} else {
|
||||
isValid = await constraint.validate(value, validationArgs);
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
if (failedIndex >= 0) validationArgs.value = value[failedIndex];
|
||||
let message = typeof constraint.message === 'function'
|
||||
? constraint.message(validationArgs)
|
||||
: constraint.message;
|
||||
validationArgs.value = value;
|
||||
|
||||
// Only decorate the library's own default wording. A message the caller wrote
|
||||
// is reported verbatim — prefixing it produced sentences like
|
||||
// "each element in tags must all be strings".
|
||||
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);
|
||||
@@ -421,9 +573,8 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
}
|
||||
|
||||
// Recursive validation
|
||||
const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key);
|
||||
if (isNested && value !== null && value !== undefined) {
|
||||
const nestedErrors = await validateInternal(value, ancestors);
|
||||
if (property.isNested && value !== null && value !== undefined) {
|
||||
const nestedErrors = await validateInternal(value, ancestors, depth + 1, maxDepth);
|
||||
if (nestedErrors.length > 0) {
|
||||
propertyErrors.children = nestedErrors;
|
||||
}
|
||||
@@ -442,7 +593,11 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
||||
|
||||
function serializeContext(options?: TransformOptions): SerializeContext {
|
||||
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 {
|
||||
@@ -451,6 +606,7 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
|
||||
naming: resolveNamingStrategy(resolved.namingStrategy),
|
||||
namingKey: resolved.namingStrategy,
|
||||
unknownKeys: resolved.unknownKeys,
|
||||
maxDepth: resolved.maxDepth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,8 +617,8 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
|
||||
* @param obj The object to validate
|
||||
* @returns Array of validation errors
|
||||
*/
|
||||
export async function validate(obj: any): Promise<ValidationError[]> {
|
||||
return validateInternal(obj, new Set());
|
||||
export async function validate(obj: any, options?: TransformOptions): Promise<ValidationError[]> {
|
||||
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 (resolveOptions(options).validate) {
|
||||
const errors = await validate(obj);
|
||||
const errors = await validate(obj, options);
|
||||
if (errors.length > 0) {
|
||||
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
|
||||
*/
|
||||
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) {
|
||||
const errors = await validate(instance);
|
||||
const errors = await validate(instance, options);
|
||||
if (errors.length > 0) {
|
||||
throw new JsonValidationError('Validation failed during deserialization', errors);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user