Skip to content

feat: stackable expose decorator (#378) #1259

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ rules:
'@typescript-eslint/no-unsafe-assignment': off
'@typescript-eslint/no-unsafe-call': off
'@typescript-eslint/no-unsafe-member-access': off
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-non-null-assertion': off
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Source code is available [here](https://github.com/pleerock/class-transformer-de
- [Providing more than one type option](#providing-more-than-one-type-option)
- [Exposing getters and method return values](#exposing-getters-and-method-return-values)
- [Exposing properties with different names](#exposing-properties-with-different-names)
- [Stacking expose decorator on a property](#stacking-expose-decorator-on-a-property)
- [Skipping specific properties](#skipping-specific-properties)
- [Skipping depend of operation](#skipping-depend-of-operation)
- [Skipping all properties of the class](#skipping-all-properties-of-the-class)
Expand Down Expand Up @@ -493,6 +494,34 @@ export class User {
}
```

## Stacking expose decorator on a property[⬆](#table-of-contents)

You can stack `@Expose` decorator more than once if you want.

```typescript
import { Expose } from 'class-transformer';

export class User {
id: number;

@Expose({ toClassOnly: true, groups: ['create', 'update'] })
@Expose({ toPlainOnly: true })
firstName: string;

@Expose({ toClassOnly: true, since: 2, name: 'lastName' })
@Expose({ toClassOnly: true, since: 1, until: 2, name: 'lastname' })
@Expose({ toClassOnly: true, until: 1, name: 'surname' })
@Expose({ toPlainOnly: true })
lastName: string;

@Transform(({ value }) => (value ? '*'.repeat(value.length) : value))
@Expose({ name: 'password', since: 2 })
@Expose({ name: 'secretKey', toClassOnly: true, since: 1, groups: ['create', 'update'] })
@Expose({ name: 'secretKey', toClassOnly: true, until: 1, groups: ['create'] })
password: string;
}
```

## Skipping specific properties[⬆](#table-of-contents)

Sometimes you want to skip some properties during transformation.
Expand Down
222 changes: 145 additions & 77 deletions src/MetadataStorage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { TypeMetadata, ExposeMetadata, ExcludeMetadata, TransformMetadata } from './interfaces';
import { TransformationType } from './enums';
import { checkVersion, flatten, onlyUnique } from './utils';

/**
* Storage all library metadata.
Expand All @@ -11,10 +12,32 @@ export class MetadataStorage {

private _typeMetadatas = new Map<Function, Map<string, TypeMetadata>>();
private _transformMetadatas = new Map<Function, Map<string, TransformMetadata[]>>();
private _exposeMetadatas = new Map<Function, Map<string, ExposeMetadata>>();
private _excludeMetadatas = new Map<Function, Map<string, ExcludeMetadata>>();
private _exposeMetadatas = new Map<Function, Map<string, ExposeMetadata[]>>();
private _excludeMetadatas = new Map<Function, Map<string, ExcludeMetadata[]>>();
private _ancestorsMap = new Map<Function, Function[]>();

// -------------------------------------------------------------------------
// Static Methods
// -------------------------------------------------------------------------

private static checkMetadataTransformationType<
T extends { options?: { toClassOnly?: boolean; toPlainOnly?: boolean } }
>(transformationType: TransformationType, metadata: T): boolean {
if (!metadata.options) return true;
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;

if (metadata.options.toClassOnly === true) {
return (
transformationType === TransformationType.CLASS_TO_CLASS ||
transformationType === TransformationType.PLAIN_TO_CLASS
);
}
if (metadata.options.toPlainOnly === true) {
return transformationType === TransformationType.CLASS_TO_PLAIN;
}
return true;
}

// -------------------------------------------------------------------------
// Adder Methods
// -------------------------------------------------------------------------
Expand All @@ -37,17 +60,88 @@ export class MetadataStorage {
}

addExposeMetadata(metadata: ExposeMetadata): void {
const { toPlainOnly, toClassOnly, name = metadata.propertyName } = metadata.options || {};

/**
* check if toPlainOnly and toClassOnly used correctly.
*/
if (
metadata.propertyName &&
!(toPlainOnly === true || toClassOnly === true || (toClassOnly === undefined && toPlainOnly === undefined))
) {
throw Error(
`${metadata.propertyName}: At least one of "toPlainOnly" and "toClassOnly" options must be "true" or both must be "undefined"`
);
}

if (!this._exposeMetadatas.has(metadata.target)) {
this._exposeMetadatas.set(metadata.target, new Map<string, ExposeMetadata>());
this._exposeMetadatas.set(metadata.target, new Map<string, ExposeMetadata[]>());
}
this._exposeMetadatas.get(metadata.target).set(metadata.propertyName, metadata);
if (!this._exposeMetadatas.get(metadata.target).has(metadata.propertyName)) {
this._exposeMetadatas.get(metadata.target).set(metadata.propertyName, []);
}
const exposeArray = this._exposeMetadatas.get(metadata.target).get(metadata.propertyName);

/**
* check if the current @expose does not conflict with the former decorators.
*/
const conflictedItemIndex = exposeArray!.findIndex(m => {
const { name: n = m.propertyName, since: s, until: u, toPlainOnly: tpo, toClassOnly: tco } = m.options ?? {};

/**
* check whether the intervals intersect or not.
*/
const s1 = s ?? Number.NEGATIVE_INFINITY;
const u1 = u ?? Number.POSITIVE_INFINITY;
const s2 = metadata.options?.since ?? Number.NEGATIVE_INFINITY;
const u2 = metadata.options?.until ?? Number.POSITIVE_INFINITY;

const intervalIntersection = s1 < u2 && s2 < u1;

/**
* check whether the current decorator's transformation types,
* means "toPlainOnly" and "toClassOnly" options,
* are common with the previous decorators or not.
*/
const mType = tpo === undefined && tco === undefined ? 3 : (tpo ? 1 : 0) + (tco ? 2 : 0);
const currentType =
toPlainOnly === undefined && toClassOnly === undefined ? 3 : (toPlainOnly ? 1 : 0) + (toClassOnly ? 2 : 0);
const commonInType = !!(mType & currentType);

/**
* check if the current "name" option
* is different with the imported decorators or not.
*/
const differentName = n !== name;

return intervalIntersection && commonInType && differentName;
});
if (conflictedItemIndex !== -1) {
const conflictedItem = exposeArray![conflictedItemIndex];
throw Error(
`"${metadata.propertyName ?? ''}" property:
The current decorator (decorator #${
exposeArray!.length
}) conflicts with the decorator #${conflictedItemIndex}.
If the stacked decorators intersect, the name option must be the same.

@Expose(${JSON.stringify(metadata.options || {})})
conflicts with
@Expose(${JSON.stringify(conflictedItem.options || {})})`
);
}

exposeArray?.push(metadata);
}

addExcludeMetadata(metadata: ExcludeMetadata): void {
if (!this._excludeMetadatas.has(metadata.target)) {
this._excludeMetadatas.set(metadata.target, new Map<string, ExcludeMetadata>());
this._excludeMetadatas.set(metadata.target, new Map<string, ExcludeMetadata[]>());
}
if (!this._excludeMetadatas.get(metadata.target).has(metadata.propertyName)) {
this._excludeMetadatas.get(metadata.target).set(metadata.propertyName, []);
}
this._excludeMetadatas.get(metadata.target).set(metadata.propertyName, metadata);
this._excludeMetadatas.get(metadata.target).get(metadata.propertyName).push(metadata);
}

// -------------------------------------------------------------------------
Expand All @@ -59,34 +153,30 @@ export class MetadataStorage {
propertyName: string,
transformationType: TransformationType
): TransformMetadata[] {
return this.findMetadatas(this._transformMetadatas, target, propertyName).filter(metadata => {
if (!metadata.options) return true;
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;

if (metadata.options.toClassOnly === true) {
return (
transformationType === TransformationType.CLASS_TO_CLASS ||
transformationType === TransformationType.PLAIN_TO_CLASS
);
}
if (metadata.options.toPlainOnly === true) {
return transformationType === TransformationType.CLASS_TO_PLAIN;
}

return true;
});
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
return this.findMetadatas(this._transformMetadatas, target, propertyName).filter(typeChecker);
}

findExcludeMetadata(target: Function, propertyName: string): ExcludeMetadata {
return this.findMetadata(this._excludeMetadatas, target, propertyName);
findExcludeMetadatas(
target: Function,
propertyName: string,
transformationType: TransformationType
): ExcludeMetadata[] {
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
return this.findMetadatas(this._excludeMetadatas, target, propertyName).filter(typeChecker);
}

findExposeMetadata(target: Function, propertyName: string): ExposeMetadata {
return this.findMetadata(this._exposeMetadatas, target, propertyName);
findExposeMetadatas(
target: Function,
propertyName: string,
transformationType: TransformationType
): ExposeMetadata[] {
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
return this.findMetadatas(this._exposeMetadatas, target, propertyName).filter(typeChecker);
}

findExposeMetadataByCustomName(target: Function, name: string): ExposeMetadata {
return this.getExposedMetadatas(target).find(metadata => {
findExposeMetadatasByCustomName(target: Function, name: string): ExposeMetadata[] {
return this.getExposedMetadatas(target).filter(metadata => {
return metadata.options && metadata.options.name === name;
});
}
Expand All @@ -112,46 +202,25 @@ export class MetadataStorage {
return this.getMetadata(this._excludeMetadatas, target);
}

getExposedProperties(target: Function, transformationType: TransformationType): string[] {
return this.getExposedMetadatas(target)
.filter(metadata => {
if (!metadata.options) return true;
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;

if (metadata.options.toClassOnly === true) {
return (
transformationType === TransformationType.CLASS_TO_CLASS ||
transformationType === TransformationType.PLAIN_TO_CLASS
);
}
if (metadata.options.toPlainOnly === true) {
return transformationType === TransformationType.CLASS_TO_PLAIN;
}

return true;
})
.map(metadata => metadata.propertyName);
getExposedProperties(
target: Function,
transformationType: TransformationType,
options: { version?: number } = {}
): string[] {
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
const { version } = options;
let array = this.getExposedMetadatas(target).filter(typeChecker);
if (version) {
array = array.filter(metadata => checkVersion(version, metadata?.options?.since, metadata?.options?.until));
}
return array.map(metadata => metadata.propertyName!).filter(onlyUnique);
}

getExcludedProperties(target: Function, transformationType: TransformationType): string[] {
const typeChecker = MetadataStorage.checkMetadataTransformationType.bind(this, transformationType);
return this.getExcludedMetadatas(target)
.filter(metadata => {
if (!metadata.options) return true;
if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true;

if (metadata.options.toClassOnly === true) {
return (
transformationType === TransformationType.CLASS_TO_CLASS ||
transformationType === TransformationType.PLAIN_TO_CLASS
);
}
if (metadata.options.toPlainOnly === true) {
return transformationType === TransformationType.CLASS_TO_PLAIN;
}

return true;
})
.map(metadata => metadata.propertyName);
.filter(typeChecker)
.map(metadata => metadata.propertyName!);
}

clear(): void {
Expand All @@ -165,26 +234,28 @@ export class MetadataStorage {
// Private Methods
// -------------------------------------------------------------------------

private getMetadata<T extends { target: Function; propertyName: string }>(
metadatas: Map<Function, Map<string, T>>,
private getMetadata<T extends { target: Function; propertyName: string | undefined }>(
metadatas: Map<Function, Map<string, T[]>>,
target: Function
): T[] {
const metadataFromTargetMap = metadatas.get(target);
let metadataFromTarget: T[];
let metadataFromTarget: T[] = [];
if (metadataFromTargetMap) {
metadataFromTarget = Array.from(metadataFromTargetMap.values()).filter(meta => meta.propertyName !== undefined);
metadataFromTarget = flatten(Array.from(metadataFromTargetMap.values())).filter(
meta => meta.propertyName !== undefined
);
}
const metadataFromAncestors: T[] = [];
for (const ancestor of this.getAncestors(target)) {
const ancestorMetadataMap = metadatas.get(ancestor);
if (ancestorMetadataMap) {
const metadataFromAncestor = Array.from(ancestorMetadataMap.values()).filter(
const metadataFromAncestor = flatten(Array.from(ancestorMetadataMap.values())).filter(
meta => meta.propertyName !== undefined
);
metadataFromAncestors.push(...metadataFromAncestor);
}
}
return metadataFromAncestors.concat(metadataFromTarget || []);
return metadataFromAncestors.concat(metadataFromTarget);
}

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

private findMetadatas<T extends { target: Function; propertyName: string }>(
private findMetadatas<T extends { target: Function; propertyName: string | undefined }>(
metadatas: Map<Function, Map<string, T[]>>,
target: Function,
propertyName: string
): T[] {
const metadataFromTargetMap = metadatas.get(target);
let metadataFromTarget: T[];
let metadataFromTarget: T[] = [];
if (metadataFromTargetMap) {
metadataFromTarget = metadataFromTargetMap.get(propertyName);
metadataFromTarget = metadataFromTargetMap.get(propertyName) ?? [];
}
const metadataFromAncestorsTarget: T[] = [];
for (const ancestor of this.getAncestors(target)) {
Expand All @@ -230,10 +301,7 @@ export class MetadataStorage {
}
}
}
return metadataFromAncestorsTarget
.slice()
.reverse()
.concat((metadataFromTarget || []).slice().reverse());
return metadataFromAncestorsTarget.slice().reverse().concat(metadataFromTarget.slice().reverse());
}

private getAncestors(target: Function): Function[] {
Expand Down
Loading