🐛 fix: repair eight correctness defects in the mapping and validation engines
Each fix is pinned by a regression test in src/regressions.test.ts describing the
old behaviour.
- Inheritance dropped base-class rules. A subclass re-decorating an inherited
property registered its constraints against its own prototype, and validate()
read only the nearest set, so everything the base declared was silently lost.
Constraints are now merged down the whole prototype chain, base first, with
genuinely identical rules collapsed so restating @IsString() on an override
does not double-report. The library's own example.ts was affected: Media's
@IsString() title had never been enforced for Book.
- Circular references exhausted the heap. serialize() recursed forever, taking
8 GB and the process with it; it now tracks ancestors and raises a
JsonMappingError naming the cause. Diamonds still serialize. validate() skips
back-edges instead of recursing.
- @Matches with a g or y flag was stateful: RegExp.test advances lastIndex, so
validating the same value twice gave different answers. Those flags are
stripped.
- An unmatched @JsonPolymorphic discriminator silently dropped the value — the
single-object branch fell through without assigning. The raw value is now
preserved, with { onUnknown: 'error' } and { fallback } to choose otherwise.
- Custom @JsonSerialize serializers ran on null/undefined, crashing on any unset
optional property. They now only see real values.
- serialize() read obj.constructor.prototype, which throws for null-prototype
objects; both engines now agree on Object.getPrototypeOf.
- __proto__, constructor and prototype arriving in untrusted JSON were copied
onto the instance, detaching it from its own class. They are dropped.
- The "each element in ..." prefix was glued onto caller-supplied messages, and
two rules sharing a name overwrote each other so only one failure surfaced.
Also adds toInstanceArray/fromJsonArray, since toInstance and fromJson accept
arrays at runtime but type the result as T, and reports a non-JSON request body
in fromRequest as a JsonMappingError rather than a raw SyntaxError.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
+55
-8
@@ -9,6 +9,7 @@ export const METADATA_KEYS = {
|
||||
DESERIALIZER: 'cereale:deserializer',
|
||||
POLYMORPHIC: 'cereale:polymorphic',
|
||||
IS_OPTIONAL: 'cereale:optional',
|
||||
NESTED: 'cereale:nested',
|
||||
};
|
||||
|
||||
export interface ValidationArguments {
|
||||
@@ -29,6 +30,12 @@ export type ValidationConstraint = {
|
||||
message: string | ((args: ValidationArguments) => string);
|
||||
constraints?: any[];
|
||||
each?: boolean;
|
||||
/**
|
||||
* True when the message came from the user via `ValidationOptions.message`.
|
||||
* The engine only decorates default messages with the "each element in ..." prefix;
|
||||
* a message the user wrote is reported exactly as written.
|
||||
*/
|
||||
hasCustomMessage?: boolean;
|
||||
};
|
||||
|
||||
export interface ValidatorConstraintInterface {
|
||||
@@ -55,9 +62,10 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
|
||||
}
|
||||
if (options.message) {
|
||||
constraint.message = options.message;
|
||||
constraint.hasCustomMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const constraints: ValidationConstraint[] = metadataStorage.getOwnMetadata(METADATA_KEYS.VALIDATION, target, propertyKey) || [];
|
||||
constraints.push(constraint);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.VALIDATION, constraints, target, propertyKey);
|
||||
@@ -98,14 +106,34 @@ export function JsonType(typeFunction: () => ClassConstructor<any>) {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PolymorphicOptions {
|
||||
/**
|
||||
* What to do when the discriminator value matches no registered subtype.
|
||||
* - `keep` (default): pass the raw value through untouched.
|
||||
* - `error`: throw a {@link JsonMappingError} naming the unknown discriminator value.
|
||||
*/
|
||||
onUnknown?: 'keep' | 'error';
|
||||
/** Subtype to use when the discriminator matches nothing. Takes precedence over `onUnknown`. */
|
||||
fallback?: ClassConstructor<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])
|
||||
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[], options?: PolymorphicOptions)
|
||||
* Defines polymorphic behavior for a property.
|
||||
*/
|
||||
export function JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) {
|
||||
export function JsonPolymorphic(
|
||||
discriminator: string,
|
||||
subTypes: { value: ClassConstructor<any>, name: string }[],
|
||||
options?: PolymorphicOptions
|
||||
) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.POLYMORPHIC, { discriminator, subTypes }, target, propertyKey);
|
||||
metadataStorage.defineMetadata(
|
||||
METADATA_KEYS.POLYMORPHIC,
|
||||
{ discriminator, subTypes, onUnknown: options?.onUnknown ?? 'keep', fallback: options?.fallback },
|
||||
target,
|
||||
propertyKey
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,10 +361,16 @@ export function IsUrl(options?: ValidationOptions) {
|
||||
* @Matches(pattern: RegExp)
|
||||
*/
|
||||
export function Matches(pattern: RegExp, options?: ValidationOptions) {
|
||||
// A `g` or `y` flag makes RegExp.prototype.test stateful: it advances lastIndex on a
|
||||
// match and resumes from there on the next call, so validating the same value twice
|
||||
// yields different answers. Validation must be a pure predicate, so drop those flags.
|
||||
const stateless = pattern.flags.includes('g') || pattern.flags.includes('y')
|
||||
? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
|
||||
: pattern;
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'matches',
|
||||
validate: (v) => typeof v === 'string' && pattern.test(v),
|
||||
validate: (v) => typeof v === 'string' && stateless.test(v),
|
||||
message: `${propertyKey} must match ${pattern} regular expression`,
|
||||
constraints: [pattern]
|
||||
}, options);
|
||||
@@ -439,13 +473,26 @@ export function IsDate(options?: ValidationOptions) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @ValidateNested()
|
||||
* @ValidateNested(options?: ValidationOptions)
|
||||
* Recursively validates the value of this property.
|
||||
*
|
||||
* `{ each: true }` documents that the property holds a collection; nested validation
|
||||
* already recurses into arrays, but passing `each` additionally asserts that the value
|
||||
* really is an array.
|
||||
*/
|
||||
export function ValidateNested() {
|
||||
export function ValidateNested(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
// This is a marker for recursive validation
|
||||
metadataStorage.defineMetadata('cereale:nested', true, target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.NESTED, true, target, propertyKey);
|
||||
|
||||
if (options?.each) {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'nestedEach',
|
||||
validate: (v) => Array.isArray(v),
|
||||
message: `${propertyKey} must be an array`
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user