diff --git a/README.md b/README.md index d9e1c1230..e89af8206 100644 --- a/README.md +++ b/README.md @@ -493,6 +493,55 @@ export class User { } ``` +If you need to rename the property from or to a plain instance, +you can use the `from` and `to` options of `@Alias`: + +```typescript +import { Expose } from 'class-transformer'; + +export class User { + @Expose({ name: 'uid' }) + id: string; + + firstName: string; + + lastName: string; + + @Alias({ from: 'secretKey', to: '__password' }) + password: string; + + @Alias({ to: 'fullName' }) + getFullName() { + return this.firstName + ' ' + this.lastName; + } +} +``` + +Using this configuration, the `User` object would accept +the following plain object for `plainToInstance(User, ...)`: + +```json +{ + "uid": "1234", + "firstName": "John", + "lastName": "Doe", + "secretKey": "allYourBaseAreBelongToUs" +} +``` + +Subsequently `instanceToPlain(...)` would then produce the +following plain object: + +```json +{ + "uid": "1234", + "firstName": "John", + "lastName": "Doe", + "__password": "allYourBaseAreBelongToUs", + "fullName": "John Doe" +} +``` + ## Skipping specific properties[⬆](#table-of-contents) Sometimes you want to skip some properties during transformation. diff --git a/src/MetadataStorage.ts b/src/MetadataStorage.ts index a4c3a8ddc..a8ffa8530 100644 --- a/src/MetadataStorage.ts +++ b/src/MetadataStorage.ts @@ -11,8 +11,8 @@ 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(); // ------------------------------------------------------------------------- @@ -20,34 +20,49 @@ export class MetadataStorage { // ------------------------------------------------------------------------- addTypeMetadata(metadata: TypeMetadata): void { - if (!this._typeMetadatas.has(metadata.target)) { - this._typeMetadatas.set(metadata.target, new Map()); + let properties = this._typeMetadatas.get(metadata.target); + if (!properties) { + properties = new Map(); + this._typeMetadatas.set(metadata.target, properties); } - this._typeMetadatas.get(metadata.target).set(metadata.propertyName, metadata); + properties.set(metadata.propertyName, metadata); } addTransformMetadata(metadata: TransformMetadata): void { - if (!this._transformMetadatas.has(metadata.target)) { - this._transformMetadatas.set(metadata.target, new Map()); + let properties = this._transformMetadatas.get(metadata.target); + if (!properties) { + properties = new Map(); + this._transformMetadatas.set(metadata.target, properties); } - if (!this._transformMetadatas.get(metadata.target).has(metadata.propertyName)) { - this._transformMetadatas.get(metadata.target).set(metadata.propertyName, []); + let metadatas = properties.get(metadata.propertyName); + if (!metadatas) { + metadatas = []; + properties.set(metadata.propertyName, metadatas); } - this._transformMetadatas.get(metadata.target).get(metadata.propertyName).push(metadata); + metadatas.push(metadata); } addExposeMetadata(metadata: ExposeMetadata): void { - if (!this._exposeMetadatas.has(metadata.target)) { - this._exposeMetadatas.set(metadata.target, new Map()); + let properties = this._exposeMetadatas.get(metadata.target); + if (!properties) { + properties = new Map(); + this._exposeMetadatas.set(metadata.target, properties); } - this._exposeMetadatas.get(metadata.target).set(metadata.propertyName, metadata); + let values = properties.get(metadata.propertyName); + if (!values) { + values = []; + properties.set(metadata.propertyName, values); + } + values.push(metadata); } addExcludeMetadata(metadata: ExcludeMetadata): void { - if (!this._excludeMetadatas.has(metadata.target)) { - this._excludeMetadatas.set(metadata.target, new Map()); + let properties = this._excludeMetadatas.get(metadata.target); + if (!properties) { + properties = new Map(); + this._excludeMetadatas.set(metadata.target, properties); } - this._excludeMetadatas.get(metadata.target).set(metadata.propertyName, metadata); + properties.set(metadata.propertyName, metadata); } // ------------------------------------------------------------------------- @@ -77,21 +92,44 @@ export class MetadataStorage { }); } - findExcludeMetadata(target: Function, propertyName: string): ExcludeMetadata { + findExcludeMetadata(target: Function, propertyName: string): ExcludeMetadata | undefined { return this.findMetadata(this._excludeMetadatas, target, propertyName); } - findExposeMetadata(target: Function, propertyName: string): ExposeMetadata { - return this.findMetadata(this._exposeMetadatas, target, propertyName); + findExposeMetadata( + target: Function, + propertyName: string, + transformationType: TransformationType + ): ExposeMetadata | undefined { + return this.findMetadatas(this._exposeMetadatas, target, propertyName).find(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; + }); } - findExposeMetadataByCustomName(target: Function, name: string): ExposeMetadata { + findExposeMetadataByCustomName( + target: Function, + name: string, + transformType: TransformationType + ): ExposeMetadata | undefined { return this.getExposedMetadatas(target).find(metadata => { return metadata.options && metadata.options.name === name; }); } - findTypeMetadata(target: Function, propertyName: string): TypeMetadata { + findTypeMetadata(target: Function, propertyName: string): TypeMetadata | undefined { return this.findMetadata(this._typeMetadatas, target, propertyName); } @@ -105,7 +143,7 @@ export class MetadataStorage { } getExposedMetadatas(target: Function): ExposeMetadata[] { - return this.getMetadata(this._exposeMetadatas, target); + return this.getMetadatas(this._exposeMetadatas, target); } getExcludedMetadatas(target: Function): ExcludeMetadata[] { @@ -115,6 +153,7 @@ export class MetadataStorage { getExposedProperties(target: Function, transformationType: TransformationType): string[] { return this.getExposedMetadatas(target) .filter(metadata => { + if (!metadata.propertyName) return false; if (!metadata.options) return true; if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true; @@ -130,12 +169,13 @@ export class MetadataStorage { return true; }) - .map(metadata => metadata.propertyName); + .map(metadata => metadata.propertyName as string); } getExcludedProperties(target: Function, transformationType: TransformationType): string[] { return this.getExcludedMetadatas(target) .filter(metadata => { + if (!metadata.propertyName) return false; if (!metadata.options) return true; if (metadata.options.toClassOnly === true && metadata.options.toPlainOnly === true) return true; @@ -151,7 +191,7 @@ export class MetadataStorage { return true; }) - .map(metadata => metadata.propertyName); + .map(metadata => metadata.propertyName as string); } clear(): void { @@ -165,12 +205,12 @@ 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[] | undefined = undefined; if (metadataFromTargetMap) { metadataFromTarget = Array.from(metadataFromTargetMap.values()).filter(meta => meta.propertyName !== undefined); } @@ -187,11 +227,37 @@ export class MetadataStorage { return metadataFromAncestors.concat(metadataFromTarget || []); } - private findMetadata( - metadatas: Map>, + private getMetadatas( + metadatas: Map>, + target: Function + ): T[] { + const metadataFromTargetMap = metadatas.get(target); + let metadataFromTarget: T[] | undefined = undefined; + if (metadataFromTargetMap) { + metadataFromTarget = Array.from(metadataFromTargetMap.values()).reduce( + (acc, values) => acc.concat(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()).reduce( + (acc, values) => acc.concat(values.filter(meta => meta.propertyName !== undefined)), + [] + ); + metadataFromAncestors.push(...metadataFromAncestor); + } + } + return metadataFromAncestors.concat(metadataFromTarget || []); + } + + private findMetadata( + metadatas: Map>, target: Function, propertyName: string - ): T { + ): T | undefined { const metadataFromTargetMap = metadatas.get(target); if (metadataFromTargetMap) { const metadataFromTarget = metadataFromTargetMap.get(propertyName); @@ -211,13 +277,13 @@ export class MetadataStorage { return undefined; } - private findMetadatas( - metadatas: Map>, + private findMetadatas( + metadatas: Map>, target: Function, propertyName: string ): T[] { const metadataFromTargetMap = metadatas.get(target); - let metadataFromTarget: T[]; + let metadataFromTarget: T[] | undefined = undefined; if (metadataFromTargetMap) { metadataFromTarget = metadataFromTargetMap.get(propertyName); } @@ -226,7 +292,7 @@ export class MetadataStorage { const ancestorMetadataMap = metadatas.get(ancestor); if (ancestorMetadataMap) { if (ancestorMetadataMap.has(propertyName)) { - metadataFromAncestorsTarget.push(...ancestorMetadataMap.get(propertyName)); + metadataFromAncestorsTarget.push(...(ancestorMetadataMap.get(propertyName) as T[])); } } } @@ -249,6 +315,6 @@ export class MetadataStorage { } this._ancestorsMap.set(target, ancestors); } - return this._ancestorsMap.get(target); + return this._ancestorsMap.get(target) || []; } } diff --git a/src/TransformOperationExecutor.ts b/src/TransformOperationExecutor.ts index 0533f03df..3464e513f 100644 --- a/src/TransformOperationExecutor.ts +++ b/src/TransformOperationExecutor.ts @@ -170,12 +170,16 @@ export class TransformOperationExecutor { } const valueKey = key; - let newValueKey = key, - propertyName = key; + let newValueKey = key; + let propertyName = key; if (!this.options.ignoreDecorators && targetType) { if (this.transformationType === TransformationType.PLAIN_TO_CLASS) { - const exposeMetadata = defaultMetadataStorage.findExposeMetadataByCustomName(targetType as Function, key); - if (exposeMetadata) { + const exposeMetadata = defaultMetadataStorage.findExposeMetadataByCustomName( + targetType as Function, + key, + this.transformationType + ); + if (exposeMetadata?.propertyName) { propertyName = exposeMetadata.propertyName; newValueKey = exposeMetadata.propertyName; } @@ -183,7 +187,11 @@ export class TransformOperationExecutor { this.transformationType === TransformationType.CLASS_TO_PLAIN || this.transformationType === TransformationType.CLASS_TO_CLASS ) { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(targetType as Function, key); + const exposeMetadata = defaultMetadataStorage.findExposeMetadata( + targetType as Function, + key, + this.transformationType + ); if (exposeMetadata && exposeMetadata.options && exposeMetadata.options.name) { newValueKey = exposeMetadata.options.name; } @@ -461,7 +469,7 @@ export class TransformOperationExecutor { let exposedProperties = defaultMetadataStorage.getExposedProperties(target, this.transformationType); if (this.transformationType === TransformationType.PLAIN_TO_CLASS) { exposedProperties = exposedProperties.map(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); + const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key, this.transformationType); if (exposeMetadata && exposeMetadata.options && exposeMetadata.options.name) { return exposeMetadata.options.name; } @@ -486,7 +494,7 @@ export class TransformOperationExecutor { // apply versioning options if (this.options.version !== undefined) { keys = keys.filter(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); + const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key, this.transformationType); if (!exposeMetadata || !exposeMetadata.options) return true; return this.checkVersion(exposeMetadata.options.since, exposeMetadata.options.until); @@ -496,14 +504,14 @@ export class TransformOperationExecutor { // apply grouping options if (this.options.groups && this.options.groups.length) { keys = keys.filter(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); + const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key, this.transformationType); if (!exposeMetadata || !exposeMetadata.options) return true; return this.checkGroups(exposeMetadata.options.groups); }); } else { keys = keys.filter(key => { - const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key); + const exposeMetadata = defaultMetadataStorage.findExposeMetadata(target, key, this.transformationType); return ( !exposeMetadata || !exposeMetadata.options || diff --git a/src/decorators/alias.decorator.ts b/src/decorators/alias.decorator.ts new file mode 100644 index 000000000..160325163 --- /dev/null +++ b/src/decorators/alias.decorator.ts @@ -0,0 +1,30 @@ +import { defaultMetadataStorage } from '../storage'; +import { AliasOptions } from '../interfaces'; + +/** + * Marks the given class or property as included. By default the property is included in both + * constructorToPlain and plainToConstructor transformations. It can be limited to only one direction + * via using the `toPlainOnly` or `toClassOnly` option. + * + * Can be applied to class definitions and properties. + */ +export function Alias(options: AliasOptions = {}): PropertyDecorator & ClassDecorator { + /** + * NOTE: The `propertyName` property must be marked as optional because + * this decorator used both as a class and a property decorator and the + * Typescript compiler will freak out if we make it mandatory as a class + * decorator only receives one parameter. + */ + return function (object: any, propertyName?: string | Symbol): void { + defaultMetadataStorage.addExposeMetadata({ + target: object instanceof Function ? object : object.constructor, + propertyName: propertyName as string, + options: { name: options.from || (propertyName as string), toClassOnly: true }, + }); + defaultMetadataStorage.addExposeMetadata({ + target: object instanceof Function ? object : object.constructor, + propertyName: propertyName as string, + options: { name: options.to || (propertyName as string), toPlainOnly: true }, + }); + }; +} diff --git a/src/decorators/expose.decorator.ts b/src/decorators/expose.decorator.ts index 2f3110d97..e0a6d2d20 100644 --- a/src/decorators/expose.decorator.ts +++ b/src/decorators/expose.decorator.ts @@ -16,10 +16,15 @@ export function Expose(options: ExposeOptions = {}): PropertyDecorator & ClassDe * decorator only receives one parameter. */ return function (object: any, propertyName?: string | Symbol): void { - defaultMetadataStorage.addExposeMetadata({ + const metadata = { target: object instanceof Function ? object : object.constructor, propertyName: propertyName as string, - options, - }); + options: { + ...options, + name: options.name || (propertyName as string), + }, + }; + + defaultMetadataStorage.addExposeMetadata(metadata); }; } diff --git a/src/decorators/index.ts b/src/decorators/index.ts index bd2475c9e..9e9ac5eb6 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -1,5 +1,6 @@ export * from './exclude.decorator'; export * from './expose.decorator'; +export * from './alias.decorator'; export * from './transform-instance-to-instance.decorator'; export * from './transform-instance-to-plain.decorator'; export * from './transform-plain-to-instance.decorator'; diff --git a/src/interfaces/decorator-options/alias-options.interface.ts b/src/interfaces/decorator-options/alias-options.interface.ts new file mode 100644 index 000000000..7cb43a8f2 --- /dev/null +++ b/src/interfaces/decorator-options/alias-options.interface.ts @@ -0,0 +1,13 @@ +/** + * Possible transformation options for the @Alias decorator. + */ +export interface AliasOptions { + /** + * Name of property on the source object to read from + */ + from?: string; + /** + * Name of property on the target object to expose as + */ + to?: string; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 4a1d1aa5b..05eb6138a 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,5 +1,6 @@ -export * from './decorator-options/expose-options.interface'; export * from './decorator-options/exclude-options.interface'; +export * from './decorator-options/expose-options.interface'; +export * from './decorator-options/alias-options.interface'; export * from './decorator-options/transform-options.interface'; export * from './decorator-options/type-discriminator-descriptor.interface'; export * from './decorator-options/type-options.interface'; diff --git a/test/functional/transformation-option.spec.ts b/test/functional/transformation-option.spec.ts index 9bae57cf8..2813b1a9d 100644 --- a/test/functional/transformation-option.spec.ts +++ b/test/functional/transformation-option.spec.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import { instanceToPlain, plainToInstance } from '../../src/index'; import { defaultMetadataStorage } from '../../src/storage'; -import { Exclude, Expose } from '../../src/decorators'; +import { Alias, Exclude, Expose } from '../../src/decorators'; describe('filtering by transformation option', () => { it('@Exclude with toPlainOnly set to true then it should be excluded only during instanceToPlain and classToPlainFromExist operations', () => { @@ -80,7 +80,7 @@ describe('filtering by transformation option', () => { }); }); - it('@Expose with toClassOnly set to true then it should be excluded only during instanceToPlain and classToPlainFromExist operations', () => { + it('@Expose with toClassOnly set to true and a name then it should be exposed renamed when transformed to instance', () => { defaultMetadataStorage.clear(); @Exclude() @@ -91,7 +91,7 @@ describe('filtering by transformation option', () => { @Expose() lastName: string; - @Expose({ toClassOnly: true }) + @Expose({ name: 'pwd', toClassOnly: true }) password: string; } @@ -103,7 +103,7 @@ describe('filtering by transformation option', () => { const plainUser = { firstName: 'Umed', lastName: 'Khudoiberdiev', - password: 'imnosuperman', + pwd: 'imnosuperman', }; const plainedUser = instanceToPlain(user); @@ -121,7 +121,7 @@ describe('filtering by transformation option', () => { }); }); - it('@Expose with toPlainOnly set to true then it should be excluded only during instanceToPlain and classToPlainFromExist operations', () => { + it('@Expose with toPlainOnly set to true and a name then it should be exposed renamed when transformed to plain', () => { defaultMetadataStorage.clear(); @Exclude() @@ -132,7 +132,7 @@ describe('filtering by transformation option', () => { @Expose() lastName: string; - @Expose({ toPlainOnly: true }) + @Expose({ name: 'pwd', toPlainOnly: true }) password: string; } @@ -147,6 +147,48 @@ describe('filtering by transformation option', () => { password: 'imnosuperman', }; + const plainedUser = instanceToPlain(user); + expect(plainedUser).toEqual({ + firstName: 'Umed', + lastName: 'Khudoiberdiev', + pwd: 'imnosuperman', + }); + + const classedUser = plainToInstance(User, plainUser); + expect(classedUser).toBeInstanceOf(User); + expect(classedUser).toEqual({ + firstName: 'Umed', + lastName: 'Khudoiberdiev', + }); + }); + + it('@Expose with toPlainOnly set to true and a name plus another @Expose with toClassOnly set to true and a name then it should be renamed appropriatly based on operation type', () => { + defaultMetadataStorage.clear(); + + @Exclude() + class User { + @Expose() + firstName: string; + + @Expose() + lastName: string; + + @Expose({ toPlainOnly: true }) + @Expose({ name: 'toClassPassword', toClassOnly: true }) + password: string; + } + + const user = new User(); + user.firstName = 'Umed'; + user.lastName = 'Khudoiberdiev'; + user.password = 'imnosuperman'; + + const plainUser = { + firstName: 'Umed', + lastName: 'Khudoiberdiev', + toClassPassword: 'imnosuperman', + }; + const plainedUser = instanceToPlain(user); expect(plainedUser).toEqual({ firstName: 'Umed', @@ -159,6 +201,95 @@ describe('filtering by transformation option', () => { expect(classedUser).toEqual({ firstName: 'Umed', lastName: 'Khudoiberdiev', + password: 'imnosuperman', + }); + }); + + it('@Alias with className only should only rename property when converted to plain', () => { + defaultMetadataStorage.clear(); + + @Exclude() + class User { + @Alias({ to: 'first_name' }) + firstName: string; + } + + const user = new User(); + user.firstName = 'Umed'; + + const plainUser = { + firstName: 'Umed', + first_name: 'WRONG', + }; + + const plainedUser = instanceToPlain(user); + expect(plainedUser).toEqual({ + first_name: 'Umed', + }); + + const classedUser = plainToInstance(User, plainUser); + expect(classedUser).toBeInstanceOf(User); + expect(classedUser).toEqual({ + firstName: 'Umed', + }); + }); + + it('@Alias with className only should only rename property when converted to plain', () => { + defaultMetadataStorage.clear(); + + @Exclude() + class User { + @Alias({ from: 'first_name' }) + firstName: string; + } + + const user = new User(); + user.firstName = 'Umed'; + + const plainUser = { + first_name: 'Umed', + firstName: 'WRONG', + }; + + const plainedUser = instanceToPlain(user); + expect(plainedUser).toEqual({ + firstName: 'Umed', + }); + + const classedUser = plainToInstance(User, plainUser); + expect(classedUser).toBeInstanceOf(User); + expect(classedUser).toEqual({ + firstName: 'Umed', + }); + }); + + it('@Alias with both className and plainName should rename property on transform', () => { + defaultMetadataStorage.clear(); + + @Exclude() + class User { + @Alias({ from: 'user_reference', to: 'userReference' }) + reference: string; + } + + const user = new User(); + user.reference = 'userRef'; + + const plainUser = { + user_reference: 'userRef', + userReference: 'WRONG', + reference: 'WRONG', + }; + + const plainedUser = instanceToPlain(user); + expect(plainedUser).toEqual({ + userReference: 'userRef', + }); + + const classedUser = plainToInstance(User, plainUser); + expect(classedUser).toBeInstanceOf(User); + expect(classedUser).toEqual({ + reference: 'userRef', }); });