typestack / class-transformer

Decorator-based transformation, serialization, and deserialization between objects and classes.
MIT License
6.78k stars 495 forks source link

fix: Exposing properties with different names does not work with nested objects. #722

Open dilizarov opened 3 years ago

dilizarov commented 3 years ago

Description

I'm trying to expose a custom property on a nested object, but it returns undefined instead of calling the getter.

Minimal code-snippet showcasing the problem

@Exclude()
class SubEntity {
  @Expose()
  @Transform(({ obj }) => obj.name.toUpperCase())
  working: string;

  @Expose()
  name: string;

  @Expose({ name: "broken" })
  get entity() {
    return this.name.toUpperCase();
  }
}
@Exclude()
export class Test {
  @Expose({ name: "name" })
  getName() {
    return "This gets exposed";
  }

  @Expose()
  @Type(() => SubEntity)
  entity: SubEntity;

  constructor() {
    Object.assign(this, { entity: { name: "name" } });
  }
}

new ClassTransformer().classToPlain(new Test());

Expected behavior

I expect the following output:

{
  name: 'This gets exposed',
  entity: { working: 'NAME', name: 'name', broken: 'NAME' }
}

Actual behavior

This is what I get:

{
  name: 'This gets exposed',
  entity: { working: 'NAME', name: 'name', broken: undefined }
}

This is a bug and forces me to use @Transform instead of @Expose as desired.

muh-hizbe commented 3 years ago

I have some problem with you. Please show me @Transform decorator usage for custom different property name.

cojack commented 2 years ago

Confirmed, it's broken

calebpitan commented 2 years ago

Not only nested object, but even direct objects exposed under a different name are undefined.

I wanted to just directly expose id as _id for mongoose/mongodb sake.

This is the workaround I came up with, downside being that both properties are now present with the same value on the object when only one is needed.

export class FreelancerSortInput extends TimeStampsSortInput {
  @IsEnum(Sort)
  @IsOptional()
  @Transform(o => o.obj.id)
  @Expose()
  _id?: Sort

  @Field(() => Sort, {
    nullable: true,
    description: 'Sort by ID either ascending (ASC) or descrending (DESC)',
  })
  @IsEnum(Sort)
  @IsOptional()
  id?: Sort
}
nvs2394 commented 2 years ago

Any update on this issue ?

I have the same issue when exposing nested objects in an array.

hsellik commented 2 years ago

Facing the exact same issue, is it still borken?

BlakeB415 commented 2 years ago

Same issue. The only workaround is to use transform instead of @Type which is not ideal.

uythoang commented 1 year ago

As @calebpitan mentioned, even direct objects exposed under a different name are undefined too. Using the example in the README, id will be undefined if uid isn't a property in the source object:

export class User {
  @Expose({ name: 'uid' })
  id: number;
}

However, this will work using Transform with id taking on uid's value:

export class User {
  @Transform(({ obj, value }) => value ? value : obj.uid))
  id: number;
}

And if uid isn't a property on the source object, id will still have a value.

nolawnchairs commented 1 year ago

Broken confirmed.

I use the following to transform a JWT with its shorthand properties to more human-readable properties. These shorthand properties should be transformed into the 3-character properties when transforming to plain, so serialization will mirror the original input.

This works fine when MemberJwt is transformed by itself, but when nested inside the Session class, things fall apart.

import 'reflect-metadata'
import { Expose, instanceToPlain, plainToInstance, Type } from 'class-transformer'

class MemberJwt {

  @Expose({ name: 'iss' })
  issuser: string

  @Expose({ name: 'sub' })
  subject: string
}

class Session {

  id: string

  @Type(() => MemberJwt)
  member: MemberJwt
}

const member = plainToInstance(MemberJwt, { iss: 'test', sub: '123' })
const memberPlain = instanceToPlain(member)
const session = plainToInstance(Session, { id: '333', member })

it('should transform member JWT to instance using @Expose aliases', () => {
  expect(member.issuser).toBe('test')
  expect(member.subject).toBe('123')
})

it('should transform member JWT to shorthand props', () => {
  expect(memberPlain.iss).toBe('test')
  expect(memberPlain.sub).toBe('123')
})

it('should retain member JWT instance properties within transformed Session instance', () => {
  expect(session.id).toBe('333')
  expect(session.member).toBeDefined()
  expect(session.member.issuser).toBe('test')
  expect(session.member.subject).toBe('123')
})

it('should not retain @Expose alias properties within transformed Session instance', () => {
  expect(session.member['iss']).toBeUndefined()
  expect(session.member['sub']).toBeUndefined()
})

The fix I found is to not use @Expose at all, and use getters:

class MemberJwt {

  private iss: string
  private sub: string

  get issuser(): string {
    return this.iss
  }

  get subject(): string {
    return this.sub
  }
}
hsulipe commented 1 year ago

Had the same issue, using getters worked for me. Thanks @nolawnchairs

RobbyKetchell commented 10 months ago

This works for me when I invert the name of the property with the options name for the Expose decorator:

@Expose({ name: 'originalPropertyName'})
newPropertyName: number;