From 3bb7abfc56c05c81635331fec49fa223413264c9 Mon Sep 17 00:00:00 2001 From: iran-110 Date: Mon, 27 Jun 2022 08:08:32 +0000 Subject: [PATCH 1/3] feat: handles stackable expose decorator (#378) --- package-lock.json | 14 +- package.json | 2 +- src/MetadataStorage.ts | 222 ++++++++----- src/TransformOperationExecutor.ts | 101 +++--- src/utils/checkers.util.ts | 18 ++ src/utils/flatten.util.ts | 3 + src/utils/index.ts | 3 + src/utils/only-unique.util.ts | 3 + .../stack-multi-expose-decorator.spec.ts | 298 ++++++++++++++++++ 9 files changed, 526 insertions(+), 138 deletions(-) create mode 100644 src/utils/checkers.util.ts create mode 100644 src/utils/flatten.util.ts create mode 100644 src/utils/only-unique.util.ts create mode 100644 test/functional/stack-multi-expose-decorator.spec.ts diff --git a/package-lock.json b/package-lock.json index 0d3e5d665..99f11bf22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.5.1", "license": "MIT", "devDependencies": { - "@rollup/plugin-commonjs": "^22.0.0", + "@rollup/plugin-commonjs": "^22.0.1", "@rollup/plugin-node-resolve": "^13.3.0", "@types/jest": "^27.5.0", "@types/node": "^18.0.0", @@ -1061,9 +1061,9 @@ } }, "node_modules/@rollup/plugin-commonjs": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.0.tgz", - "integrity": "sha512-Ktvf2j+bAO+30awhbYoCaXpBcyPmJbaEUYClQns/+6SNCYFURbvBiNbWgHITEsIgDDWCDUclWRKEuf8cwZCFoQ==", + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.1.tgz", + "integrity": "sha512-dGfEZvdjDHObBiP5IvwTKMVeq/tBZGMBHZFMdIV1ClMM/YoWS34xrHFGfag9SN2ZtMgNZRFruqvxZQEa70O6nQ==", "dev": true, "dependencies": { "@rollup/pluginutils": "^3.1.0", @@ -9595,9 +9595,9 @@ } }, "@rollup/plugin-commonjs": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.0.tgz", - "integrity": "sha512-Ktvf2j+bAO+30awhbYoCaXpBcyPmJbaEUYClQns/+6SNCYFURbvBiNbWgHITEsIgDDWCDUclWRKEuf8cwZCFoQ==", + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.1.tgz", + "integrity": "sha512-dGfEZvdjDHObBiP5IvwTKMVeq/tBZGMBHZFMdIV1ClMM/YoWS34xrHFGfag9SN2ZtMgNZRFruqvxZQEa70O6nQ==", "dev": true, "requires": { "@rollup/pluginutils": "^3.1.0", diff --git a/package.json b/package.json index d90b5bcf0..9965c575d 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ ] }, "devDependencies": { - "@rollup/plugin-commonjs": "^22.0.0", + "@rollup/plugin-commonjs": "^22.0.1", "@rollup/plugin-node-resolve": "^13.3.0", "@types/jest": "^27.5.0", "@types/node": "^18.0.0", diff --git a/src/MetadataStorage.ts b/src/MetadataStorage.ts index a4c3a8ddc..5182d5bb7 100644 --- a/src/MetadataStorage.ts +++ b/src/MetadataStorage.ts @@ -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. @@ -11,10 +12,32 @@ export class MetadataStorage { private _typeMetadatas = new Map>(); private _transformMetadatas = new Map>(); - private _exposeMetadatas = new Map>(); - private _excludeMetadatas = new Map>(); + private _exposeMetadatas = new Map>(); + private _excludeMetadatas = new Map>(); private _ancestorsMap = new Map(); + // ------------------------------------------------------------------------- + // 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 // ------------------------------------------------------------------------- @@ -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()); + this._exposeMetadatas.set(metadata.target, new Map()); } - 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()); + this._excludeMetadatas.set(metadata.target, new Map()); + } + 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); } // ------------------------------------------------------------------------- @@ -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; }); } @@ -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 { @@ -165,26 +234,28 @@ export class MetadataStorage { // Private Methods // ------------------------------------------------------------------------- - private getMetadata( - metadatas: Map>, + private getMetadata( + metadatas: Map>, 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( @@ -211,15 +282,15 @@ export class MetadataStorage { return undefined; } - private findMetadatas( + private findMetadatas( metadatas: Map>, 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)) { @@ -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[] { diff --git a/src/TransformOperationExecutor.ts b/src/TransformOperationExecutor.ts index 0533f03df..528e7b8be 100644 --- a/src/TransformOperationExecutor.ts +++ b/src/TransformOperationExecutor.ts @@ -1,7 +1,7 @@ import { defaultMetadataStorage } from './storage'; import { ClassTransformOptions, TypeHelpOptions, TypeMetadata, TypeOptions } from './interfaces'; import { TransformationType } from './enums'; -import { getGlobal, isPromise } from './utils'; +import { getGlobal, isPromise, checkGroups, checkVersion } from './utils'; function instantiateArrayType(arrayType: Function): Array | Set { const array = new (arrayType as any)(); @@ -174,7 +174,10 @@ export class TransformOperationExecutor { propertyName = key; if (!this.options.ignoreDecorators && targetType) { if (this.transformationType === TransformationType.PLAIN_TO_CLASS) { - const exposeMetadata = defaultMetadataStorage.findExposeMetadataByCustomName(targetType as Function, key); + const exposeMetadatas = defaultMetadataStorage.findExposeMetadatasByCustomName(targetType as Function, key); + const exposeMetadata = exposeMetadatas.find(metadata => + checkVersion(this.options.version, metadata.options.since, metadata.options.until) + ); if (exposeMetadata) { propertyName = exposeMetadata.propertyName; newValueKey = exposeMetadata.propertyName; @@ -183,9 +186,16 @@ export class TransformOperationExecutor { this.transformationType === TransformationType.CLASS_TO_PLAIN || this.transformationType === TransformationType.CLASS_TO_CLASS ) { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(targetType as Function, key); - if (exposeMetadata && exposeMetadata.options && exposeMetadata.options.name) { - newValueKey = exposeMetadata.options.name; + const exposeMetadatas = defaultMetadataStorage.findExposeMetadatas( + targetType as Function, + key, + this.transformationType + ); + const firstNamedExposeMetadata = exposeMetadatas.find( + metadata => metadata && metadata.options && metadata.options.name + ); + if (firstNamedExposeMetadata) { + newValueKey = firstNamedExposeMetadata.options.name!; } } } @@ -391,7 +401,7 @@ export class TransformOperationExecutor { metadatas = metadatas.filter(metadata => { if (!metadata.options) return true; - return this.checkVersion(metadata.options.since, metadata.options.until); + return checkVersion(this.options.version, metadata.options.since, metadata.options.until); }); } @@ -400,7 +410,7 @@ export class TransformOperationExecutor { metadatas = metadatas.filter(metadata => { if (!metadata.options) return true; - return this.checkGroups(metadata.options.groups); + return checkGroups(this.options.groups!, metadata.options.groups); }); } else { metadatas = metadatas.filter(metadata => { @@ -458,12 +468,22 @@ export class TransformOperationExecutor { if (!this.options.ignoreDecorators && target) { // add all exposed to list of keys - let exposedProperties = defaultMetadataStorage.getExposedProperties(target, this.transformationType); + let exposedProperties = defaultMetadataStorage.getExposedProperties(target, this.transformationType, { + version: this.options.version, + }); if (this.transformationType === TransformationType.PLAIN_TO_CLASS) { exposedProperties = exposedProperties.map(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); - if (exposeMetadata && exposeMetadata.options && exposeMetadata.options.name) { - return exposeMetadata.options.name; + const exposeMetadatas = defaultMetadataStorage.findExposeMetadatas(target, key, this.transformationType); + const firstExposeMetadata = exposeMetadatas.find(metadata => { + return ( + metadata && + metadata.options && + metadata.options.name && + checkVersion(this.options.version, metadata.options?.since, metadata.options?.until) + ); + }); + if (firstExposeMetadata) { + return firstExposeMetadata.options.name!; } return key; @@ -483,35 +503,24 @@ export class TransformOperationExecutor { }); } - // apply versioning options - if (this.options.version !== undefined) { - keys = keys.filter(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); - if (!exposeMetadata || !exposeMetadata.options) return true; - - return this.checkVersion(exposeMetadata.options.since, exposeMetadata.options.until); - }); - } - - // apply grouping options - if (this.options.groups && this.options.groups.length) { - keys = keys.filter(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); - if (!exposeMetadata || !exposeMetadata.options) return true; - - return this.checkGroups(exposeMetadata.options.groups); - }); - } else { - keys = keys.filter(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); - return ( - !exposeMetadata || - !exposeMetadata.options || - !exposeMetadata.options.groups || - !exposeMetadata.options.groups.length - ); + // apply versioning and grouping options + keys = keys.filter(key => { + const exposeMetadatas = defaultMetadataStorage.findExposeMetadatas(target, key, this.transformationType); + if (!exposeMetadatas.length) return true; + + // check if the current version/groups is in consistent with "ONE OF" the exposed metadatas. + return exposeMetadatas.some(metadata => { + if (!metadata.options) return true; + let ret = true; + ret &&= checkVersion(this.options.version, metadata.options.since, metadata.options.until); + if (this.options.groups && this.options.groups.length) { + ret &&= checkGroups(this.options.groups, metadata.options.groups); + } else { + ret &&= !metadata.options.groups || !metadata.options.groups.length; + } + return ret; }); - } + }); } // exclude prefixed properties @@ -530,18 +539,4 @@ export class TransformOperationExecutor { return keys; } - - private checkVersion(since: number, until: number): boolean { - let decision = true; - if (decision && since) decision = this.options.version >= since; - if (decision && until) decision = this.options.version < until; - - return decision; - } - - private checkGroups(groups: string[]): boolean { - if (!groups) return true; - - return this.options.groups.some(optionGroup => groups.includes(optionGroup)); - } } diff --git a/src/utils/checkers.util.ts b/src/utils/checkers.util.ts new file mode 100644 index 000000000..6d08e0c07 --- /dev/null +++ b/src/utils/checkers.util.ts @@ -0,0 +1,18 @@ +export function checkVersion( + version: number | undefined, + since: number | undefined, + until: number | undefined +): boolean { + if (version === undefined) return true; + let decision = true; + if (decision && since) decision = version >= since; + if (decision && until) decision = version < until; + + return decision; +} + +export function checkGroups(groups: string[], metadataGroups: string[] | undefined): boolean { + if (!metadataGroups || !metadataGroups.length) return true; + + return groups.some(optionGroup => metadataGroups.includes(optionGroup)); +} diff --git a/src/utils/flatten.util.ts b/src/utils/flatten.util.ts new file mode 100644 index 000000000..944872d8e --- /dev/null +++ b/src/utils/flatten.util.ts @@ -0,0 +1,3 @@ +export function flatten(arrayOfArrays: T[][]): T[] { + return ([] as T[]).concat.apply([], arrayOfArrays); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 382ad2d7f..2c1fed694 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,5 @@ export * from './get-global.util'; export * from './is-promise.util'; +export * from './checkers.util'; +export * from './flatten.util'; +export * from './only-unique.util'; diff --git a/src/utils/only-unique.util.ts b/src/utils/only-unique.util.ts new file mode 100644 index 000000000..690c90ce7 --- /dev/null +++ b/src/utils/only-unique.util.ts @@ -0,0 +1,3 @@ +export function onlyUnique(value: T, index: number, self: T[]) { + return self.indexOf(value) === index; +} diff --git a/test/functional/stack-multi-expose-decorator.spec.ts b/test/functional/stack-multi-expose-decorator.spec.ts new file mode 100644 index 000000000..b06b07a3b --- /dev/null +++ b/test/functional/stack-multi-expose-decorator.spec.ts @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import 'reflect-metadata'; +import { instanceToPlain, plainToInstance, Transform, Exclude, Expose } from '../../src'; +import { defaultMetadataStorage } from '../../src/storage'; + +describe('stack multi expose decorator', () => { + /** + * test-case for issue #378 + */ + it('handling groups with stacked @Expose decorators', () => { + defaultMetadataStorage.clear(); + + @Exclude() + class User { + @Expose({ toClassOnly: true, groups: ['create', 'update'] }) + @Expose({ toPlainOnly: true }) + public email?: string; + + @Expose({ toClassOnly: true, groups: ['create', 'update'] }) + @Expose({ toPlainOnly: true }) + public firstName?: string; + + @Expose({ toClassOnly: true, groups: ['create'] }) + @Expose({ toPlainOnly: true }) + public password?: string; + } + + const plainUser = { + email: 'email@example.com', + firstName: 'John', + password: '12345', + }; + const instance = plainToInstance(User, plainUser, { groups: ['update'] }); + expect(instance).toEqual({ + firstName: 'John', + email: 'email@example.com', + }); + + const user = new User(); + user.email = 'email@example.com'; + user.firstName = 'John'; + user.password = '12345'; + const plain = instanceToPlain(user); + expect(plain).toEqual({ + firstName: 'John', + password: '12345', + email: 'email@example.com', + }); + }); + + it('Stacking @Expose decorator with "name" option only on decorator with "toClassOnly" option', () => { + defaultMetadataStorage.clear(); + + class User { + @Expose({ toClassOnly: true, name: 'inputName' }) + @Expose({ toPlainOnly: true }) + name: string; + + @Expose({ toPlainOnly: true }) + @Expose({ toClassOnly: true, name: 'inputFamily' }) + family: string; + } + + const plainUser = { + inputName: 'Mohammad', + inputFamily: 'Hassani', + }; + + const classedUser = plainToInstance(User, plainUser, { excludeExtraneousValues: true }); + expect(classedUser).toEqual({ + name: 'Mohammad', + family: 'Hassani', + }); + }); + + it('Stacking @Expose decorator with "name" option only on decorator with "toPlainOnly" option', () => { + defaultMetadataStorage.clear(); + + class User { + @Expose({ toClassOnly: true }) + @Expose({ toPlainOnly: true, name: 'outputName' }) + name: string; + + @Expose({ toPlainOnly: true, name: 'outputFamily' }) + @Expose({ toClassOnly: true }) + family: string; + } + + const plainUser = { + name: 'Hassan', + family: 'Hosseini', + }; + + const classedUser = plainToInstance(User, plainUser); + expect(classedUser).toBeInstanceOf(User); + expect(classedUser.name).toEqual('Hassan'); + expect(classedUser.family).toEqual('Hosseini'); + + const plainedUser = instanceToPlain(classedUser); + expect(plainedUser).not.toBeInstanceOf(User); + expect(plainedUser).toEqual({ + outputName: 'Hassan', + outputFamily: 'Hosseini', + }); + }); + + it('versions should work with stacked @Expose decorators too', () => { + defaultMetadataStorage.clear(); + + class User { + @Expose({ since: 2, name: 'inputName_since2' }) + @Expose({ until: 1, name: 'inputName_until1' }) + name: string; + + @Expose({ since: 2 }) + @Expose({ since: 0, until: 1 }) + family: string; + + id: number; + } + + const plainUser = { + id: 1, + inputName_until1: 'Mohammad', + inputName_since2: 'Ahmad', + family: 'Mohammadi', + }; + + const classedUser1 = plainToInstance(User, plainUser, { version: 0 }); + expect(classedUser1).toBeInstanceOf(User); + expect(classedUser1.name).toBe('Mohammad'); + expect(classedUser1.family).toBe('Mohammadi'); + + const classedUser2 = plainToInstance(User, plainUser, { version: 1 }); + expect(classedUser2).toBeInstanceOf(User); + expect(classedUser2).not.toHaveProperty('name'); + expect(classedUser2).not.toHaveProperty('family'); + + const classedUser3 = plainToInstance(User, plainUser, { version: 2 }); + expect(classedUser3).toBeInstanceOf(User); + expect(classedUser3.name).toBe('Ahmad'); + expect(classedUser3.family).toBe('Mohammadi'); + }); + + it('also, versions and groups work with stacked @Expose decorators', () => { + defaultMetadataStorage.clear(); + + class User { + @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: 'familyName' }) + @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, until: 2, groups: ['create', 'update'] }) + @Expose({ name: 'secretKey', toClassOnly: true, until: 1, groups: ['create'] }) + password: string; + + id: number; + } + + const plainUser1 = { + id: 1, + firstName: 'Mohammad', + surname: 'Ahmadi', + password: '12345', + }; + + const classedUser1 = plainToInstance(User, plainUser1, { version: 0, groups: ['update'] }); + expect(classedUser1).toBeInstanceOf(User); + expect(classedUser1.id).toBe(1); + expect(classedUser1.firstName).toBe('Mohammad'); + expect(classedUser1.lastName).toBe('Ahmadi'); + expect(classedUser1.password).toBeUndefined(); + + const plainUser2 = { + id: 2, + firstName: 'Mohammad', + familyName: 'Ahmadi', + password: '12345', + }; + + const classedUser2 = plainToInstance(User, plainUser2, { version: 1, groups: ['create'] }); + expect(classedUser2).toBeInstanceOf(User); + expect(classedUser2.id).toBe(2); + expect(classedUser2.firstName).toBe('Mohammad'); + expect(classedUser2.lastName).toBe('Ahmadi'); + expect(classedUser2.password).toBeUndefined(); + + const plainUser3 = { + id: 3, + firstName: 'Mohammad', + familyName: 'Ahmadi', + password: '12345', + }; + + const classedUser3 = plainToInstance(User, plainUser3, { version: 2 }); + expect(classedUser3).toBeInstanceOf(User); + expect(classedUser3.id).toBe(3); + expect(classedUser3.firstName).toBeUndefined(); + expect(classedUser3.lastName).toBeUndefined(); + expect(classedUser3.password).toBe('*****'); + }); + + it('handling wrong "toClassOnly" and "toPlainOnly" options', () => { + defaultMetadataStorage.clear(); + + const plainUser = { + name: 'Mohammad', + family: 'Mohammadi', + gender: 'male', + }; + + expect(() => { + class User { + @Expose() + name: string; + + @Expose({ toClassOnly: true, toPlainOnly: false }) + @Expose({ toClassOnly: false, toPlainOnly: true }) + family: string; + + @Expose({ toClassOnly: true, toPlainOnly: true }) + gender: string; + } + plainToInstance(User, plainUser); + }).not.toThrowError(); + + expect(() => { + class User { + @Expose({ toClassOnly: false }) + name: string; + } + plainToInstance(User, plainUser); + }).toThrowError(); + + expect(() => { + class User { + @Expose({ toPlainOnly: false }) + name: string; + } + plainToInstance(User, plainUser); + }).toThrowError(); + + expect(() => { + class User { + @Expose({ toClassOnly: false, toPlainOnly: false }) + name: string; + } + plainToInstance(User, plainUser); + }).toThrowError(); + }); + + it('handling conflict between stacked decorators.', () => { + defaultMetadataStorage.clear(); + + const plainUser = { + name: 'Mohammad', + family: 'Mohammadi', + gender: 'male', + }; + + expect(() => { + class User { + @Expose({ since: 2, name: 'firstName' }) + @Expose({ since: 0, until: 2, name: 'name', groups: ['update'] }) + @Expose({ since: 0, until: 1 }) + @Expose({ until: 0, name: 'lastName' }) + name: string; + } + plainToInstance(User, plainUser); + }).not.toThrowError(); + + expect(() => { + class User { + @Expose() + @Expose({ until: 0, name: 'lastName' }) + name: string; + } + plainToInstance(User, plainUser); + }).toThrowError(); + + expect(() => { + class User { + @Expose({ since: 1, until: 2, name: 'lastName' }) + @Expose({ since: 0, until: 2, name: 'firstName' }) + name: string; + } + plainToInstance(User, plainUser); + }).toThrowError(); + }); +}); From 18fdf8536c3331995175737131741b52da2b0c8f Mon Sep 17 00:00:00 2001 From: iran-110 Date: Sat, 2 Jul 2022 09:26:53 +0200 Subject: [PATCH 2/3] fix: switches off the linter "no-non-null-assertion" --- .eslintrc.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.yml b/.eslintrc.yml index a570c9dbf..825bcb6c9 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -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 \ No newline at end of file + '@typescript-eslint/explicit-module-boundary-types': off + '@typescript-eslint/no-non-null-assertion': off \ No newline at end of file From 5a85a6f49f03981bce4b55573dc63df8de665969 Mon Sep 17 00:00:00 2001 From: iran-110 Date: Sat, 2 Jul 2022 09:31:22 +0200 Subject: [PATCH 3/3] docs: adds readme documentation for stacking expose decorator --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index e48c2ac88..e09656151 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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.