type-graphql icon indicating copy to clipboard operation
type-graphql copied to clipboard

Types transformation utils

Open MichalLytek opened this issue 4 years ago • 18 comments

The upcoming Prisma 2 integration (#217) will generate TS classes with TypeGraphQL decorators based on data model definition of Prisma schema.

But to allow for further customization, TypeGraphQL needs some operators to transform the generated classes into the user one. Mainly to hide some fields from database like user password, so we need a way to omit some fields of the base class or pick only some selected fields.

Proposed API have two approaches to apply the transformation:

  • pick/omit fields from emitted GraphQL schema using decorator option
@ObjectType({ pick: ["firstname", "lastname"] })
export class User extends BaseUser {
  // ...
}

@ObjectType({ omit: ["password", "salary"] })
export class User extends BaseUser {
  // ...
}

This approach is for use cases where you still want to have access to the hidden fields in other field resolvers, like hidding array or rates but exposing average rating by the field resolver.

  • pick/omit fields both from emitted GraphQL schema and TS type using class wrapper
@ObjectType()
export class User extends Pick(BaseUser, ["firstname", "lastname"]) {
  // ...
}

@ObjectType()
export class User extends Omit(BaseUser, ["password", "salary"]) {
  // ...
}

This approach is better when you want to create a derived type, like a subset of some input type, so you won't accidentally use the not existing field.

Initial version might not support inheritance or have a prototype methods leaks, because it's not needed by the Prisma integration. In the next release cycle I will try to make it work with broader range of use cases.

Later, more types transformation utils will be implemented, like Partial(Foo) for making fields optional, Required(Foo) for making fields non-nullable.

If possible, maybe I will try to add even a mapping util that will map the keys of one type to new values in a new type. For example if you want to generate a sorting input (with field types only ASC/DESC) based on an object type which fields are representing database columns 😉

MichalLytek avatar Oct 30 '19 18:10 MichalLytek

Later, more types transformation utils will be implemented, like Partial(Foo) for making fields optional, Required(Foo) for making fields non-nullable.

For anyone else looking for this functionality now, I was able to hack together this partial type factory function by directly modifying the metadata storage object. Not ideal as this is a private api but it seems to do the trick so far. It just copies fields of the given type and creates a new type with the same fields, only all nullable.

import { ClassType } from 'type-graphql'

// NOTE: This only works for object types, not input types

export function PartialType<E>(EntityClass: ClassType<E>): any {
  const metadata = (global as any).TypeGraphQLMetadataStorage
  class PartialClass {}
  const name = `${EntityClass.name}Partial`
  Object.defineProperty(PartialClass, 'name', { value: name })

  // Create a new object type
  const newObjectType = {
    name,
    target: PartialClass,
    description: `Partial type for ${EntityClass.name}`
  }
  metadata.objectTypes.push(newObjectType)

  // Copy relevant fields and create a nullable version on the new type
  const fields = metadata.fields.filter(
    f => f.target === EntityClass || EntityClass.prototype instanceof f.target
  )
  fields.forEach(field => {
    const newField = {
      ...field,
      typeOptions: { ...field.typeOptions, nullable: true },
      target: PartialClass
    }
    metadata.fields.push(newField)
  })

  return PartialClass
}

Use it like so:

@ObjectType()
export class User {
  @Field()
  name: string

  @Field()
  email: string

  // ...
}

export const UserPartial = PartialType(User)

amille14 avatar Oct 31 '19 18:10 amille14

And for anyone else looking for pick/omit functionality now, I would recommend using mixin classes to compose bigger types, rather than picking/omitting from bigger types to create a small ones: https://github.com/MichalLytek/type-graphql/tree/master/examples/mixin-classes

MichalLytek avatar Oct 31 '19 18:10 MichalLytek

Could something like @Field({ nullable: { objType: false, inputType: true } }) be considered instead, in order to remove duplication between ObjectTypes and InputTypes and allow specifying different behavior?

Or create a separate decorator for @InputField(...) in addition of @Field(..)?

Seems like it would be more powerful and concise than a Partial(...) mixin.

andreialecu avatar Dec 18 '19 13:12 andreialecu

I think that nullable is not enough - you don't want to accept id field in "edit" mutation, so you need to pick/omit the fields.

Also, this will give you false positive if you want to use the same class as the type in the mutation, so you may thing that the value will exist and in runtime it will be nullable.

That's why it's better to describe it as Input = Partial(Pick(Output, field)) which will work both for type signature, as well as for the schema part.

MichalLytek avatar Dec 18 '19 13:12 MichalLytek

Alright, makes sense. There should be a way to make certain fields required as well, a combination of both Partial and also Required somehow. Not sure how that would look though.

andreialecu avatar Dec 18 '19 14:12 andreialecu

In that case I think that the mixins pattern makes more sense than trying to combine Partial and Required - it would be much more maintainable I think 😉

MichalLytek avatar Dec 18 '19 14:12 MichalLytek

Here's a Partial mixin based on @amille14 's code above, one that works for both InputType and ObjectType.

import { ClassType, InputType, ObjectType } from 'type-graphql';

export default function PartialType<TClassType extends ClassType>(
  BaseClass: TClassType,
) {
  const metadata = (global as any).TypeGraphQLMetadataStorage;

  @ObjectType({ isAbstract: true })
  @InputType({ isAbstract: true })
  class PartialClass extends BaseClass {}

  // Copy relevant fields and create a nullable version on the new type
  const fields = metadata.fields.filter(
    f => f.target === BaseClass || BaseClass.prototype instanceof f.target,
  );
  fields.forEach(field => {
    const newField = {
      ...field,
      typeOptions: { ...field.typeOptions, nullable: true },
      target: PartialClass,
    };
    metadata.fields.push(newField);
  });

  return PartialClass;
}

use like:

@InputType()
export class SomethingInput extends PartialType(
  SomethingModelBase,
) {}

andreialecu avatar Dec 18 '19 14:12 andreialecu

Be aware that the internal metadata storage might be changed without any notice between releases, so it's not recommended to do that kind of hacks.

MichalLytek avatar Dec 18 '19 14:12 MichalLytek

@MichalLytek I'm personally well aware. Hopefully you can include such functionality in the core soon, so we can get rid of the workaround. This is one of the main issues users would run into as soon as their API evolves into something CRUD-like.

andreialecu avatar Dec 18 '19 17:12 andreialecu

~~I don't use this any more but you should be able to change the return to return PartialClass as Partial<TClassType>~~

andreialecu avatar Jul 27 '20 09:07 andreialecu

I don't use this any more but you should be able to change the return to return PartialClass as Partial<TClassType>

@andreialecu That won't work in the extends, @InputType() export class SomethingInput extends PartialType(SomethingModelBase){} Type 'Partial<typeof SomethingModelBase>' is not a constructor function type.ts(2507)

AmrAnwar avatar Jul 27 '20 10:07 AmrAnwar

try this monster 😄 return PartialClass as ClassType<Partial<InstanceType<TClassType>>>

MichalLytek avatar Jul 27 '20 10:07 MichalLytek

as ClassType<Partial<InstanceType<TClassType>>>

Oh that's worked :D thanks, I've already added ClassType<Partial<>> but I got another error when adding another types that have the same fields types but this line worked :D

AmrAnwar avatar Jul 27 '20 10:07 AmrAnwar

Curious if there's been any progress on this? Also curious how I'd get involved in helping out with this if not, would be super useful for something I'm doing at the moment!

rtnolan avatar Oct 19 '20 14:10 rtnolan

It would be very useful to avoid code duplication if this is implemented in the library itself.

Pablo1107 avatar Mar 29 '21 16:03 Pablo1107

I don't use this any more but you should be able to change the return to return PartialClass as Partial<TClassType>

@andreialecu

Curious why you no longer use this PartialClass approach, I was going to create an Input type class factory based on it, wonder if there were pitfalls that you ran into later with TypeGraphQL? The other recommended approach of mixins/traits is safer, but gets pretty tedious and probably a maintenance hassle once you have multiple fields that are nested input types

theseyi avatar May 29 '21 04:05 theseyi

Just citing @ChrisLahaye from https://github.com/MichalLytek/type-graphql/pull/1295#issuecomment-1209744547:

Hi! We published our transformation utils to the type-graphql-utils package. It exports Pick, Required, Partial, and Omit. It doesn't have any specific code for class-validator like this PR, so I am not sure whether that works as we don't use it.

itpropro avatar Aug 12 '22 21:08 itpropro

What do you guys think about support Partial with "deep" options so that the partial is applied into nested classes?

thongxuan avatar Jun 14 '23 08:06 thongxuan