✨ feat!: v2 — strongly typed decorators on the TC39 standard
BREAKING CHANGE: cereale moves from legacy `experimentalDecorators` to TC39
standard decorators, which is what makes validation rules type-checked against
the fields they are attached to.
class User {
@IsString() name!: string; // fine
@IsString() age!: number; // Type 'number' is not assignable to 'string'
}
Legacy decorators receive (target: any, key: string) and lose the field type
entirely, so this was impossible in v1. Standard decorators receive
ClassFieldDecoratorContext<This, Value>, which carries it. Rules now checked:
scalar rules against scalar fields; { each: true } against arrays, in both
directions; @JsonType against the field's class; @JsonSerialize/@JsonDeserialize
against the field's type; @IsIn and @IsEnum against the field's value type.
17 tests invoke the real compiler to assert the wrong code stays rejected — a
guarantee nobody checks is one that quietly stops holding.
Positioning follows the capability: validated domain objects, not validated
data. The README now leads with the Zod comparison. Cereale does not infer your
type from a schema — you still write the field type and the rule — but it
guarantees the two cannot disagree, which is what class-validator never offered.
Removed
- metadata-storage.ts and its WeakMap singleton. Metadata lives on
context.metadata now, which also removes the dual ESM/CJS double-singleton
hazard. Inheritance merging becomes structural rather than reconstructed on
every read, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot
reoccur by construction.
- registerDecorator, replaced by defineRule(Class, 'field', constraint).
Unchanged: the engine, options, naming strategies, access control, error
helpers, the sync API, and the performance work. 193 tests pass.
Toolchain note: standard decorators are transformed by tsc and esbuild, but not
yet by oxc. The library builds with tsc and consumers on esbuild/Vite are fine;
Vitest 4 uses oxc, so the test runner needs an esbuild transform plugin. This is
recorded in vitest.config.ts and the README, and is the reason 1.x should stay
available for oxc-based toolchains.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
+65
-1
@@ -5,7 +5,71 @@ 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]
|
## [2.0.0] - 2026-08-04
|
||||||
|
|
||||||
|
**Breaking.** Cereale moves to TC39 standard decorators, which is what makes validation rules
|
||||||
|
type-checked against the fields they are attached to.
|
||||||
|
|
||||||
|
### The headline
|
||||||
|
|
||||||
|
A rule that does not fit its field is now a compile error:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class User {
|
||||||
|
@IsString() name!: string; // fine
|
||||||
|
@IsString() age!: number; // Type 'number' is not assignable to type 'string'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy decorators receive `(target: any, key: string)` and lose the field's type entirely, so
|
||||||
|
this was impossible in v1. Standard decorators receive `ClassFieldDecoratorContext<This, Value>`,
|
||||||
|
which carries it. Checked rules include:
|
||||||
|
|
||||||
|
- scalar rules against scalar fields (`@Min` on a string is rejected)
|
||||||
|
- `{ each: true }` against arrays (`@IsString({ each: true })` demands a `string[]`, and a bare
|
||||||
|
`@IsString()` on a `string[]` is rejected)
|
||||||
|
- `@JsonType(() => Address)` against the field's class
|
||||||
|
- `@JsonSerialize` / `@JsonDeserialize` against the field's type
|
||||||
|
- `@IsIn([...])` and `@IsEnum(E)` against the field's value type
|
||||||
|
|
||||||
|
17 tests invoke the real compiler to assert these stay rejected.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- Remove `"experimentalDecorators": true`; add `"ESNext.Decorators"` to `lib`.
|
||||||
|
- `registerDecorator({ target, propertyName, validator })` is replaced by
|
||||||
|
`defineRule(Class, 'field', constraint)`.
|
||||||
|
- Decorators cannot be applied to `abstract` fields. Declare the field concretely in the base.
|
||||||
|
- Field types may need tightening where a rule narrows them: `@IsIn(['a','b']) x!: string`
|
||||||
|
becomes `x!: 'a' | 'b'`.
|
||||||
|
- `@JsonPolymorphic` takes its base type explicitly to check subtypes:
|
||||||
|
`@JsonPolymorphic<Media>('type', [...])`.
|
||||||
|
- `@ValidateIf` takes the class as a type argument: `@ValidateIf<Movie>(m => ...)`.
|
||||||
|
|
||||||
|
Everything else — the engine, options, naming strategies, access control, error helpers, the
|
||||||
|
sync API — is unchanged.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `metadata-storage.ts` and its WeakMap singleton. Metadata now lives on `context.metadata`,
|
||||||
|
the language's own mechanism, which also removes the dual ESM/CJS double-singleton hazard.
|
||||||
|
- `registerDecorator`, replaced by `defineRule`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Inheritance merging is now structural rather than reconstructed: `context.metadata` inherits
|
||||||
|
through the prototype chain, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot
|
||||||
|
reoccur by construction. Identical inherited rules are still collapsed so re-stating a rule
|
||||||
|
on an override does not double-report.
|
||||||
|
|
||||||
|
### Toolchain
|
||||||
|
|
||||||
|
Standard decorators are transformed by `tsc` and by esbuild; **oxc does not implement them
|
||||||
|
yet**. The library builds with `tsc` and consumers bundling with esbuild or Vite are fine, but
|
||||||
|
the test runner (Vitest 4, which uses oxc) needs an esbuild transform plugin — see
|
||||||
|
`vitest.config.ts`. Projects on an oxc-based toolchain should stay on 1.x for now.
|
||||||
|
|
||||||
|
## [Unreleased] (released as 1.0.0)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,58 @@
|
|||||||
# Cereale
|
# Cereale
|
||||||
|
|
||||||
Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support.
|
**Validated domain objects, not validated data.**
|
||||||
|
|
||||||
|
Cereale maps JSON onto your own classes and gives you back real instances — with your methods,
|
||||||
|
your inheritance, your `instanceof` checks — and type-checks the validation rules against the
|
||||||
|
fields they are attached to. Zero runtime dependencies.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class User {
|
||||||
|
@JsonProperty('display_name')
|
||||||
|
@IsString() @MinLength(2)
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsInt() @Min(0)
|
||||||
|
age!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
age2!: number; // ← compile error: Type 'number' is not assignable to type 'string'
|
||||||
|
|
||||||
|
greet() { return `Hi ${this.displayName}`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = fromJsonSync(User, body); // a real User
|
||||||
|
user.greet(); // your methods are still there
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why not Zod?
|
||||||
|
|
||||||
|
Zod is excellent, and if a plain validated object is what you want, use it. The difference is
|
||||||
|
what you get back:
|
||||||
|
|
||||||
|
| | Zod | Cereale |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Result of parsing | an anonymous object matching a schema | an instance of **your class** |
|
||||||
|
| Methods, getters, inheritance | none — data only | preserved |
|
||||||
|
| Where the type comes from | inferred from the schema | your class declaration |
|
||||||
|
| Rules checked against the type | not applicable — schema *is* the type | **yes, at compile time** |
|
||||||
|
| Bidirectional mapping (renaming both ways) | not the focus | first-class |
|
||||||
|
|
||||||
|
Cereale does not infer your type from a schema, so you still write the field type and the rule.
|
||||||
|
What it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not
|
||||||
|
compile. That is the guarantee class-validator has never offered.
|
||||||
|
|
||||||
|
Reach for Cereale when your domain model is already a class — a NestJS provider, a TypeORM
|
||||||
|
entity, anything with behaviour attached. Reach for Zod when you just want the data.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Spring-like Decorators:** Familiar `@JsonProperty`, `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
|
- **Strongly typed decorators:** a rule that does not fit its field is a compile error.
|
||||||
- **Field-name Mapping:** Map `first_name` to `firstName` per property or with a naming strategy.
|
- **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes.
|
||||||
- **Access Control:** Keep passwords out of responses and server-owned ids out of requests.
|
- **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions.
|
||||||
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
|
- **Access control:** keep passwords out of responses and server-owned ids out of requests.
|
||||||
- **Polymorphism Support:** Native handling of polymorphic types via discriminators.
|
- **Sync and async:** every entry point has a synchronous twin.
|
||||||
- **Integrated Validation:** 50+ validation decorators, applied during mapping or on demand.
|
- **Zero dependencies**, ESM + CJS, Node 20+.
|
||||||
- **Type Safety:** Fully written in TypeScript for excellent developer experience.
|
|
||||||
- **Zero Dependencies:** Extremely lightweight and fast.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -19,109 +60,92 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
|
|||||||
npm install cereale
|
npm install cereale
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable `experimentalDecorators` in your `tsconfig.json`:
|
Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` flag:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
"target": "ES2022",
|
||||||
"target": "ES2022"
|
"lib": ["ESNext", "ESNext.Decorators"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Cereale stores its own metadata, so `reflect-metadata` is not required and
|
Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and
|
||||||
`emitDecoratorMetadata` is not read. Requires Node 20 or later.
|
`emitDecoratorMetadata` is not read.
|
||||||
|
|
||||||
|
> **Toolchain note.** Standard decorators are transformed by `tsc` and by esbuild (so Vite
|
||||||
|
> works). They are **not** yet transformed by oxc; if your toolchain uses it, decorator syntax
|
||||||
|
> will fail to parse. v1.x, which uses legacy decorators, remains available for those setups.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Define your Models
|
### 1. Define your model
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString, IsDate, IsInt, Min, ValidateNested, JsonType,
|
||||||
IsDate,
|
JsonProperty, JsonWriteOnly, JsonPolymorphic,
|
||||||
ValidateNested,
|
|
||||||
JsonSerialize,
|
|
||||||
JsonDeserialize,
|
|
||||||
JsonPolymorphic,
|
|
||||||
JsonSerializer,
|
|
||||||
JsonDeserializer
|
|
||||||
} from 'cereale';
|
} from 'cereale';
|
||||||
|
|
||||||
// Custom Date Serializer
|
class Address {
|
||||||
class DateSerializer implements JsonSerializer<Date, string> {
|
@IsString() street!: string;
|
||||||
serialize(value: Date): string {
|
@IsString() city!: string;
|
||||||
return value.toISOString().split('T')[0]!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DateDeserializer implements JsonDeserializer<string, Date> {
|
format() { return `${this.street}, ${this.city}`; }
|
||||||
deserialize(value: string): Date {
|
|
||||||
return new Date(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
@IsString()
|
// Standard decorators cannot decorate an `abstract` member, so declare it concretely.
|
||||||
abstract type: string;
|
@IsString() type: string = '';
|
||||||
|
@IsString() title: string = '';
|
||||||
@IsString()
|
|
||||||
title: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
type = 'book';
|
@IsString() override type = 'book';
|
||||||
|
@IsString() author!: string;
|
||||||
|
|
||||||
@IsString()
|
@JsonProperty('published_at')
|
||||||
author: string;
|
|
||||||
|
|
||||||
@JsonSerialize(DateSerializer)
|
|
||||||
@JsonDeserialize(DateDeserializer)
|
|
||||||
@IsDate()
|
@IsDate()
|
||||||
publishedAt: Date;
|
publishedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
@IsString()
|
@IsString() name!: string;
|
||||||
name: string;
|
|
||||||
|
@ValidateNested() @JsonType(() => Address)
|
||||||
|
address!: Address; // the class must match the field
|
||||||
|
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@JsonPolymorphic('type', [
|
@JsonPolymorphic<Media>('type', [{ value: Book, name: 'book' }])
|
||||||
{ value: Book, name: 'book' }
|
items!: Media[];
|
||||||
])
|
|
||||||
items: Media[];
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s
|
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s rules as
|
||||||
`@IsString() title` as well as its own rules.
|
well as its own, and re-stating a rule on an override does not report it twice.
|
||||||
|
|
||||||
### 2. Map JSON with Validation
|
### 2. Map JSON, synchronously or not
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale';
|
import { fromJsonSync, toJsonSync, JsonValidationError, flattenErrors } from 'cereale';
|
||||||
|
|
||||||
async function main() {
|
try {
|
||||||
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
|
const library = fromJsonSync(Library, json);
|
||||||
|
library.address.format(); // your method, on a real Address
|
||||||
try {
|
library.items[0] instanceof Book; // true
|
||||||
// Deserialize JSON to Class Instance
|
console.log(toJsonSync(library));
|
||||||
const library = await fromJson(Library, json);
|
} catch (error) {
|
||||||
console.log(library.name); // "Central Library"
|
|
||||||
console.log(library.items[0] instanceof Book); // true
|
|
||||||
|
|
||||||
// Serialize Class Instance back to JSON
|
|
||||||
console.log(await toJson(library));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof JsonValidationError) {
|
if (error instanceof JsonValidationError) {
|
||||||
console.error(flattenErrors(error.errors));
|
console.error(flattenErrors(error.errors));
|
||||||
// { "items[0].title": ["title must be a string"] }
|
// { "items[0].title": ["title must be a string"] }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every function has an async form too (`fromJson`, `toJson`, …) for when a serializer,
|
||||||
|
deserializer or validator of yours returns a Promise.
|
||||||
|
|
||||||
### 3. Modern Web Frameworks (Request Integration)
|
### 3. Modern Web Frameworks (Request Integration)
|
||||||
|
|
||||||
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
||||||
@@ -401,6 +425,13 @@ dominant cost.
|
|||||||
|
|
||||||
## Notes and Limitations
|
## Notes and Limitations
|
||||||
|
|
||||||
|
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
||||||
|
cereale guarantees they agree. If you want the type derived from a schema, that is Zod's
|
||||||
|
model, not this one.
|
||||||
|
- **`abstract` fields cannot be decorated.** Standard decorators do not apply to abstract
|
||||||
|
members. Declare the field concretely in the base class instead.
|
||||||
|
- **oxc does not transform standard decorators yet.** `tsc` and esbuild do.
|
||||||
|
|
||||||
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
||||||
the cycle with `@JsonIgnore()` on the back-reference.
|
the cycle with `@JsonIgnore()` on the back-reference.
|
||||||
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
|
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+7
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.1.0",
|
"version": "2.0.0",
|
||||||
"description": "Spring-like decorators for JSON mapping and validation in TypeScript",
|
"description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"module": "./dist/esm/index.js",
|
"module": "./dist/esm/index.js",
|
||||||
@@ -39,11 +39,13 @@
|
|||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"json",
|
"json",
|
||||||
"mapping",
|
|
||||||
"validation",
|
"validation",
|
||||||
"decorators",
|
"decorators",
|
||||||
"spring",
|
"typescript",
|
||||||
"typescript"
|
"zod-alternative",
|
||||||
|
"dto",
|
||||||
|
"serialization",
|
||||||
|
"class-validator"
|
||||||
],
|
],
|
||||||
"author": "Avalon Vanguard",
|
"author": "Avalon Vanguard",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+14
-25
@@ -13,7 +13,7 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
IsNotIn,
|
IsNotIn,
|
||||||
Validate,
|
Validate,
|
||||||
registerDecorator,
|
defineRule,
|
||||||
JsonType,
|
JsonType,
|
||||||
JsonPolymorphic,
|
JsonPolymorphic,
|
||||||
JsonMapper,
|
JsonMapper,
|
||||||
@@ -244,21 +244,14 @@ describe('Additional Decorators', () => {
|
|||||||
|
|
||||||
describe('registerDecorator', () => {
|
describe('registerDecorator', () => {
|
||||||
it('should register a custom decorator with functional validator', async () => {
|
it('should register a custom decorator with functional validator', async () => {
|
||||||
function IsEven() {
|
|
||||||
return function (object: any, propertyName: string) {
|
|
||||||
registerDecorator({
|
|
||||||
name: 'isEven',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
validator: (value: any) => typeof value === 'number' && value % 2 === 0,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class Test {
|
class Test {
|
||||||
@IsEven()
|
val: number = 0;
|
||||||
val: number;
|
|
||||||
}
|
}
|
||||||
|
defineRule(Test, 'val', {
|
||||||
|
name: 'isEven',
|
||||||
|
validate: (value: any) => typeof value === 'number' && value % 2 === 0,
|
||||||
|
message: 'val must be even',
|
||||||
|
});
|
||||||
|
|
||||||
const t = new Test();
|
const t = new Test();
|
||||||
t.val = 2;
|
t.val = 2;
|
||||||
@@ -271,19 +264,15 @@ describe('Additional Decorators', () => {
|
|||||||
class MyValidator implements ValidatorConstraintInterface {
|
class MyValidator implements ValidatorConstraintInterface {
|
||||||
validate(v: any) { return v === 'ok'; }
|
validate(v: any) { return v === 'ok'; }
|
||||||
}
|
}
|
||||||
function IsOk() {
|
|
||||||
return function (object: any, propertyName: string) {
|
|
||||||
registerDecorator({
|
|
||||||
name: 'isOk',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
validator: MyValidator,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
class Test {
|
class Test {
|
||||||
@IsOk() val: string;
|
val: string = '';
|
||||||
}
|
}
|
||||||
|
const validator = new MyValidator();
|
||||||
|
defineRule(Test, 'val', {
|
||||||
|
name: 'isOk',
|
||||||
|
validate: (v: any) => validator.validate(v),
|
||||||
|
message: 'val must be ok',
|
||||||
|
});
|
||||||
const t = new Test();
|
const t = new Test();
|
||||||
t.val = 'ok';
|
t.val = 'ok';
|
||||||
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
||||||
|
|||||||
+497
-991
File diff suppressed because it is too large
Load Diff
+12
-19
@@ -25,8 +25,7 @@ import {
|
|||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraintInterface,
|
ValidatorConstraintInterface,
|
||||||
ValidationArguments,
|
ValidationArguments,
|
||||||
registerDecorator,
|
Matches,
|
||||||
ValidationOptions
|
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
// --- Custom Validators ---
|
// --- Custom Validators ---
|
||||||
@@ -42,17 +41,8 @@ class IsLongerThan implements ValidatorConstraintInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function IsSlug(options?: ValidationOptions) {
|
/** A custom rule is just a decorator that composes an existing one. */
|
||||||
return function (object: any, propertyName: string) {
|
const IsSlug = () => Matches(/^[a-z0-9-]+$/, { message: 'name must be a lowercase slug' });
|
||||||
registerDecorator({
|
|
||||||
name: 'isSlug',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
...(options ? { options } : {}),
|
|
||||||
validator: (value: any) => typeof value === 'string' && /^[a-z0-9-]+$/.test(value)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Custom Serializers ---
|
// --- Custom Serializers ---
|
||||||
|
|
||||||
@@ -76,12 +66,14 @@ enum Format {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
|
// Standard decorators cannot be applied to an `abstract` member, so the discriminator is a
|
||||||
|
// concrete field the subclasses override.
|
||||||
@IsString()
|
@IsString()
|
||||||
abstract type: string;
|
type: string = '';
|
||||||
|
|
||||||
// Declared once here. Subclasses inherit the rule without restating it.
|
// Declared once here. Subclasses inherit the rule without restating it.
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string;
|
title: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
@@ -111,7 +103,7 @@ class Movie extends Media {
|
|||||||
duration: number;
|
duration: number;
|
||||||
|
|
||||||
// Only checked for films that claim to be part of a series.
|
// Only checked for films that claim to be part of a series.
|
||||||
@ValidateIf((movie: Movie) => movie.duration > 200)
|
@ValidateIf<Movie>(movie => movie.duration > 200)
|
||||||
@IsString()
|
@IsString()
|
||||||
intermissionNote?: string;
|
intermissionNote?: string;
|
||||||
}
|
}
|
||||||
@@ -122,7 +114,7 @@ class Library {
|
|||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsSlug({ message: 'name must be a lowercase slug' })
|
@IsSlug()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@JsonProperty('curator_email')
|
@JsonProperty('curator_email')
|
||||||
@@ -136,11 +128,12 @@ class Library {
|
|||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@JsonPolymorphic('type', [
|
// Naming the base type has the subtype list checked against it.
|
||||||
|
@JsonPolymorphic<Media>('type', [
|
||||||
{ value: Book, name: 'book' },
|
{ value: Book, name: 'book' },
|
||||||
{ value: Movie, name: 'movie' }
|
{ value: Movie, name: 'movie' }
|
||||||
])
|
])
|
||||||
items: Media[];
|
items: Media[] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Execution ---
|
// --- Execution ---
|
||||||
|
|||||||
+11
-12
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
||||||
JsonSerializer, JsonDeserializer, JsonMappingError,
|
JsonSerializer, JsonDeserializer, JsonMappingError,
|
||||||
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
|
defineRule, validate, toInstance, toPlain, configure, resetConfig,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
afterEach(() => resetConfig());
|
afterEach(() => resetConfig());
|
||||||
@@ -20,11 +20,10 @@ describe('plan caching', () => {
|
|||||||
expect(await validate(before)).toEqual([]);
|
expect(await validate(before)).toEqual([]);
|
||||||
|
|
||||||
// Register a rule after the plan has already been built and cached.
|
// Register a rule after the plan has already been built and cached.
|
||||||
registerDecorator({
|
defineRule(Late, 'value', {
|
||||||
name: 'isEven',
|
name: 'isEven',
|
||||||
target: Late,
|
validate: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||||
propertyName: 'value',
|
message: 'value must be even',
|
||||||
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const after = new Late();
|
const after = new Late();
|
||||||
@@ -148,11 +147,11 @@ describe('each: true error reporting', () => {
|
|||||||
it('names the index of the element that failed', async () => {
|
it('names the index of the element that failed', async () => {
|
||||||
class Basket {
|
class Basket {
|
||||||
@IsIn(['a', 'b'], { each: true })
|
@IsIn(['a', 'b'], { each: true })
|
||||||
tags: string[];
|
tags!: ('a' | 'b')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const basket = new Basket();
|
const basket = new Basket();
|
||||||
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
|
basket.tags = ['a', 'b', 'a', 'nope' as 'a', 'b'];
|
||||||
|
|
||||||
const errors = await validate(basket);
|
const errors = await validate(basket);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
@@ -162,10 +161,10 @@ describe('each: true error reporting', () => {
|
|||||||
it('leaves a caller-supplied message untouched', async () => {
|
it('leaves a caller-supplied message untouched', async () => {
|
||||||
class Basket {
|
class Basket {
|
||||||
@IsIn(['a'], { each: true, message: 'bad tag' })
|
@IsIn(['a'], { each: true, message: 'bad tag' })
|
||||||
tags: string[];
|
tags!: 'a'[];
|
||||||
}
|
}
|
||||||
const basket = new Basket();
|
const basket = new Basket();
|
||||||
basket.tags = ['a', 'zzz'];
|
basket.tags = ['a', 'zzz' as 'a'];
|
||||||
|
|
||||||
const errors = await validate(basket);
|
const errors = await validate(basket);
|
||||||
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
||||||
@@ -174,10 +173,10 @@ describe('each: true error reporting', () => {
|
|||||||
it('gives the failing element to a message function, not the whole array', async () => {
|
it('gives the failing element to a message function, not the whole array', async () => {
|
||||||
class Basket {
|
class Basket {
|
||||||
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
|
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
|
||||||
tags: string[];
|
tags!: 'a'[];
|
||||||
}
|
}
|
||||||
const basket = new Basket();
|
const basket = new Basket();
|
||||||
basket.tags = ['a', 'zzz'];
|
basket.tags = ['a', 'zzz' as 'a'];
|
||||||
|
|
||||||
const errors = await validate(basket);
|
const errors = await validate(basket);
|
||||||
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
||||||
@@ -186,7 +185,7 @@ describe('each: true error reporting', () => {
|
|||||||
it('reports nothing when every element passes', async () => {
|
it('reports nothing when every element passes', async () => {
|
||||||
class Basket {
|
class Basket {
|
||||||
@IsIn(['a', 'b'], { each: true })
|
@IsIn(['a', 'b'], { each: true })
|
||||||
tags: string[];
|
tags!: ('a' | 'b')[];
|
||||||
}
|
}
|
||||||
const basket = new Basket();
|
const basket = new Basket();
|
||||||
basket.tags = ['a', 'b'];
|
basket.tags = ['a', 'b'];
|
||||||
|
|||||||
+8
-6
@@ -41,16 +41,18 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
|
|||||||
|
|
||||||
// --- Domain Models ---
|
// --- Domain Models ---
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
|
// Standard decorators cannot be applied to an `abstract` member, so the base declares a
|
||||||
|
// concrete field the subclasses override.
|
||||||
@IsString()
|
@IsString()
|
||||||
abstract type: string;
|
type: string = '';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string;
|
title: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'book';
|
override type: string = 'book';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
author: string;
|
author: string;
|
||||||
@@ -63,7 +65,7 @@ class Book extends Media {
|
|||||||
|
|
||||||
class Movie extends Media {
|
class Movie extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'movie';
|
override type: string = 'movie';
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -162,7 +164,7 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
@ArrayNotEmpty()
|
@ArrayNotEmpty()
|
||||||
@IsIn(['admin', 'user', 'guest'], { each: true })
|
@IsIn(['admin', 'user', 'guest'], { each: true })
|
||||||
roles: string[];
|
roles!: ('admin' | 'user' | 'guest')[];
|
||||||
|
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -224,7 +226,7 @@ describe('JsonMapper', () => {
|
|||||||
user.username = 'johndoe';
|
user.username = 'johndoe';
|
||||||
user.email = 'john@example.com';
|
user.email = 'john@example.com';
|
||||||
user.active = true;
|
user.active = true;
|
||||||
user.roles = ['superadmin'];
|
user.roles = ['superadmin' as 'admin'];
|
||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './interfaces.js';
|
export * from './interfaces.js';
|
||||||
|
export * from './metadata.js';
|
||||||
export * from './naming.js';
|
export * from './naming.js';
|
||||||
export * from './config.js';
|
export * from './config.js';
|
||||||
export * from './decorators.js';
|
export * from './decorators.js';
|
||||||
|
|||||||
@@ -38,3 +38,42 @@ export interface JsonDeserializer<T = any, R = any> {
|
|||||||
export type ClassConstructor<T> = {
|
export type ClassConstructor<T> = {
|
||||||
new (...args: any[]): T;
|
new (...args: any[]): T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decorator that may only be applied to a field whose type is assignable to `Allowed`.
|
||||||
|
*
|
||||||
|
* This is what makes cereale's rules type-checked rather than merely declared. Standard
|
||||||
|
* decorators receive a `ClassFieldDecoratorContext<This, Value>` that carries the field's
|
||||||
|
* declared type, so applying `@IsString()` to a `number` field is a compile error rather
|
||||||
|
* than a runtime surprise:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* class User {
|
||||||
|
* @IsString() name!: string; // fine
|
||||||
|
* @IsString() age!: number; // Type 'number' is not assignable to type 'string'
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `null` and `undefined` are included in the `Allowed` union of every built-in rule so
|
||||||
|
* optional fields (`nickname?: string`) still accept the rule that describes them.
|
||||||
|
*/
|
||||||
|
export type FieldDecorator<Allowed> = <This, Value extends Allowed>(
|
||||||
|
target: undefined,
|
||||||
|
context: ClassFieldDecoratorContext<This, Value>
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** A field holding a string, or nothing. */
|
||||||
|
export type StringField = string | null | undefined;
|
||||||
|
/** A field holding a number, or nothing. */
|
||||||
|
export type NumberField = number | null | undefined;
|
||||||
|
/** A field holding a boolean, or nothing. */
|
||||||
|
export type BooleanField = boolean | null | undefined;
|
||||||
|
/** A field holding a bigint, or nothing. */
|
||||||
|
export type BigIntField = bigint | null | undefined;
|
||||||
|
/** A field holding a Date, or nothing. */
|
||||||
|
export type DateField = Date | null | undefined;
|
||||||
|
/** A field holding an array, or nothing. */
|
||||||
|
export type ArrayField = readonly unknown[] | null | undefined;
|
||||||
|
|
||||||
|
/** The element type of an array field, used by rules that run per element. */
|
||||||
|
export type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
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[]>();
|
|
||||||
|
|
||||||
// Maps a prototype and property name to its metadata
|
|
||||||
// Map<Prototype, Map<PropertyKey, Map<MetadataKey, Value>>>
|
|
||||||
private propertyMetadata = new WeakMap<any, Map<string, Map<string, any>>>();
|
|
||||||
|
|
||||||
// Maps a prototype to its class-level metadata
|
|
||||||
private classMetadata = new WeakMap<any, Map<string, any>>();
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
static getInstance(): MetadataStorage {
|
|
||||||
if (!MetadataStorage.instance) {
|
|
||||||
MetadataStorage.instance = new MetadataStorage();
|
|
||||||
}
|
|
||||||
return MetadataStorage.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
targetMap = new Map();
|
|
||||||
this.propertyMetadata.set(target, targetMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
let propertyMap = targetMap.get(propertyKey);
|
|
||||||
if (!propertyMap) {
|
|
||||||
propertyMap = new Map();
|
|
||||||
targetMap.set(propertyKey, propertyMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
propertyMap.set(key, value);
|
|
||||||
} else {
|
|
||||||
let targetMap = this.classMetadata.get(target);
|
|
||||||
if (!targetMap) {
|
|
||||||
targetMap = new Map();
|
|
||||||
this.classMetadata.set(target, targetMap);
|
|
||||||
}
|
|
||||||
targetMap.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets metadata for a specific property on a target, including from the prototype chain.
|
|
||||||
*/
|
|
||||||
getMetadata(key: string, target: any, propertyKey?: string): any {
|
|
||||||
let current = target;
|
|
||||||
while (current) {
|
|
||||||
const value = this.getOwnMetadata(key, current, propertyKey);
|
|
||||||
if (value !== undefined) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
current = Object.getPrototypeOf(current);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects a metadata value from every level of the prototype chain that defines one.
|
|
||||||
*
|
|
||||||
* Unlike {@link getMetadata}, which stops at the first (most derived) match, this returns
|
|
||||||
* every value found, ordered from the BASE class down to the most derived one. It exists
|
|
||||||
* for metadata that must accumulate across an inheritance chain rather than be overridden —
|
|
||||||
* validation constraints in particular, where a subclass re-decorating an inherited property
|
|
||||||
* must add to the base class's rules instead of silently replacing them.
|
|
||||||
*/
|
|
||||||
getMetadataChain(key: string, target: any, propertyKey?: string): any[] {
|
|
||||||
const chain: any[] = [];
|
|
||||||
let current = target;
|
|
||||||
while (current) {
|
|
||||||
const value = this.getOwnMetadata(key, current, propertyKey);
|
|
||||||
if (value !== undefined) {
|
|
||||||
// Walking derived -> base, so prepend to end up base-first.
|
|
||||||
chain.unshift(value);
|
|
||||||
}
|
|
||||||
current = Object.getPrototypeOf(current);
|
|
||||||
}
|
|
||||||
return chain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets metadata defined directly on the target.
|
|
||||||
*/
|
|
||||||
getOwnMetadata(key: string, target: any, propertyKey?: string): any {
|
|
||||||
if (propertyKey) {
|
|
||||||
return this.propertyMetadata.get(target)?.get(propertyKey)?.get(key);
|
|
||||||
} else {
|
|
||||||
return this.classMetadata.get(target)?.get(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a property for a target.
|
|
||||||
*/
|
|
||||||
registerProperty(target: any, propertyKey: string) {
|
|
||||||
this._version++;
|
|
||||||
let props = this.properties.get(target);
|
|
||||||
if (!props) {
|
|
||||||
props = [];
|
|
||||||
this.properties.set(target, props);
|
|
||||||
}
|
|
||||||
if (!props.includes(propertyKey)) {
|
|
||||||
props.push(propertyKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all registered properties for a target, including from the prototype chain.
|
|
||||||
*/
|
|
||||||
getProperties(target: any): string[] {
|
|
||||||
const allProps = new Set<string>();
|
|
||||||
let current = target;
|
|
||||||
while (current) {
|
|
||||||
const props = this.properties.get(current);
|
|
||||||
if (props) {
|
|
||||||
props.forEach(p => allProps.add(p));
|
|
||||||
}
|
|
||||||
current = Object.getPrototypeOf(current);
|
|
||||||
}
|
|
||||||
return Array.from(allProps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadataStorage = MetadataStorage.getInstance();
|
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
import type { ClassConstructor } from './interfaces.js';
|
||||||
|
|
||||||
|
// TypeScript's standard-decorator emit reads `Symbol.metadata`. Node does not define it yet,
|
||||||
|
// so it is installed here, before any decorated class in the consuming application is
|
||||||
|
// evaluated. `Symbol.for` keeps it identical across duplicate copies of the library, which
|
||||||
|
// the dual ESM/CJS build can otherwise produce.
|
||||||
|
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??=
|
||||||
|
Symbol.for('Symbol.metadata');
|
||||||
|
|
||||||
|
export interface ValidationArguments {
|
||||||
|
value: any;
|
||||||
|
object: any;
|
||||||
|
property: string;
|
||||||
|
constraints: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationOptions {
|
||||||
|
/** Apply the rule to each element of an array rather than to the array itself. */
|
||||||
|
each?: boolean;
|
||||||
|
/** Replaces the built-in message. Reported verbatim — the engine never decorates it. */
|
||||||
|
message?: string | ((args: ValidationArguments) => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrowed form used by the `each: true` decorator overloads. */
|
||||||
|
export interface EachValidationOptions extends ValidationOptions {
|
||||||
|
each: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationConstraint = {
|
||||||
|
name: string;
|
||||||
|
validate: (value: any, args: ValidationArguments) => boolean | Promise<boolean>;
|
||||||
|
message: string | ((args: ValidationArguments) => string);
|
||||||
|
constraints?: any[];
|
||||||
|
each?: boolean;
|
||||||
|
/**
|
||||||
|
* True when the message came from the caller. The engine only decorates its own default
|
||||||
|
* wording with the "each element in ..." prefix.
|
||||||
|
*/
|
||||||
|
hasCustomMessage?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ValidatorConstraintInterface {
|
||||||
|
validate(value: any, args: ValidationArguments): boolean | Promise<boolean>;
|
||||||
|
defaultMessage?(args: ValidationArguments): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which directions a property participates in.
|
||||||
|
*
|
||||||
|
* - `readwrite` (default): mapped both ways.
|
||||||
|
* - `readonly`: written to JSON, never populated from incoming JSON (server-assigned ids).
|
||||||
|
* - `writeonly`: populated from incoming JSON, never written back out (passwords).
|
||||||
|
* - `none`: ignored entirely.
|
||||||
|
*/
|
||||||
|
export type PropertyAccess = 'readwrite' | 'readonly' | 'writeonly' | 'none';
|
||||||
|
|
||||||
|
export interface PolymorphicInfo {
|
||||||
|
discriminator: string;
|
||||||
|
subTypes: { value: ClassConstructor<any>; name: string }[];
|
||||||
|
onUnknown: 'keep' | 'error';
|
||||||
|
fallback?: ClassConstructor<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything cereale knows about one field. */
|
||||||
|
export interface PropertyModel {
|
||||||
|
constraints: ValidationConstraint[];
|
||||||
|
optional?: boolean;
|
||||||
|
nested?: boolean;
|
||||||
|
condition?: (object: any) => boolean;
|
||||||
|
/** Explicit JSON name from `@JsonProperty`. */
|
||||||
|
name?: string;
|
||||||
|
aliases?: string[];
|
||||||
|
access?: PropertyAccess;
|
||||||
|
serializer?: ClassConstructor<any>;
|
||||||
|
deserializer?: ClassConstructor<any>;
|
||||||
|
type?: () => ClassConstructor<any>;
|
||||||
|
polymorphic?: PolymorphicInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClassModel = Record<string, PropertyModel>;
|
||||||
|
|
||||||
|
const MODEL = Symbol.for('cereale.model');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped whenever a model is written. Derived structures (the plans in engine.ts) record the
|
||||||
|
* version they were built from and rebuild if it moves, so programmatic registration after a
|
||||||
|
* class has already been used stays correct.
|
||||||
|
*/
|
||||||
|
let version = 0;
|
||||||
|
|
||||||
|
export function modelVersion(): number {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the model owned by this class, creating it if necessary.
|
||||||
|
*
|
||||||
|
* `context.metadata` inherits from the base class's metadata through the prototype chain, so
|
||||||
|
* a subclass starts out seeing everything its base declared. Writing requires an own copy —
|
||||||
|
* otherwise a subclass would mutate its parent — and the inherited entries are deep-copied so
|
||||||
|
* that a subclass re-decorating an inherited field *adds to* the base's rules instead of
|
||||||
|
* replacing them. That inheritance-merging behaviour is structural here; the previous
|
||||||
|
* WeakMap-based storage had to reconstruct it by walking prototypes on every read.
|
||||||
|
*/
|
||||||
|
function ownModel(metadata: DecoratorMetadata): ClassModel {
|
||||||
|
if (!Object.hasOwn(metadata, MODEL)) {
|
||||||
|
const inherited = (metadata as Record<symbol, ClassModel | undefined>)[MODEL];
|
||||||
|
const own: ClassModel = {};
|
||||||
|
for (const [key, property] of Object.entries(inherited ?? {})) {
|
||||||
|
own[key] = { ...property, constraints: [...property.constraints] };
|
||||||
|
}
|
||||||
|
(metadata as Record<symbol, ClassModel>)[MODEL] = own;
|
||||||
|
}
|
||||||
|
return (metadata as Record<symbol, ClassModel>)[MODEL]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns (creating if needed) the model entry for one field. */
|
||||||
|
export function propertyModel(metadata: DecoratorMetadata, property: string): PropertyModel {
|
||||||
|
version++;
|
||||||
|
const model = ownModel(metadata);
|
||||||
|
return (model[property] ??= { constraints: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends a validation rule to a field, honouring `each` and a caller-supplied message. */
|
||||||
|
export function addConstraint(
|
||||||
|
metadata: DecoratorMetadata,
|
||||||
|
property: string,
|
||||||
|
constraint: ValidationConstraint,
|
||||||
|
options?: ValidationOptions
|
||||||
|
): void {
|
||||||
|
if (options?.each) constraint.each = true;
|
||||||
|
if (options?.message) {
|
||||||
|
constraint.message = options.message;
|
||||||
|
constraint.hasCustomMessage = true;
|
||||||
|
}
|
||||||
|
propertyModel(metadata, property).constraints.push(constraint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the model declared on a class. Returns an empty model for undecorated classes. */
|
||||||
|
export function modelOf(clazz: unknown): ClassModel {
|
||||||
|
if (typeof clazz !== 'function') return {};
|
||||||
|
const metadata = (clazz as { [Symbol.metadata]?: DecoratorMetadata })[Symbol.metadata];
|
||||||
|
return (metadata as Record<symbol, ClassModel> | undefined)?.[MODEL] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the model that applies to an instance.
|
||||||
|
*
|
||||||
|
* Guarded rather than reading `obj.constructor` directly: null-prototype objects have no
|
||||||
|
* constructor, and an instance whose `constructor` property has been overwritten would lie.
|
||||||
|
*/
|
||||||
|
export function modelOfInstance(obj: object): ClassModel {
|
||||||
|
const prototype = Object.getPrototypeOf(obj);
|
||||||
|
if (!prototype) return {};
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor');
|
||||||
|
return modelOf(descriptor?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a rule on a class from outside a decorator.
|
||||||
|
*
|
||||||
|
* The escape hatch for rules that cannot be expressed at the declaration site — built from
|
||||||
|
* configuration, say. Prefer decorators, which are type-checked against the field.
|
||||||
|
*/
|
||||||
|
export function defineRule<T>(
|
||||||
|
clazz: ClassConstructor<T>,
|
||||||
|
property: keyof T & string,
|
||||||
|
constraint: ValidationConstraint,
|
||||||
|
options?: ValidationOptions
|
||||||
|
): void {
|
||||||
|
const holder = clazz as unknown as { [Symbol.metadata]?: DecoratorMetadata };
|
||||||
|
holder[Symbol.metadata] ??= Object.create(null) as DecoratorMetadata;
|
||||||
|
addConstraint(holder[Symbol.metadata]!, property, constraint, options);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ describe('regressions', () => {
|
|||||||
}
|
}
|
||||||
class Sub extends Base {
|
class Sub extends Base {
|
||||||
@IsString()
|
@IsString()
|
||||||
declare name: string;
|
override name: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = new Sub();
|
const s = new Sub();
|
||||||
@@ -35,7 +35,7 @@ describe('regressions', () => {
|
|||||||
it('enforces base constraints that the subclass never restates', async () => {
|
it('enforces base constraints that the subclass never restates', async () => {
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string;
|
title: string = '';
|
||||||
}
|
}
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -57,7 +57,7 @@ describe('regressions', () => {
|
|||||||
}
|
}
|
||||||
class Sub extends Base {
|
class Sub extends Base {
|
||||||
@IsString()
|
@IsString()
|
||||||
declare type: string;
|
override type: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = new Sub();
|
const s = new Sub();
|
||||||
|
|||||||
+2
-2
@@ -383,14 +383,14 @@ describe('write-only redaction in validation errors', () => {
|
|||||||
it('does not redact a value that merely sits next to a secret', async () => {
|
it('does not redact a value that merely sits next to a secret', async () => {
|
||||||
class Form {
|
class Form {
|
||||||
@IsIn(['a', 'b'])
|
@IsIn(['a', 'b'])
|
||||||
choice: string;
|
choice!: 'a' | 'b';
|
||||||
|
|
||||||
@JsonWriteOnly()
|
@JsonWriteOnly()
|
||||||
@IsString()
|
@IsString()
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
const form = new Form();
|
const form = new Form();
|
||||||
form.choice = 'zzz';
|
form.choice = 'zzz' as 'a';
|
||||||
form.token = 'secret-token';
|
form.token = 'secret-token';
|
||||||
|
|
||||||
const errors = await validate(form);
|
const errors = await validate(form);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The headline guarantee of v2 is that a rule cannot be attached to a field it does not fit.
|
||||||
|
* That is a *compile-time* claim, so asserting it needs the compiler: each case below is
|
||||||
|
* type-checked in isolation and must fail.
|
||||||
|
*
|
||||||
|
* These run the real `tsc`, so they are slower than the rest of the suite — but a guarantee
|
||||||
|
* nobody checks is a guarantee that quietly stops holding.
|
||||||
|
*/
|
||||||
|
const TSC = resolve('node_modules/.bin/tsc');
|
||||||
|
const SRC = resolve('src/index.js').replace(/\.js$/, '');
|
||||||
|
|
||||||
|
function typeCheck(body: string): { ok: boolean; output: string } {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'cereale-types-'));
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext',
|
||||||
|
// DOM supplies URL/Request, which the library's own signatures reference. A real
|
||||||
|
// consumer has these from either DOM or @types/node.
|
||||||
|
lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true,
|
||||||
|
strictPropertyInitialization: false, noEmit: true, skipLibCheck: true,
|
||||||
|
},
|
||||||
|
include: ['case.ts'],
|
||||||
|
}));
|
||||||
|
writeFileSync(join(dir, 'case.ts'), `import {\n IsString, IsInt, Min, MinLength, IsArray, ArrayMinSize, ArrayUnique,\n IsDate, MinDate, IsBoolean, IsIn, IsEnum, JsonType, JsonSerialize,\n JsonDeserialize, JsonSerializer, JsonDeserializer,\n} from ${JSON.stringify(SRC + '.js')};\n\n${body}\n`);
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, [TSC, '-p', dir], { stdio: 'pipe' });
|
||||||
|
return { ok: true, output: '' };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { ok: false, output: String(error.stdout ?? '') + String(error.stderr ?? '') };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compiles = (body: string) => {
|
||||||
|
const result = typeCheck(body);
|
||||||
|
if (!result.ok) throw new Error(`expected this to compile but it did not:\n${result.output}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejects = (body: string) => {
|
||||||
|
const result = typeCheck(body);
|
||||||
|
expect(result.ok, 'expected a compile error, but it compiled').toBe(false);
|
||||||
|
return result.output;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('rules are checked against the field type', () => {
|
||||||
|
it('accepts rules that match the field', () => {
|
||||||
|
compiles(`
|
||||||
|
class Ok {
|
||||||
|
@IsString() @MinLength(2) name!: string;
|
||||||
|
@IsInt() @Min(0) age!: number;
|
||||||
|
@IsBoolean() active!: boolean;
|
||||||
|
@IsDate() @MinDate(new Date(0)) when!: Date;
|
||||||
|
@IsArray() @ArrayMinSize(1) tags!: string[];
|
||||||
|
@IsString() nickname?: string;
|
||||||
|
@IsString() maybe!: string | null;
|
||||||
|
}
|
||||||
|
void Ok;
|
||||||
|
`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a string rule on a number field', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString() age!: number } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a number rule on a string field', () => {
|
||||||
|
expect(rejects(`class Bad { @Min(0) label!: string } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an array rule on a non-array field', () => {
|
||||||
|
expect(rejects(`class Bad { @ArrayMinSize(1) count!: number } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a date rule on a string field', () => {
|
||||||
|
expect(rejects(`class Bad { @MinDate(new Date(0)) when!: string } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('each: true moves the rule onto the elements', () => {
|
||||||
|
it('accepts a matching array field', () => {
|
||||||
|
compiles(`class Ok { @IsString({ each: true }) tags!: string[] } void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects each:true on a scalar field', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString({ each: true }) tag!: string } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a bare rule on an array field', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString() tags!: string[] } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an element-type mismatch', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString({ each: true }) nums!: number[] } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nested types and converters are checked', () => {
|
||||||
|
const shapes = `
|
||||||
|
class Address { street!: string }
|
||||||
|
class Money { amount!: number }
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('accepts the matching class', () => {
|
||||||
|
compiles(`${shapes}
|
||||||
|
class Ok {
|
||||||
|
@JsonType(() => Address) ship!: Address;
|
||||||
|
@JsonType(() => Address) history!: Address[];
|
||||||
|
}
|
||||||
|
void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an unrelated class', () => {
|
||||||
|
expect(rejects(`${shapes}
|
||||||
|
class Bad { @JsonType(() => Money) ship!: Address }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a serializer whose input does not match the field', () => {
|
||||||
|
expect(rejects(`
|
||||||
|
class DateToString implements JsonSerializer<Date, string> {
|
||||||
|
serialize(v: Date) { return v.toISOString(); }
|
||||||
|
}
|
||||||
|
class Bad { @JsonSerialize(DateToString) name!: string }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a deserializer whose output does not match the field', () => {
|
||||||
|
expect(rejects(`
|
||||||
|
class StringToDate implements JsonDeserializer<string, Date> {
|
||||||
|
deserialize(v: string) { return new Date(v); }
|
||||||
|
}
|
||||||
|
class Bad { @JsonDeserialize(StringToDate) name!: string }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('membership rules narrow the field', () => {
|
||||||
|
it('accepts a field typed as the allowed union', () => {
|
||||||
|
compiles(`class Ok { @IsIn(['a', 'b']) choice!: 'a' | 'b' } void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a field that cannot hold the allowed values', () => {
|
||||||
|
expect(rejects(`class Bad { @IsIn(['a', 'b']) choice!: number } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an enum rule on a mismatched field', () => {
|
||||||
|
expect(rejects(`
|
||||||
|
enum Role { Admin = 'admin' }
|
||||||
|
class Bad { @IsEnum(Role) role!: number }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('accepts an enum rule on the enum field', () => {
|
||||||
|
compiles(`
|
||||||
|
enum Role { Admin = 'admin', User = 'user' }
|
||||||
|
class Ok { @IsEnum(Role) role!: Role }
|
||||||
|
void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
+71
-105
@@ -1,6 +1,9 @@
|
|||||||
import { ClassConstructor } from './interfaces.js';
|
import { ClassConstructor } from './interfaces.js';
|
||||||
import { METADATA_KEYS, PropertyAccess, ValidationConstraint, ValidationArguments } from './decorators.js';
|
import {
|
||||||
import { metadataStorage } from './metadata-storage.js';
|
modelOf, modelOfInstance, modelVersion,
|
||||||
|
type ClassModel, type PropertyAccess, type PropertyModel,
|
||||||
|
type ValidationArguments, type ValidationConstraint,
|
||||||
|
} from './metadata.js';
|
||||||
import { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
|
import { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
|
||||||
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
|
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
|
||||||
|
|
||||||
@@ -62,25 +65,13 @@ interface DeserializeContext {
|
|||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function accessOf(model: ClassModel, key: string): PropertyAccess {
|
||||||
* Resolves the metadata lookup target for a value.
|
return model[key]?.access ?? 'readwrite';
|
||||||
*
|
|
||||||
* `Object.getPrototypeOf` rather than `obj.constructor.prototype`: the latter throws on
|
|
||||||
* null-prototype objects (which have no `constructor`) and lies for instances whose
|
|
||||||
* `constructor` property has been overwritten.
|
|
||||||
*/
|
|
||||||
function prototypeOf(obj: any): any {
|
|
||||||
return Object.getPrototypeOf(obj) ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function accessOf(target: any, key: string): PropertyAccess {
|
|
||||||
return (target ? metadataStorage.getMetadata(METADATA_KEYS.ACCESS, target, key) : undefined) ?? 'readwrite';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The name this property takes in JSON: an explicit @JsonProperty, else the naming strategy. */
|
/** The name this property takes in JSON: an explicit @JsonProperty, else the naming strategy. */
|
||||||
function outboundName(target: any, key: string, naming: NamingStrategyFn): string {
|
function outboundName(model: ClassModel, key: string, naming: NamingStrategyFn): string {
|
||||||
const explicit = target ? metadataStorage.getMetadata(METADATA_KEYS.NAME, target, key) : undefined;
|
return model[key]?.name ?? naming(key);
|
||||||
return explicit ?? naming(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-property serialization facts, resolved once instead of per call. */
|
/** Per-property serialization facts, resolved once instead of per call. */
|
||||||
@@ -93,7 +84,7 @@ interface OutboundProperty {
|
|||||||
serializer?: any;
|
serializer?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
||||||
@@ -101,16 +92,11 @@ const outboundCache = new WeakMap<object, { version: number; byStrategy: Map<unk
|
|||||||
* Serialization walks the runtime keys of each object, so undeclared properties turn up here
|
* 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.
|
* too; they memoize just as well, since the naming strategy is deterministic.
|
||||||
*/
|
*/
|
||||||
function outboundFor(target: any, key: string, ctx: SerializeContext): OutboundProperty {
|
function outboundFor(model: ClassModel, key: string, ctx: SerializeContext): OutboundProperty {
|
||||||
if (!target) {
|
let entry = outboundCache.get(model);
|
||||||
// Null-prototype object: nothing is declared, so there is nothing to cache against.
|
if (!entry || entry.version !== modelVersion()) {
|
||||||
return { name: ctx.naming(key), skip: false };
|
entry = { version: modelVersion(), byStrategy: new Map() };
|
||||||
}
|
outboundCache.set(model, entry);
|
||||||
|
|
||||||
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);
|
let byKey = entry.byStrategy.get(ctx.namingKey);
|
||||||
@@ -121,10 +107,10 @@ function outboundFor(target: any, key: string, ctx: SerializeContext): OutboundP
|
|||||||
|
|
||||||
let resolved = byKey.get(key);
|
let resolved = byKey.get(key);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
const access = accessOf(target, key);
|
const access = accessOf(model, key);
|
||||||
const serializer = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
|
const serializer = model[key]?.serializer;
|
||||||
resolved = {
|
resolved = {
|
||||||
name: outboundName(target, key, ctx.naming),
|
name: outboundName(model, key, ctx.naming),
|
||||||
// `writeonly` is accepted on input but must never be echoed back out.
|
// `writeonly` is accepted on input but must never be echoed back out.
|
||||||
skip: access === 'none' || access === 'writeonly',
|
skip: access === 'none' || access === 'writeonly',
|
||||||
...(serializer ? { serializer } : {}),
|
...(serializer ? { serializer } : {}),
|
||||||
@@ -157,7 +143,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, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
const inboundCache = new WeakMap<ClassModel, { 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.
|
||||||
@@ -167,11 +153,11 @@ const inboundCache = new WeakMap<object, { version: number; byStrategy: Map<unkn
|
|||||||
* property therefore stops the old name from being silently accepted — add `@JsonAlias` to
|
* property therefore stops the old name from being silently accepted — add `@JsonAlias` to
|
||||||
* keep it working for older clients.
|
* keep it working for older clients.
|
||||||
*/
|
*/
|
||||||
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
function inboundNameMap(model: ClassModel, ctx: DeserializeContext): InboundNames {
|
||||||
let entry = inboundCache.get(target);
|
let entry = inboundCache.get(model);
|
||||||
if (!entry || entry.version !== metadataStorage.version) {
|
if (!entry || entry.version !== modelVersion()) {
|
||||||
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
entry = { version: modelVersion(), byStrategy: new Map() };
|
||||||
inboundCache.set(target, entry);
|
inboundCache.set(model, entry);
|
||||||
}
|
}
|
||||||
const cached = entry.byStrategy.get(ctx.namingKey);
|
const cached = entry.byStrategy.get(ctx.namingKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -191,13 +177,10 @@ function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
|||||||
accept.set(external, key);
|
accept.set(external, key);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of metadataStorage.getProperties(target)) {
|
for (const [key, property] of Object.entries(model)) {
|
||||||
const names = [
|
const names = [outboundName(model, key, ctx.naming), ...(property.aliases ?? [])];
|
||||||
outboundName(target, key, ctx.naming),
|
|
||||||
...(metadataStorage.getMetadata(METADATA_KEYS.ALIASES, target, key) || []),
|
|
||||||
];
|
|
||||||
|
|
||||||
const access = accessOf(target, key);
|
const access = accessOf(model, key);
|
||||||
if (access === 'none' || access === 'readonly') {
|
if (access === 'none' || access === 'readonly') {
|
||||||
for (const name of names) blocked.add(name);
|
for (const name of names) blocked.add(name);
|
||||||
continue;
|
continue;
|
||||||
@@ -205,14 +188,11 @@ 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);
|
if (property.deserializer || property.polymorphic || property.type) {
|
||||||
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, {
|
props.set(key, {
|
||||||
...(deserializer ? { deserializer } : {}),
|
...(property.deserializer ? { deserializer: property.deserializer } : {}),
|
||||||
...(polymorphic ? { polymorphic } : {}),
|
...(property.polymorphic ? { polymorphic: property.polymorphic } : {}),
|
||||||
...(typeFn ? { typeFn } : {}),
|
...(property.type ? { typeFn: property.type } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,11 +275,11 @@ function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth:
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = prototypeOf(obj);
|
const model = modelOfInstance(obj);
|
||||||
|
|
||||||
const result: any = {};
|
const result: any = {};
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
const property = outboundFor(target, key, ctx);
|
const property = outboundFor(model, key, ctx);
|
||||||
if (property.skip) continue;
|
if (property.skip) continue;
|
||||||
|
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
@@ -347,8 +327,7 @@ function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deserialize
|
|||||||
if (typeof plain !== 'object') return plain;
|
if (typeof plain !== 'object') return plain;
|
||||||
|
|
||||||
const instance = new clazz();
|
const instance = new clazz();
|
||||||
const target = clazz.prototype;
|
const inbound = inboundNameMap(modelOf(clazz), ctx);
|
||||||
const inbound = inboundNameMap(target, ctx);
|
|
||||||
|
|
||||||
for (const incoming of Object.keys(plain)) {
|
for (const incoming of Object.keys(plain)) {
|
||||||
if (FORBIDDEN_KEYS.has(incoming)) continue;
|
if (FORBIDDEN_KEYS.has(incoming)) continue;
|
||||||
@@ -428,36 +407,6 @@ function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deserialize
|
|||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects the validation constraints that apply to a property, merged across the whole
|
|
||||||
* prototype chain.
|
|
||||||
*
|
|
||||||
* A subclass that re-decorates an inherited property registers its constraints against its
|
|
||||||
* own prototype. Reading only the nearest set would silently drop everything the base class
|
|
||||||
* declared, so the chain is flattened base-first. Constraints that are genuinely identical
|
|
||||||
* (same rule, same fixed message) are collapsed so that re-stating `@IsString()` on an
|
|
||||||
* override does not report the same failure twice; anything with a computed message — custom
|
|
||||||
* validators in particular — is always kept.
|
|
||||||
*/
|
|
||||||
function collectConstraints(target: any, key: string): ValidationConstraint[] {
|
|
||||||
const levels: ValidationConstraint[][] = metadataStorage.getMetadataChain(METADATA_KEYS.VALIDATION, target, key);
|
|
||||||
const merged: ValidationConstraint[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const level of levels) {
|
|
||||||
for (const constraint of level) {
|
|
||||||
if (typeof constraint.message === 'string') {
|
|
||||||
const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}`;
|
|
||||||
if (seen.has(identity)) continue;
|
|
||||||
seen.add(identity);
|
|
||||||
}
|
|
||||||
merged.push(constraint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the validator needs to know about one property, resolved once.
|
* Everything the validator needs to know about one property, resolved once.
|
||||||
*/
|
*/
|
||||||
@@ -476,33 +425,53 @@ interface CachedPlan {
|
|||||||
plan: PropertyPlan[];
|
plan: PropertyPlan[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolving a class's validation rules means walking its prototype chain several times per
|
// Turning a class model into a per-property plan is cheap, but doing it on every call was
|
||||||
// property, per call — which profiling showed to be roughly half of all validation time,
|
// measurably not: profiling showed roughly half of all validation time re-deriving answers
|
||||||
// recomputing an answer that cannot change. The result is memoized per prototype and
|
// that cannot change. Plans are memoized per model and invalidated by the model version.
|
||||||
// invalidated by MetadataStorage's version counter, so metadata registered late still works.
|
const planCache = new WeakMap<ClassModel, CachedPlan>();
|
||||||
const planCache = new WeakMap<object, CachedPlan>();
|
|
||||||
|
|
||||||
function validationPlan(target: any): PropertyPlan[] {
|
/**
|
||||||
const cached = planCache.get(target);
|
* Collapses rules that are genuinely identical.
|
||||||
if (cached && cached.version === metadataStorage.version) {
|
*
|
||||||
|
* Inheritance is structural here: a subclass's model starts as a copy of its base's, so
|
||||||
|
* re-stating `@IsString()` on an override would otherwise report the same failure twice.
|
||||||
|
* Only rules with a fixed message are compared — anything with a computed message, custom
|
||||||
|
* validators in particular, is always kept, since two of them can differ while looking alike.
|
||||||
|
*/
|
||||||
|
function dedupe(constraints: ValidationConstraint[]): ValidationConstraint[] {
|
||||||
|
const kept: ValidationConstraint[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const constraint of constraints) {
|
||||||
|
if (typeof constraint.message === 'string') {
|
||||||
|
const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}|${constraint.each ?? false}`;
|
||||||
|
if (seen.has(identity)) continue;
|
||||||
|
seen.add(identity);
|
||||||
|
}
|
||||||
|
kept.push(constraint);
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validationPlan(model: ClassModel): PropertyPlan[] {
|
||||||
|
const cached = planCache.get(model);
|
||||||
|
if (cached && cached.version === modelVersion()) {
|
||||||
return cached.plan;
|
return cached.plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan: PropertyPlan[] = [];
|
const plan: PropertyPlan[] = [];
|
||||||
for (const key of metadataStorage.getProperties(target)) {
|
for (const [key, property] of Object.entries(model) as [string, PropertyModel][]) {
|
||||||
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
|
const access = property.access ?? 'readwrite';
|
||||||
const access = accessOf(target, key);
|
|
||||||
plan.push({
|
plan.push({
|
||||||
key,
|
key,
|
||||||
constraints: collectConstraints(target, key),
|
constraints: dedupe(property.constraints),
|
||||||
isOptional: !!metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key),
|
isOptional: !!property.optional,
|
||||||
isNested: !!metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key),
|
isNested: !!property.nested,
|
||||||
redact: access === 'writeonly' || access === 'none',
|
redact: access === 'writeonly' || access === 'none',
|
||||||
...(condition ? { condition } : {}),
|
...(property.condition ? { condition: property.condition } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
planCache.set(target, { version: metadataStorage.version, plan });
|
planCache.set(model, { version: modelVersion(), plan });
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,10 +613,7 @@ function validateInternal(obj: any, ancestors: Set<any>, depth: number, maxDepth
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = prototypeOf(obj);
|
for (const property of validationPlan(modelOfInstance(obj))) {
|
||||||
if (!target) return errors;
|
|
||||||
|
|
||||||
for (const property of validationPlan(target)) {
|
|
||||||
const key = property.key;
|
const key = property.key;
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
|
|
||||||
|
|||||||
+22
-5
@@ -11,12 +11,29 @@ import {
|
|||||||
validate, toInstance,
|
validate, toInstance,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
/** Builds a one-property class, assigns `value`, and returns the constraint keys that failed. */
|
/**
|
||||||
async function check(decorate: (target: any, key: string) => void, value: any): Promise<string[]> {
|
* Applies a decorator to a synthetic one-field class and reports which rules failed.
|
||||||
|
*
|
||||||
|
* Standard decorators are invoked as `(undefined, context)` rather than against a prototype,
|
||||||
|
* so the context is built by hand here. Only `name` and `metadata` are read by the library;
|
||||||
|
* the rest satisfies the shape.
|
||||||
|
*/
|
||||||
|
async function check(decorator: any, value: any): Promise<string[]> {
|
||||||
|
const metadata = Object.create(null) as DecoratorMetadata;
|
||||||
|
decorator(undefined, {
|
||||||
|
kind: 'field',
|
||||||
|
name: 'val',
|
||||||
|
static: false,
|
||||||
|
private: false,
|
||||||
|
metadata,
|
||||||
|
access: { has: () => true, get: (o: any) => o.val, set: (o: any, v: any) => { o.val = v; } },
|
||||||
|
addInitializer: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
class Subject {
|
class Subject {
|
||||||
val: any;
|
val: any;
|
||||||
}
|
}
|
||||||
decorate(Subject.prototype, 'val');
|
(Subject as any)[Symbol.metadata] = metadata;
|
||||||
|
|
||||||
const subject = new Subject();
|
const subject = new Subject();
|
||||||
subject.val = value;
|
subject.val = value;
|
||||||
@@ -232,9 +249,9 @@ describe('arrays', () => {
|
|||||||
describe('@ValidateIf', () => {
|
describe('@ValidateIf', () => {
|
||||||
class Payment {
|
class Payment {
|
||||||
@IsIn(['card', 'invoice'])
|
@IsIn(['card', 'invoice'])
|
||||||
method: string;
|
method!: 'card' | 'invoice';
|
||||||
|
|
||||||
@ValidateIf(o => o.method === 'card')
|
@ValidateIf<Payment>(o => o.method === 'card')
|
||||||
@IsString()
|
@IsString()
|
||||||
cardNumber?: string;
|
cardNumber?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -8,7 +8,7 @@
|
|||||||
// Environment Settings
|
// Environment Settings
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"target": "ES2025",
|
"target": "ES2025",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|
||||||
// Other Outputs
|
// Other Outputs
|
||||||
@@ -33,7 +33,9 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
"experimentalDecorators": true
|
// NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what
|
||||||
|
// gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of
|
||||||
|
// rules against field types. The two decorator systems cannot coexist in one program.
|
||||||
},
|
},
|
||||||
// NOTE: test files are deliberately included here so that `npm run type-check`
|
// NOTE: test files are deliberately included here so that `npm run type-check`
|
||||||
// covers them. The two build configs exclude them (and the demo) from `dist`.
|
// covers them. The two build configs exclude them (and the demo) from `dist`.
|
||||||
|
|||||||
+30
-7
@@ -1,13 +1,36 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { transform } from 'esbuild';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transpiles test sources with esbuild instead of oxc.
|
||||||
|
*
|
||||||
|
* Vitest 4 transforms with oxc, which does not yet implement the TC39 standard decorator
|
||||||
|
* transform — it leaves the syntax in place and Node then fails to parse it, reporting
|
||||||
|
* "0 test" rather than an error. esbuild and tsc both implement it, so the library's own
|
||||||
|
* build (`tsc`) and consumers bundling with esbuild or Vite are unaffected; only the test
|
||||||
|
* runner needs this. Remove it once oxc gains standard-decorator support.
|
||||||
|
*/
|
||||||
|
function standardDecorators() {
|
||||||
|
return {
|
||||||
|
name: 'cereale:standard-decorators',
|
||||||
|
enforce: 'pre' as const,
|
||||||
|
async transform(code: string, id: string) {
|
||||||
|
if (!/\.ts$/.test(id) || id.includes('node_modules')) return null;
|
||||||
|
const result = await transform(code, {
|
||||||
|
loader: 'ts',
|
||||||
|
target: 'es2022',
|
||||||
|
sourcefile: id,
|
||||||
|
sourcemap: true,
|
||||||
|
// Standard semantics, not the legacy ones: the library reads context.metadata.
|
||||||
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||||
|
});
|
||||||
|
return { code: result.code, map: result.map };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// Vitest 4 transpiles with oxc, which does not read `experimentalDecorators`
|
plugins: [standardDecorators()],
|
||||||
// out of tsconfig.json for files the tsconfig does not `include`. Without this
|
|
||||||
// the decorator syntax in the test files fails to parse and every suite is
|
|
||||||
// silently reported as "0 test".
|
|
||||||
oxc: {
|
|
||||||
decorator: { legacy: true },
|
|
||||||
},
|
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
Reference in New Issue
Block a user