Files
cereale/src/mapping.test.ts
T
Claude 6e458fdd43 ⚡ 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
2026-08-04 11:36:53 +00:00

450 lines
13 KiB
TypeScript

import { describe, it, expect, afterEach } from 'vitest';
import {
IsString, IsInt, IsOptional, ValidateNested, Min,
JsonProperty, JsonAlias, JsonIgnore, JsonReadOnly, JsonWriteOnly, JsonType,
JsonMappingError, JsonValidationError,
toPlain, toJson, toInstance, fromJson, validate, validateOrReject,
configure, resetConfig, getConfig,
flattenErrors, formatErrors, collectErrorMessages,
resolveNamingStrategy,
} from './index.js';
afterEach(() => resetConfig());
describe('@JsonProperty', () => {
class User {
@JsonProperty('first_name')
@IsString()
firstName: string;
@JsonProperty('last_name')
@IsString()
lastName: string;
}
it('renames on the way out', async () => {
const u = new User();
u.firstName = 'Ada';
u.lastName = 'Lovelace';
await expect(toPlain(u)).resolves.toEqual({ first_name: 'Ada', last_name: 'Lovelace' });
});
it('renames on the way in', async () => {
const u = await fromJson(User, '{"first_name":"Ada","last_name":"Lovelace"}');
expect(u.firstName).toBe('Ada');
expect(u.lastName).toBe('Lovelace');
});
it('round-trips', async () => {
const json = '{"first_name":"Ada","last_name":"Lovelace"}';
expect(await toJson(await fromJson(User, json))).toBe(json);
});
it('no longer accepts the raw property name once renamed', async () => {
const u = await toInstance(
User,
{ firstName: 'Ada', last_name: 'L' },
{ unknownKeys: 'strip', validate: false }
);
expect(u.firstName).toBeUndefined();
expect(u.lastName).toBe('L');
});
it('rejects two properties claiming the same JSON name', async () => {
class Clash {
@JsonProperty('name')
a: string;
@JsonProperty('name')
b: string;
}
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(JsonMappingError);
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(/both map to the JSON name/);
});
});
describe('@JsonAlias', () => {
class Person {
@JsonProperty('surname')
@JsonAlias('last_name', 'lastName')
@IsString()
surname: string;
}
it('accepts every alias on input', async () => {
for (const key of ['surname', 'last_name', 'lastName']) {
const p = await toInstance(Person, { [key]: 'Hopper' });
expect(p.surname).toBe('Hopper');
}
});
it('never emits an alias on output', async () => {
const p = new Person();
p.surname = 'Hopper';
await expect(toPlain(p)).resolves.toEqual({ surname: 'Hopper' });
});
});
describe('access control decorators', () => {
it('@JsonIgnore drops the property in both directions', async () => {
class Secretive {
@IsString()
name: string;
@JsonIgnore()
internalNote: string;
}
const s = new Secretive();
s.name = 'x';
s.internalNote = 'do not leak';
await expect(toPlain(s)).resolves.toEqual({ name: 'x' });
const parsed = await toInstance(Secretive, { name: 'x', internalNote: 'injected' });
expect(parsed.internalNote).toBeUndefined();
});
it('@JsonWriteOnly accepts input but never echoes it back', async () => {
class Credentials {
@IsString()
email: string;
@JsonWriteOnly()
@IsString()
password: string;
}
const c = await toInstance(Credentials, { email: 'a@b.com', password: 'hunter2' });
expect(c.password).toBe('hunter2');
await expect(toPlain(c)).resolves.toEqual({ email: 'a@b.com' });
});
it('@JsonReadOnly is emitted but cannot be set by a client', async () => {
class Record {
@JsonReadOnly()
id: number;
@IsString()
title: string;
}
const r = await toInstance(Record, { id: 999, title: 'hello' });
expect(r.id).toBeUndefined();
r.id = 1;
await expect(toPlain(r)).resolves.toEqual({ id: 1, title: 'hello' });
});
it('@JsonReadOnly is not resurrected by the default unknownKeys policy', async () => {
class Record {
@JsonProperty('identifier')
@JsonReadOnly()
id: number;
@IsString()
title: string;
}
const r = await toInstance(Record, { identifier: 999, title: 't' }, { unknownKeys: 'allow' });
expect(r.id).toBeUndefined();
expect((r as any).identifier).toBeUndefined();
});
});
describe('naming strategies', () => {
class Account {
@IsString()
accountHolderName: string;
@IsInt()
balanceInCents: number;
}
it('snake_case both ways', async () => {
const a = new Account();
a.accountHolderName = 'Ada';
a.balanceInCents = 100;
const plain = await toPlain(a, { namingStrategy: 'snake_case' });
expect(plain).toEqual({ account_holder_name: 'Ada', balance_in_cents: 100 });
const back = await toInstance(Account, plain, { namingStrategy: 'snake_case' });
expect(back.accountHolderName).toBe('Ada');
expect(back.balanceInCents).toBe(100);
});
it('kebab-case, SCREAMING_SNAKE_CASE and PascalCase', async () => {
const a = new Account();
a.accountHolderName = 'Ada';
a.balanceInCents = 1;
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
.resolves.toEqual({ 'account-holder-name': 'Ada', 'balance-in-cents': 1 });
await expect(toPlain(a, { namingStrategy: 'SCREAMING_SNAKE_CASE' }))
.resolves.toEqual({ ACCOUNT_HOLDER_NAME: 'Ada', BALANCE_IN_CENTS: 1 });
await expect(toPlain(a, { namingStrategy: 'PascalCase' }))
.resolves.toEqual({ AccountHolderName: 'Ada', BalanceInCents: 1 });
});
it('accepts a custom function', async () => {
const a = new Account();
a.accountHolderName = 'Ada';
a.balanceInCents = 1;
await expect(toPlain(a, { namingStrategy: (k) => `x_${k}` }))
.resolves.toEqual({ x_accountHolderName: 'Ada', x_balanceInCents: 1 });
});
it('@JsonProperty wins over the naming strategy', async () => {
class Mixed {
@JsonProperty('EXPLICIT')
someField: string;
otherField: string;
}
const m = new Mixed();
m.someField = 'a';
m.otherField = 'b';
await expect(toPlain(m, { namingStrategy: 'snake_case' }))
.resolves.toEqual({ EXPLICIT: 'a', other_field: 'b' });
});
it('splits acronyms the way a reader expects', () => {
const snake = resolveNamingStrategy('snake_case');
expect(snake('parseHTTPResponse')).toBe('parse_http_response');
expect(snake('firstName')).toBe('first_name');
expect(snake('id')).toBe('id');
expect(snake('already_snake')).toBe('already_snake');
const camel = resolveNamingStrategy('camelCase');
expect(camel('first_name')).toBe('firstName');
});
it('rejects an unknown strategy name', () => {
expect(() => resolveNamingStrategy('shouty' as any)).toThrow(/Unknown naming strategy/);
});
it('applies to nested objects too', async () => {
class Inner {
@IsString()
innerValue: string;
}
class Outer {
@ValidateNested()
@JsonType(() => Inner)
outerChild: Inner;
}
const parsed = await toInstance(
Outer,
{ outer_child: { inner_value: 'v' } },
{ namingStrategy: 'snake_case' }
);
expect(parsed.outerChild).toBeInstanceOf(Inner);
expect(parsed.outerChild.innerValue).toBe('v');
});
});
describe('configure()', () => {
class Account {
@IsString()
accountHolderName: string;
}
it('sets a library-wide default', async () => {
configure({ namingStrategy: 'snake_case' });
const a = new Account();
a.accountHolderName = 'Ada';
await expect(toPlain(a)).resolves.toEqual({ account_holder_name: 'Ada' });
expect(getConfig().namingStrategy).toBe('snake_case');
});
it('is overridden by per-call options', async () => {
configure({ namingStrategy: 'snake_case' });
const a = new Account();
a.accountHolderName = 'Ada';
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
.resolves.toEqual({ 'account-holder-name': 'Ada' });
});
it('resetConfig() restores the defaults', async () => {
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
resetConfig();
expect(getConfig()).toEqual({
namingStrategy: 'identity',
unknownKeys: 'allow',
validate: true,
maxDepth: 64,
});
});
});
describe('unknownKeys policy', () => {
class Dto {
@IsString()
known: string;
}
it('allow (default) copies unknown keys through', async () => {
const d = await toInstance(Dto, { known: 'a', extra: 'b' });
expect((d as any).extra).toBe('b');
});
it('strip drops them', async () => {
const d = await toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'strip' });
expect((d as any).extra).toBeUndefined();
expect(d.known).toBe('a');
});
it('error rejects the payload and names the offending key', async () => {
await expect(toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'error' }))
.rejects.toThrow(/Unknown property "extra"/);
});
it('never lets __proto__ through, whatever the policy', async () => {
for (const unknownKeys of ['allow', 'strip', 'error'] as const) {
const d = await toInstance(Dto, JSON.parse('{"known":"a","__proto__":{"x":1}}'), { unknownKeys });
expect(Object.getPrototypeOf(d)).toBe(Dto.prototype);
expect(({} as any).x).toBeUndefined();
}
});
});
describe('validate option', () => {
class Strict {
@IsString()
name: string;
@IsOptional()
@IsInt()
@Min(0)
age?: number;
}
it('throws by default', async () => {
await expect(toInstance(Strict, { name: 123 })).rejects.toThrow(JsonValidationError);
});
it('maps without validating when told to', async () => {
const s = await toInstance(Strict, { name: 123 }, { validate: false });
expect(s).toBeInstanceOf(Strict);
expect(s.name).toBe(123 as any);
});
it('skips validation on the way out too', async () => {
const s = new Strict();
s.name = 123 as any;
await expect(toPlain(s, { validate: false })).resolves.toEqual({ name: 123 });
});
it('validateOrReject throws, validate returns', async () => {
const s = new Strict();
s.name = 123 as any;
await expect(validateOrReject(s)).rejects.toThrow(JsonValidationError);
await expect(validate(s)).resolves.toHaveLength(1);
});
});
describe('error helpers', () => {
class Item {
@IsInt()
@Min(1)
qty: number;
}
class Order {
@IsString()
reference: string;
@ValidateNested()
@JsonType(() => Item)
items: Item[];
}
const buildFailing = () => {
const bad = new Item();
bad.qty = -5;
const o = new Order();
o.reference = 42 as any;
o.items = [bad];
return o;
};
it('flattenErrors produces dotted paths with array indices', async () => {
const errors = await validate(buildFailing());
const flat = flattenErrors(errors);
expect(flat['reference']).toEqual(['reference must be a string']);
expect(flat['items[0].qty']).toEqual(['qty must be at least 1']);
});
it('formatErrors renders one line per failure', async () => {
const errors = await validate(buildFailing());
const text = formatErrors(errors);
expect(text).toContain('reference: reference must be a string');
expect(text).toContain('items[0].qty: qty must be at least 1');
});
it('collectErrorMessages returns just the messages', async () => {
const messages = collectErrorMessages(await validate(buildFailing()));
expect(messages).toHaveLength(2);
expect(messages).toContain('qty must be at least 1');
});
it('returns an empty result for a valid object', async () => {
const o = new Order();
o.reference = 'ok';
o.items = [];
expect(flattenErrors(await validate(o))).toEqual({});
expect(formatErrors(await validate(o))).toBe('');
});
});
describe('a realistic API payload', () => {
it('maps a snake_case request and answers without the secret', async () => {
class SignUp {
@JsonReadOnly()
id: number;
@JsonProperty('email_address')
@IsString()
email: string;
@JsonWriteOnly()
@IsString()
password: string;
@IsString()
displayName: string;
}
const body = JSON.stringify({
id: 999, // client must not be able to set this
email_address: 'ada@example.com',
password: 'hunter2',
display_name: 'Ada',
});
const signUp = await fromJson(SignUp, body, { namingStrategy: 'snake_case' });
expect(signUp.id).toBeUndefined();
expect(signUp.email).toBe('ada@example.com');
expect(signUp.password).toBe('hunter2');
expect(signUp.displayName).toBe('Ada');
signUp.id = 1;
const response = await toJson(signUp, { namingStrategy: 'snake_case' });
expect(JSON.parse(response)).toEqual({
id: 1,
email_address: 'ada@example.com',
display_name: 'Ada',
});
expect(response).not.toContain('hunter2');
});
});