Skip to content

Commit d500255

Browse files
committed
Handles stackable @expose decorator.
Fixes the issue typestack#378.
1 parent f3ed75b commit d500255

File tree

9 files changed

+549
-131
lines changed

9 files changed

+549
-131
lines changed

.eslintrc.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ rules:
3030
'@typescript-eslint/no-unsafe-assignment': off
3131
'@typescript-eslint/no-unsafe-call': off
3232
'@typescript-eslint/no-unsafe-member-access': off
33-
'@typescript-eslint/explicit-module-boundary-types': off
33+
'@typescript-eslint/explicit-module-boundary-types': off
34+
'@typescript-eslint/no-non-null-assertion': off

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Source code is available [here](https://github.com/pleerock/class-transformer-de
3030
- [Providing more than one type option](#providing-more-than-one-type-option)
3131
- [Exposing getters and method return values](#exposing-getters-and-method-return-values)
3232
- [Exposing properties with different names](#exposing-properties-with-different-names)
33+
- [Exposing a property more than once](#exposing-a-property-more-than-one-time)
3334
- [Skipping specific properties](#skipping-specific-properties)
3435
- [Skipping depend of operation](#skipping-depend-of-operation)
3536
- [Skipping all properties of the class](#skipping-all-properties-of-the-class)
@@ -493,6 +494,34 @@ export class User {
493494
}
494495
```
495496

497+
## Exposing a property more than once[](#table-of-contents)
498+
499+
You can stack `@Expose` decorator more than once if you want.
500+
501+
```typescript
502+
import { Expose } from 'class-transformer';
503+
504+
export class User {
505+
id: number;
506+
507+
@Expose({ toClassOnly: true, groups: ['create', 'update'] })
508+
@Expose({ toPlainOnly: true })
509+
firstName: string;
510+
511+
@Expose({ toClassOnly: true, since: 2, name: 'lastName' })
512+
@Expose({ toClassOnly: true, since: 1, until: 2, name: 'lastname' })
513+
@Expose({ toClassOnly: true, until: 1, name: 'surname' })
514+
@Expose({ toPlainOnly: true })
515+
lastName: string;
516+
517+
@Transform(({ value }) => (value ? '*'.repeat(value.length) : value))
518+
@Expose({ name: 'password', since: 2 })
519+
@Expose({ name: 'secretKey', toClassOnly: true, since: 1, groups: ['create', 'update'] })
520+
@Expose({ name: 'secretKey', toClassOnly: true, until: 1, groups: ['create'] })
521+
password: string;
522+
}
523+
```
524+
496525
## Skipping specific properties[](#table-of-contents)
497526

498527
Sometimes you want to skip some properties during transformation.

src/MetadataStorage.ts

Lines changed: 145 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TypeMetadata, ExposeMetadata, ExcludeMetadata, TransformMetadata } from './interfaces';
22
import { TransformationType } from './enums';
3+
import { checkVersion, flatten, onlyUnique } from './utils';
34

45
/**
56
* Storage all library metadata.
@@ -11,10 +12,32 @@ export class MetadataStorage {
1112

1213
private _typeMetadatas = new Map<Function, Map<string, TypeMetadata>>();
1314
private _transformMetadatas = new Map<Function, Map<string, TransformMetadata[]>>();
14-
private _exposeMetadatas = new Map<Function, Map<string, ExposeMetadata>>();
15-
private _excludeMetadatas = new Map<Function, Map<string, ExcludeMetadata>>();
15+
private _exposeMetadatas = new Map<Function, Map<string, ExposeMetadata[]>>();
16+
private _excludeMetadatas = new Map<Function, Map<string, ExcludeMetadata[]>>();
1617
private _ancestorsMap = new Map<Function, Function[]>();
1718

19+
// -------------------------------------------------------------------------
20+
// Static Methods
21+
// -------------------------------------------------------------------------
22+
23+
private static checkMetadataTransformationType<
24+
T extends { options?: { toClassOnly?: boolean; toPlainOnly?: boolean } }
25+
>(transformationType: TransformationType, metadata: T): boolean {
26+
if (!metadata.options) return true;
27+
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;
28+
29+
if (metadata.options.toClassOnly === true) {
30+
return (
31+
transformationType === TransformationType.CLASS_TO_CLASS ||
32+
transformationType === TransformationType.PLAIN_TO_CLASS
33+
);
34+
}
35+
if (metadata.options.toPlainOnly === true) {
36+
return transformationType === TransformationType.CLASS_TO_PLAIN;
37+
}
38+
return true;
39+
}
40+
1841
// -------------------------------------------------------------------------
1942
// Adder Methods
2043
// -------------------------------------------------------------------------
@@ -37,17 +60,88 @@ export class MetadataStorage {
3760
}
3861

3962
addExposeMetadata(metadata: ExposeMetadata): void {
63+
const { toPlainOnly, toClassOnly, name = metadata.propertyName } = metadata.options || {};
64+
65+
/**
66+
* check if toPlainOnly and toClassOnly used correctly.
67+
*/
68+
if (
69+
metadata.propertyName &&
70+
!(toPlainOnly === true || toClassOnly === true || (toClassOnly === undefined && toPlainOnly === undefined))
71+
) {
72+
throw Error(
73+
`${metadata.propertyName}: At least one of "toPlainOnly" and "toClassOnly" options must be "true" or both must be "undefined"`
74+
);
75+
}
76+
4077
if (!this._exposeMetadatas.has(metadata.target)) {
41-
this._exposeMetadatas.set(metadata.target, new Map<string, ExposeMetadata>());
78+
this._exposeMetadatas.set(metadata.target, new Map<string, ExposeMetadata[]>());
4279
}
43-
this._exposeMetadatas.get(metadata.target).set(metadata.propertyName, metadata);
80+
if (!this._exposeMetadatas.get(metadata.target).has(metadata.propertyName)) {
81+
this._exposeMetadatas.get(metadata.target).set(metadata.propertyName, []);
82+
}
83+
const exposeArray = this._exposeMetadatas.get(metadata.target).get(metadata.propertyName);
84+
85+
/**
86+
* check if the current @expose does not conflict with the former decorators.
87+
*/
88+
const conflictedItemIndex = exposeArray!.findIndex(m => {
89+
const { name: n = m.propertyName, since: s, until: u, toPlainOnly: tpo, toClassOnly: tco } = m.options ?? {};
90+
91+
/**
92+
* check whether the intervals intersect or not.
93+
*/
94+
const s1 = s ?? Number.NEGATIVE_INFINITY;
95+
const u1 = u ?? Number.POSITIVE_INFINITY;
96+
const s2 = metadata.options?.since ?? Number.NEGATIVE_INFINITY;
97+
const u2 = metadata.options?.until ?? Number.POSITIVE_INFINITY;
98+
99+
const intervalIntersection = s1 < u2 && s2 < u1;
100+
101+
/**
102+
* check whether the current decorator's transformation types,
103+
* means "toPlainOnly" and "toClassOnly" options,
104+
* are common with the previous decorators or not.
105+
*/
106+
const mType = tpo === undefined && tco === undefined ? 3 : (tpo ? 1 : 0) + (tco ? 2 : 0);
107+
const currentType =
108+
toPlainOnly === undefined && toClassOnly === undefined ? 3 : (toPlainOnly ? 1 : 0) + (toClassOnly ? 2 : 0);
109+
const commonInType = !!(mType & currentType);
110+
111+
/**
112+
* check if the current "name" option
113+
* is different with the imported decorators or not.
114+
*/
115+
const differentName = n !== name;
116+
117+
return intervalIntersection && commonInType && differentName;
118+
});
119+
if (conflictedItemIndex !== -1) {
120+
const conflictedItem = exposeArray![conflictedItemIndex];
121+
throw Error(
122+
`"${metadata.propertyName ?? ''}" property:
123+
The current decorator (decorator #${
124+
exposeArray!.length
125+
}) conflicts with the decorator #${conflictedItemIndex}.
126+
If the stacked decorators intersect, the name option must be the same.
127+
128+
@Expose(${JSON.stringify(metadata.options || {})})
129+
conflicts with
130+
@Expose(${JSON.stringify(conflictedItem.options || {})})`
131+
);
132+
}
133+
134+
exposeArray?.push(metadata);
44135
}
45136

46137
addExcludeMetadata(metadata: ExcludeMetadata): void {
47138
if (!this._excludeMetadatas.has(metadata.target)) {
48-
this._excludeMetadatas.set(metadata.target, new Map<string, ExcludeMetadata>());
139+
this._excludeMetadatas.set(metadata.target, new Map<string, ExcludeMetadata[]>());
140+
}
141+
if (!this._excludeMetadatas.get(metadata.target).has(metadata.propertyName)) {
142+
this._excludeMetadatas.get(metadata.target).set(metadata.propertyName, []);
49143
}
50-
this._excludeMetadatas.get(metadata.target).set(metadata.propertyName, metadata);
144+
this._excludeMetadatas.get(metadata.target).get(metadata.propertyName).push(metadata);
51145
}
52146

53147
// -------------------------------------------------------------------------
@@ -59,34 +153,30 @@ export class MetadataStorage {
59153
propertyName: string,
60154
transformationType: TransformationType
61155
): TransformMetadata[] {
62-
return this.findMetadatas(this._transformMetadatas, target, propertyName).filter(metadata => {
63-
if (!metadata.options) return true;
64-
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;
65-
66-
if (metadata.options.toClassOnly === true) {
67-
return (
68-
transformationType === TransformationType.CLASS_TO_CLASS ||
69-
transformationType === TransformationType.PLAIN_TO_CLASS
70-
);
71-
}
72-
if (metadata.options.toPlainOnly === true) {
73-
return transformationType === TransformationType.CLASS_TO_PLAIN;
74-
}
75-
76-
return true;
77-
});
156+
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
157+
return this.findMetadatas(this._transformMetadatas, target, propertyName).filter(typeChecker);
78158
}
79159

80-
findExcludeMetadata(target: Function, propertyName: string): ExcludeMetadata {
81-
return this.findMetadata(this._excludeMetadatas, target, propertyName);
160+
findExcludeMetadatas(
161+
target: Function,
162+
propertyName: string,
163+
transformationType: TransformationType
164+
): ExcludeMetadata[] {
165+
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
166+
return this.findMetadatas(this._excludeMetadatas, target, propertyName).filter(typeChecker);
82167
}
83168

84-
findExposeMetadata(target: Function, propertyName: string): ExposeMetadata {
85-
return this.findMetadata(this._exposeMetadatas, target, propertyName);
169+
findExposeMetadatas(
170+
target: Function,
171+
propertyName: string,
172+
transformationType: TransformationType
173+
): ExposeMetadata[] {
174+
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
175+
return this.findMetadatas(this._exposeMetadatas, target, propertyName).filter(typeChecker);
86176
}
87177

88-
findExposeMetadataByCustomName(target: Function, name: string): ExposeMetadata {
89-
return this.getExposedMetadatas(target).find(metadata => {
178+
findExposeMetadatasByCustomName(target: Function, name: string): ExposeMetadata[] {
179+
return this.getExposedMetadatas(target).filter(metadata => {
90180
return metadata.options && metadata.options.name === name;
91181
});
92182
}
@@ -112,46 +202,25 @@ export class MetadataStorage {
112202
return this.getMetadata(this._excludeMetadatas, target);
113203
}
114204

115-
getExposedProperties(target: Function, transformationType: TransformationType): string[] {
116-
return this.getExposedMetadatas(target)
117-
.filter(metadata => {
118-
if (!metadata.options) return true;
119-
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;
120-
121-
if (metadata.options.toClassOnly === true) {
122-
return (
123-
transformationType === TransformationType.CLASS_TO_CLASS ||
124-
transformationType === TransformationType.PLAIN_TO_CLASS
125-
);
126-
}
127-
if (metadata.options.toPlainOnly === true) {
128-
return transformationType === TransformationType.CLASS_TO_PLAIN;
129-
}
130-
131-
return true;
132-
})
133-
.map(metadata => metadata.propertyName);
205+
getExposedProperties(
206+
target: Function,
207+
transformationType: TransformationType,
208+
options: { version?: number } = {}
209+
): string[] {
210+
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
211+
const { version } = options;
212+
let array = this.getExposedMetadatas(target).filter(typeChecker);
213+
if (version) {
214+
array = array.filter(metadata => checkVersion(version, metadata?.options?.since, metadata?.options?.until));
215+
}
216+
return array.map(metadata => metadata.propertyName!).filter(onlyUnique);
134217
}
135218

136219
getExcludedProperties(target: Function, transformationType: TransformationType): string[] {
220+
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
137221
return this.getExcludedMetadatas(target)
138-
.filter(metadata => {
139-
if (!metadata.options) return true;
140-
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;
141-
142-
if (metadata.options.toClassOnly === true) {
143-
return (
144-
transformationType === TransformationType.CLASS_TO_CLASS ||
145-
transformationType === TransformationType.PLAIN_TO_CLASS
146-
);
147-
}
148-
if (metadata.options.toPlainOnly === true) {
149-
return transformationType === TransformationType.CLASS_TO_PLAIN;
150-
}
151-
152-
return true;
153-
})
154-
.map(metadata => metadata.propertyName);
222+
.filter(typeChecker)
223+
.map(metadata => metadata.propertyName!);
155224
}
156225

157226
clear(): void {
@@ -165,26 +234,28 @@ export class MetadataStorage {
165234
// Private Methods
166235
// -------------------------------------------------------------------------
167236

168-
private getMetadata<T extends { target: Function; propertyName: string }>(
169-
metadatas: Map<Function, Map<string, T>>,
237+
private getMetadata<T extends { target: Function; propertyName: string | undefined }>(
238+
metadatas: Map<Function, Map<string, T[]>>,
170239
target: Function
171240
): T[] {
172241
const metadataFromTargetMap = metadatas.get(target);
173-
let metadataFromTarget: T[];
242+
let metadataFromTarget: T[] = [];
174243
if (metadataFromTargetMap) {
175-
metadataFromTarget = Array.from(metadataFromTargetMap.values()).filter(meta => meta.propertyName !== undefined);
244+
metadataFromTarget = flatten(Array.from(metadataFromTargetMap.values())).filter(
245+
meta => meta.propertyName !== undefined
246+
);
176247
}
177248
const metadataFromAncestors: T[] = [];
178249
for (const ancestor of this.getAncestors(target)) {
179250
const ancestorMetadataMap = metadatas.get(ancestor);
180251
if (ancestorMetadataMap) {
181-
const metadataFromAncestor = Array.from(ancestorMetadataMap.values()).filter(
252+
const metadataFromAncestor = flatten(Array.from(ancestorMetadataMap.values())).filter(
182253
meta => meta.propertyName !== undefined
183254
);
184255
metadataFromAncestors.push(...metadataFromAncestor);
185256
}
186257
}
187-
return metadataFromAncestors.concat(metadataFromTarget || []);
258+
return metadataFromAncestors.concat(metadataFromTarget);
188259
}
189260

190261
private findMetadata<T extends { target: Function; propertyName: string }>(
@@ -211,15 +282,15 @@ export class MetadataStorage {
211282
return undefined;
212283
}
213284

214-
private findMetadatas<T extends { target: Function; propertyName: string }>(
285+
private findMetadatas<T extends { target: Function; propertyName: string | undefined }>(
215286
metadatas: Map<Function, Map<string, T[]>>,
216287
target: Function,
217288
propertyName: string
218289
): T[] {
219290
const metadataFromTargetMap = metadatas.get(target);
220-
let metadataFromTarget: T[];
291+
let metadataFromTarget: T[] = [];
221292
if (metadataFromTargetMap) {
222-
metadataFromTarget = metadataFromTargetMap.get(propertyName);
293+
metadataFromTarget = metadataFromTargetMap.get(propertyName) ?? [];
223294
}
224295
const metadataFromAncestorsTarget: T[] = [];
225296
for (const ancestor of this.getAncestors(target)) {
@@ -230,10 +301,7 @@ export class MetadataStorage {
230301
}
231302
}
232303
}
233-
return metadataFromAncestorsTarget
234-
.slice()
235-
.reverse()
236-
.concat((metadataFromTarget || []).slice().reverse());
304+
return metadataFromAncestorsTarget.slice().reverse().concat(metadataFromTarget.slice().reverse());
237305
}
238306

239307
private getAncestors(target: Function): Function[] {

0 commit comments

Comments
 (0)