From 7ff26058bef96349c72dae497bcfcf5fdab25879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sat, 16 Feb 2019 20:01:04 +0100 Subject: [PATCH 1/4] feat(generics): add support for generic types --- CHANGELOG.md | 1 + src/decorators/InputType.ts | 13 ++++++++----- src/decorators/InterfaceType.ts | 13 ++++++++----- src/decorators/ObjectType.ts | 10 ++++++---- src/decorators/types.ts | 7 ++++--- src/metadata/definitions/class-metadata.ts | 1 + src/schema/schema-generator.ts | 21 ++++++++++++++------- 7 files changed, 42 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfce8e10d..7bf3c4bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **Breaking Change**: change the default `PrintSchemaOptions` option `commentDescriptions` to false (no more `#` comments in SDL) - add support for passing `PrintSchemaOptions` in `buildSchema.emitSchemaFile` (e.g. `commentDescriptions: true` to restore previous behavior) - add `buildTypeDefsAndResolvers` utils function for generating apollo-like `typeDefs` and `resolvers` pair (#233) +- add support for generic types (#255) ## Fixes - fix calling return type getter function `@Field(type => Foo)` before finishing module evaluation (allow for extending circular classes using `require`) diff --git a/src/decorators/InputType.ts b/src/decorators/InputType.ts index cf932c9bc..5b54b28a6 100644 --- a/src/decorators/InputType.ts +++ b/src/decorators/InputType.ts @@ -1,13 +1,15 @@ import { getMetadataStorage } from "../metadata/getMetadataStorage"; import { getNameDecoratorParams } from "../helpers/decorators"; -import { DescriptionOptions } from "./types"; +import { DescriptionOptions, AbstractClassOptions } from "./types"; + +export type InputTypeOptions = DescriptionOptions & AbstractClassOptions; export function InputType(): ClassDecorator; -export function InputType(options: DescriptionOptions): ClassDecorator; -export function InputType(name: string, options?: DescriptionOptions): ClassDecorator; +export function InputType(options: InputTypeOptions): ClassDecorator; +export function InputType(name: string, options?: InputTypeOptions): ClassDecorator; export function InputType( - nameOrOptions?: string | DescriptionOptions, - maybeOptions?: DescriptionOptions, + nameOrOptions?: string | InputTypeOptions, + maybeOptions?: InputTypeOptions, ): ClassDecorator { const { name, options } = getNameDecoratorParams(nameOrOptions, maybeOptions); return target => { @@ -15,6 +17,7 @@ export function InputType( name: name || target.name, target, description: options.description, + isAbstract: options.isAbstract, }); }; } diff --git a/src/decorators/InterfaceType.ts b/src/decorators/InterfaceType.ts index 20f69db8b..5befd5e36 100644 --- a/src/decorators/InterfaceType.ts +++ b/src/decorators/InterfaceType.ts @@ -1,13 +1,15 @@ import { getMetadataStorage } from "../metadata/getMetadataStorage"; import { getNameDecoratorParams } from "../helpers/decorators"; -import { DescriptionOptions } from "./types"; +import { DescriptionOptions, AbstractClassOptions } from "./types"; + +export type InterfaceOptions = DescriptionOptions & AbstractClassOptions; export function InterfaceType(): ClassDecorator; -export function InterfaceType(options: DescriptionOptions): ClassDecorator; -export function InterfaceType(name: string, options?: DescriptionOptions): ClassDecorator; +export function InterfaceType(options: InterfaceOptions): ClassDecorator; +export function InterfaceType(name: string, options?: InterfaceOptions): ClassDecorator; export function InterfaceType( - nameOrOptions?: string | DescriptionOptions, - maybeOptions?: DescriptionOptions, + nameOrOptions?: string | InterfaceOptions, + maybeOptions?: InterfaceOptions, ): ClassDecorator { const { name, options } = getNameDecoratorParams(nameOrOptions, maybeOptions); return target => { @@ -15,6 +17,7 @@ export function InterfaceType( name: name || target.name, target, description: options.description, + isAbstract: options.isAbstract, }); }; } diff --git a/src/decorators/ObjectType.ts b/src/decorators/ObjectType.ts index e0f5b262f..d8bb82953 100644 --- a/src/decorators/ObjectType.ts +++ b/src/decorators/ObjectType.ts @@ -1,11 +1,12 @@ import { getMetadataStorage } from "../metadata/getMetadataStorage"; import { getNameDecoratorParams } from "../helpers/decorators"; -import { DescriptionOptions } from "./types"; +import { DescriptionOptions, AbstractClassOptions } from "./types"; import { ClassType } from "../interfaces"; -export type ObjectOptions = DescriptionOptions & { - implements?: Function | Function[]; -}; +export type ObjectOptions = DescriptionOptions & + AbstractClassOptions & { + implements?: Function | Function[]; + }; export function ObjectType(): ClassDecorator; export function ObjectType(options: ObjectOptions): ClassDecorator; @@ -24,6 +25,7 @@ export function ObjectType( target, description: options.description, interfaceClasses, + isAbstract: options.isAbstract, }); }; } diff --git a/src/decorators/types.ts b/src/decorators/types.ts index 9a6ec1645..89e998ab0 100644 --- a/src/decorators/types.ts +++ b/src/decorators/types.ts @@ -44,6 +44,9 @@ export interface ComplexityOptions { export interface SchemaNameOptions { name?: string; } +export interface AbstractClassOptions { + isAbstract?: boolean; +} export type BasicOptions = DecoratorTypeOptions & DescriptionOptions; export type AdvancedOptions = BasicOptions & DepreciationOptions & @@ -57,6 +60,4 @@ export interface EnumConfig { export type MethodAndPropDecorator = PropertyDecorator & MethodDecorator; -export interface ResolverClassOptions { - isAbstract?: boolean; -} +export type ResolverClassOptions = AbstractClassOptions; diff --git a/src/metadata/definitions/class-metadata.ts b/src/metadata/definitions/class-metadata.ts index fe42f793a..358da6eb6 100644 --- a/src/metadata/definitions/class-metadata.ts +++ b/src/metadata/definitions/class-metadata.ts @@ -6,4 +6,5 @@ export interface ClassMetadata { fields?: FieldMetadata[]; description?: string; interfaceClasses?: Function[]; + isAbstract?: boolean; } diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index ef27df101..2d0d6093a 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -42,17 +42,20 @@ import { ResolverFilterData, ResolverTopicData } from "../interfaces"; import { getFieldMetadataFromInputType, getFieldMetadataFromObjectType } from "./utils"; import { ensureInstalledCorrectGraphQLPackage } from "../utils/graphql-version"; -interface ObjectTypeInfo { +interface AbstractInfo { + isAbstract: boolean; +} +interface ObjectTypeInfo extends AbstractInfo { target: Function; type: GraphQLObjectType; } -interface InputObjectTypeInfo { +interface InterfaceTypeInfo extends AbstractInfo { target: Function; - type: GraphQLInputObjectType; + type: GraphQLInterfaceType; } -interface InterfaceTypeInfo { +interface InputObjectTypeInfo extends AbstractInfo { target: Function; - type: GraphQLInterfaceType; + type: GraphQLInputObjectType; } interface EnumTypeInfo { enumObj: object; @@ -181,6 +184,7 @@ export abstract class SchemaGenerator { }; return { target: interfaceType.target, + isAbstract: interfaceType.isAbstract || false, type: new GraphQLInterfaceType({ name: interfaceType.name, description: interfaceType.description, @@ -222,6 +226,7 @@ export abstract class SchemaGenerator { const interfaceClasses = objectType.interfaceClasses || []; return { target: objectType.target, + isAbstract: objectType.isAbstract || false, type: new GraphQLObjectType({ name: objectType.name, description: objectType.description, @@ -306,6 +311,7 @@ export abstract class SchemaGenerator { const inputInstance = new (inputType.target as any)(); return { target: inputType.target, + isAbstract: inputType.isAbstract || false, type: new GraphQLInputObjectType({ name: inputType.name, description: inputType.description, @@ -374,8 +380,9 @@ export abstract class SchemaGenerator { // TODO: investigate the need of directly providing this types // maybe GraphQL can use only the types provided indirectly return [ - ...this.objectTypesInfo.map(it => it.type), - ...this.interfaceTypesInfo.map(it => it.type), + ...this.objectTypesInfo.filter(it => !it.isAbstract).map(it => it.type), + ...this.interfaceTypesInfo.filter(it => !it.isAbstract).map(it => it.type), + ...this.inputTypesInfo.filter(it => !it.isAbstract).map(it => it.type), ]; } From c91ae35f93a109d9f95f7cdbb725a04fd7dff94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sat, 16 Feb 2019 20:01:40 +0100 Subject: [PATCH 2/4] test(generics): add suite for generic types support --- tests/functional/generic-types.ts | 496 ++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 tests/functional/generic-types.ts diff --git a/tests/functional/generic-types.ts b/tests/functional/generic-types.ts new file mode 100644 index 000000000..543e93439 --- /dev/null +++ b/tests/functional/generic-types.ts @@ -0,0 +1,496 @@ +import "reflect-metadata"; +import { + IntrospectionObjectType, + IntrospectionInterfaceType, + IntrospectionNonNullTypeRef, + IntrospectionScalarType, + TypeKind, + IntrospectionListTypeRef, + graphql, + GraphQLSchema, + IntrospectionSchema, + IntrospectionInputObjectType, +} from "graphql"; + +import { getSchemaInfo } from "../helpers/getSchemaInfo"; +import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; +import { + ObjectType, + Field, + Resolver, + Query, + InterfaceType, + ClassType, + Int, + InputType, + Arg, +} from "../../src"; + +describe("Generic types", () => { + beforeEach(() => { + getMetadataStorage().clear(); + }); + + it("shouldn't emit abstract object type", async () => { + @ObjectType({ isAbstract: true }) + abstract class BaseType { + @Field() + baseField: string; + } + + @ObjectType() + class SampleType extends BaseType { + @Field() + sampleField: string; + } + + @Resolver() + class SampleResolver { + @Query() + sampleQuery(): SampleType { + return { + sampleField: "sampleField", + baseField: "baseField", + }; + } + } + + const { schemaIntrospection } = await getSchemaInfo({ resolvers: [SampleResolver] }); + + const sampleTypeInfo = schemaIntrospection.types.find( + it => it.name === "SampleType", + ) as IntrospectionObjectType; + const baseTypeInfo = schemaIntrospection.types.find(it => it.name === "BaseType") as undefined; + + expect(sampleTypeInfo.fields).toHaveLength(2); + expect(baseTypeInfo).toBeUndefined(); + }); + + it("shouldn't emit abstract interface type", async () => { + @InterfaceType({ isAbstract: true }) + abstract class BaseInterfaceType { + @Field() + baseField: string; + } + + @InterfaceType() + abstract class SampleInterfaceType extends BaseInterfaceType { + @Field() + sampleField: string; + } + + @ObjectType({ implements: SampleInterfaceType }) + class SampleType implements SampleInterfaceType { + baseField: string; + sampleField: string; + } + + @Resolver() + class SampleResolver { + @Query() + sampleQuery(): SampleInterfaceType { + const sample = new SampleType(); + sample.baseField = "baseField"; + sample.sampleField = "sampleField"; + return sample; + } + } + + const { schemaIntrospection } = await getSchemaInfo({ resolvers: [SampleResolver] }); + + const sampleInterfaceTypeInfo = schemaIntrospection.types.find( + it => it.name === "SampleInterfaceType", + ) as IntrospectionInterfaceType; + const baseInterfaceTypeInfo = schemaIntrospection.types.find( + it => it.name === "BaseInterfaceType", + ) as undefined; + + expect(sampleInterfaceTypeInfo.fields).toHaveLength(2); + expect(baseInterfaceTypeInfo).toBeUndefined(); + }); + + it("shouldn't emit abstract input object type", async () => { + @InputType({ isAbstract: true }) + abstract class BaseInput { + @Field() + baseField: string; + } + + @InputType() + class SampleInput extends BaseInput { + @Field() + sampleField: string; + } + + @Resolver() + class SampleResolver { + @Query() + sampleQuery(@Arg("input") input: SampleInput): boolean { + return true; + } + } + + const { schemaIntrospection } = await getSchemaInfo({ resolvers: [SampleResolver] }); + + const sampleInputInfo = schemaIntrospection.types.find( + it => it.name === "SampleInput", + ) as IntrospectionInputObjectType; + const baseInputInfo = schemaIntrospection.types.find( + it => it.name === "BaseInput", + ) as undefined; + + expect(sampleInputInfo.inputFields).toHaveLength(2); + expect(baseInputInfo).toBeUndefined(); + }); + + describe("multiple children of base generic class", async () => { + let schema: GraphQLSchema; + let schemaIntrospection: IntrospectionSchema; + let dogsResponseMock: any; + + beforeEach(async () => { + function Connection(TItemClass: ClassType) { + @ObjectType(`${TItemClass.name}Connection`, { isAbstract: true }) + class ConnectionClass { + @Field(type => Int) + count: number; + + @Field(type => [TItemClass]) + items: TItem[]; + } + return ConnectionClass; + } + + @ObjectType() + class User { + @Field() + name: string; + } + + @ObjectType() + class Dog { + @Field() + canBark: boolean; + } + + const UserConnection = Connection(User); + type UserConnection = InstanceType; + @ObjectType() + class DogConnection extends Connection(Dog) {} + + // FIXME: refactor after inheritance instance fix + dogsResponseMock = new DogConnection(); + Object.assign(dogsResponseMock, { + count: 2, + items: [{ canBark: false }, { canBark: true }], + }); + + @Resolver() + class GenericConnectionResolver { + @Query(returns => UserConnection) + users(): UserConnection { + return { + count: 2, + items: [{ name: "Tony" }, { name: "Michael" }], + }; + } + + @Query(returns => DogConnection) + dogs(): DogConnection { + return dogsResponseMock; + } + } + + ({ schema, schemaIntrospection } = await getSchemaInfo({ + resolvers: [GenericConnectionResolver], + })); + }); + + it("should register proper types in schema using const and class syntax", async () => { + const schemaObjectTypes = schemaIntrospection.types.filter( + it => it.kind === TypeKind.OBJECT && !it.name.startsWith("__"), + ); + const userConnectionTypeInfo = schemaObjectTypes.find( + it => it.name === "UserConnection", + ) as IntrospectionObjectType; + const userConnectionCountField = userConnectionTypeInfo.fields.find( + it => it.name === "count", + )!; + const userConnectionCountFieldType = (userConnectionCountField.type as IntrospectionNonNullTypeRef) + .ofType as IntrospectionScalarType; + const userConnectionItemsField = userConnectionTypeInfo.fields.find( + it => it.name === "items", + )!; + const userConnectionItemsFieldType = (((userConnectionItemsField.type as IntrospectionNonNullTypeRef) + .ofType as IntrospectionListTypeRef).ofType as IntrospectionNonNullTypeRef) + .ofType as IntrospectionObjectType; + + expect(schemaObjectTypes).toHaveLength(5); // Query, User, Dog, UserCon, DogCon + expect(userConnectionTypeInfo.fields).toHaveLength(2); + expect(userConnectionCountFieldType.kind).toBe(TypeKind.SCALAR); + expect(userConnectionCountFieldType.name).toBe("Int"); + expect(userConnectionItemsFieldType.kind).toBe(TypeKind.OBJECT); + expect(userConnectionItemsFieldType.name).toBe("User"); + }); + + it("should return child class data from query", async () => { + const query = /* graphql */ ` + query { + dogs { + count + items { + canBark + } + } + } + `; + + const result = await graphql(schema, query); + + expect(result.data!.dogs).toEqual(dogsResponseMock); + }); + }); + + describe("adding new properties in child class", () => { + let schema: GraphQLSchema; + let schemaIntrospection: IntrospectionSchema; + let recipeEdgeResponse: any; + let friendshipEdgeResponse: any; + + beforeEach(async () => { + function Edge(NodeClass: ClassType) { + @ObjectType({ isAbstract: true }) + abstract class EdgeClass { + @Field(type => NodeClass) + node: TNodeClass; + + @Field() + cursor: string; + } + return EdgeClass; + } + + @ObjectType() + class Recipe { + @Field() + title: string; + } + + @ObjectType() + class User { + @Field() + name: string; + } + + @ObjectType() + class RecipeEdge extends Edge(Recipe) { + @Field() + personalNotes: string; + } + recipeEdgeResponse = { + cursor: "recipeCursor", + node: { + title: "recipeTitle", + }, + personalNotes: "recipePersonalNotes", + }; + + @ObjectType() + class FriendshipEdge extends Edge(User) { + @Field() + friendedAt: Date; + } + friendshipEdgeResponse = { + cursor: "friendshipCursor", + node: { + name: "userName", + }, + friendedAt: new Date(), + }; + + @Resolver() + class EdgeResolver { + @Query() + recipeEdge(): RecipeEdge { + // TODO: refactor when inheritance fix is ready + const response = new RecipeEdge(); + Object.assign(response, recipeEdgeResponse); + return response; + } + + @Query() + friendshipEdge(): FriendshipEdge { + // TODO: refactor when inheritance fix is ready + const response = new FriendshipEdge(); + Object.assign(response, friendshipEdgeResponse); + return response; + } + } + + ({ schema, schemaIntrospection } = await getSchemaInfo({ + resolvers: [EdgeResolver], + })); + }); + + it("should register fields properly in schema", async () => { + const schemaObjectTypes = schemaIntrospection.types.filter( + it => it.kind === TypeKind.OBJECT && !it.name.startsWith("__"), + ); + const recipeEdgeTypeInfo = schemaObjectTypes.find( + it => it.name === "RecipeEdge", + ) as IntrospectionObjectType; + const recipeEdgeNodeField = recipeEdgeTypeInfo.fields.find(it => it.name === "node")!; + const recipeEdgeNodeFieldType = (recipeEdgeNodeField.type as IntrospectionNonNullTypeRef) + .ofType as IntrospectionObjectType; + const recipeEdgePersonalNotesField = recipeEdgeTypeInfo.fields.find( + it => it.name === "personalNotes", + )!; + const recipeEdgePersonalNotesFieldType = (recipeEdgePersonalNotesField.type as IntrospectionNonNullTypeRef) + .ofType as IntrospectionObjectType; + const friendshipEdgeTypeInfo = schemaObjectTypes.find( + it => it.name === "FriendshipEdge", + ) as IntrospectionObjectType; + const friendshipEdgeNodeField = friendshipEdgeTypeInfo.fields.find(it => it.name === "node")!; + const friendshipEdgeNodeFieldType = (friendshipEdgeNodeField.type as IntrospectionNonNullTypeRef) + .ofType as IntrospectionObjectType; + const friendshipEdgeFriendedAtField = friendshipEdgeTypeInfo.fields.find( + it => it.name === "friendedAt", + )!; + const friendshipEdgeFriendedAtFieldType = (friendshipEdgeFriendedAtField.type as IntrospectionNonNullTypeRef) + .ofType as IntrospectionObjectType; + + expect(schemaObjectTypes).toHaveLength(5); // Query, User, Dog, UserCon, DogCon + expect(recipeEdgeTypeInfo.fields).toHaveLength(3); + expect(recipeEdgeNodeFieldType.kind).toBe(TypeKind.OBJECT); + expect(recipeEdgeNodeFieldType.name).toBe("Recipe"); + expect(recipeEdgePersonalNotesFieldType.kind).toBe(TypeKind.SCALAR); + expect(recipeEdgePersonalNotesFieldType.name).toBe("String"); + expect(friendshipEdgeTypeInfo.fields).toHaveLength(3); + expect(friendshipEdgeNodeFieldType.kind).toBe(TypeKind.OBJECT); + expect(friendshipEdgeNodeFieldType.name).toBe("User"); + expect(friendshipEdgeFriendedAtFieldType.kind).toBe(TypeKind.SCALAR); + expect(friendshipEdgeFriendedAtFieldType.name).toBe("DateTime"); + }); + + it("should return child classes data from queries", async () => { + const query = /* graphql */ ` + query { + recipeEdge { + cursor + node { + title + } + personalNotes + } + friendshipEdge { + cursor + node { + name + } + friendedAt + } + } + `; + + const result = await graphql(schema, query); + + expect(result.data!.recipeEdge).toEqual(recipeEdgeResponse); + expect(result.data!.friendshipEdge).toEqual({ + ...friendshipEdgeResponse, + friendedAt: friendshipEdgeResponse.friendedAt.toISOString(), + }); + }); + }); + + describe("overwriting a property from base generic class in child class", async () => { + let schema: GraphQLSchema; + let schemaIntrospection: IntrospectionSchema; + + beforeAll(async () => { + function Base(TTypeClass: ClassType) { + @ObjectType({ isAbstract: true }) + class BaseClass { + @Field(type => TTypeClass) + baseField: TType; + } + return BaseClass; + } + + @ObjectType() + class BaseSample { + @Field() + sampleField: string; + } + + @ObjectType() + class ChildSample { + @Field() + sampleField: string; + @Field() + childField: string; + } + + @ObjectType() + class Child extends Base(BaseSample) { + @Field() + baseField: ChildSample; // overwriting field with a up compatible type + } + + @Resolver() + class OverwriteResolver { + @Query() + child(): Child { + // TODO: refactor when inheritance instance fix appear + const response = new Child(); + Object.assign(response, { + baseField: { + sampleField: "sampleField", + childField: "childField", + }, + } as Child); + return response; + } + } + + ({ schema, schemaIntrospection } = await getSchemaInfo({ + resolvers: [OverwriteResolver], + })); + }); + + it("should register proper type with overwritten field from base generic class", async () => { + const childTypeInfo = schemaIntrospection.types.find( + it => it.name === "Child", + ) as IntrospectionObjectType; + const childTypeBaseField = childTypeInfo.fields.find(it => it.name === "baseField")!; + const childTypeBaseFieldType = (childTypeBaseField.type as IntrospectionNonNullTypeRef) + .ofType as IntrospectionObjectType; + + expect(childTypeBaseFieldType.kind).toEqual(TypeKind.OBJECT); + expect(childTypeBaseFieldType.name).toEqual("ChildSample"); + }); + + it("should return overwritten child class data from query", async () => { + const document = /* graphql */ ` + query { + child { + baseField { + sampleField + childField + } + } + } + `; + + const result = await graphql(schema, document); + + expect(result.data!).toEqual({ + child: { + baseField: { + sampleField: "sampleField", + childField: "childField", + }, + }, + }); + }); + }); +}); From ce71647821ad3fd74429ca11d28a819f3db0a7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sat, 16 Feb 2019 21:51:28 +0100 Subject: [PATCH 3/4] docs(examples): add examples for generic types --- dev.js | 1 + docs/examples.md | 3 +- examples/README.md | 3 +- examples/generic-types/examples.gql | 16 +++++++ examples/generic-types/index.ts | 23 ++++++++++ .../generic-types/paginated-response.type.ts | 17 +++++++ examples/generic-types/recipe.resolver.ts | 45 +++++++++++++++++++ examples/generic-types/recipe.samples.ts | 21 +++++++++ examples/generic-types/recipe.type.ts | 13 ++++++ examples/generic-types/schema.gql | 24 ++++++++++ 10 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 examples/generic-types/examples.gql create mode 100644 examples/generic-types/index.ts create mode 100644 examples/generic-types/paginated-response.type.ts create mode 100644 examples/generic-types/recipe.resolver.ts create mode 100644 examples/generic-types/recipe.samples.ts create mode 100644 examples/generic-types/recipe.type.ts create mode 100644 examples/generic-types/schema.gql diff --git a/dev.js b/dev.js index 5b5cc2b26..fb4b492c8 100644 --- a/dev.js +++ b/dev.js @@ -3,6 +3,7 @@ require("ts-node/register/transpile-only"); // require("./examples/authorization/index.ts"); // require("./examples/automatic-validation/index.ts"); // require("./examples/enums-and-unions/index.ts"); +// require("./examples/generic-types/index.ts"); // require("./examples/interfaces-inheritance/index.ts"); // require("./examples/middlewares/index.ts"); // require("./examples/redis-subscriptions/index.ts"); diff --git a/docs/examples.md b/docs/examples.md index ba93d19b9..1372683b2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -12,10 +12,11 @@ All examples has a `examples.gql` file with sample queries/mutations/subscriptio ## Advanced - [Enums and unions](https://github.com/19majkel94/type-graphql/tree/master/examples/enums-and-unions) -- [Interfaces and types inheritance](https://github.com/19majkel94/type-graphql/tree/master/examples/interfaces-inheritance) - [Subscriptions (simple)](https://github.com/19majkel94/type-graphql/tree/master/examples/simple-subscriptions) - [Subscriptions (using Redis)](https://github.com/19majkel94/type-graphql/tree/master/examples/redis-subscriptions) +- [Interfaces and types inheritance](https://github.com/19majkel94/type-graphql/tree/master/examples/interfaces-inheritance) - [Resolvers inheritance](https://github.com/19majkel94/type-graphql/tree/master/examples/resolvers-inheritance) +- [Generic types](https://github.com/19majkel94/type-graphql/tree/master/examples/generic-types) ## Features usage - [Dependency injection (IoC container)](https://github.com/19majkel94/type-graphql/tree/master/examples/using-container) diff --git a/examples/README.md b/examples/README.md index aeb5a2c50..3bc44b5cc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,10 +8,11 @@ All examples has a `examples.gql` file with sample queries/mutations/subscriptio ## Advanced - [Enums and unions](./enums-and-unions) -- [Interfaces and types inheritance](./interfaces-inheritance) - [Subscriptions (simple)](./simple-subscriptions) - [Subscriptions (using Redis)](./redis-subscriptions) +- [Interfaces and types inheritance](./interfaces-inheritance) - [Resolvers inheritance](./resolvers-inheritance) +- [Generic types](./generic-types) ## Features usage - [Dependency injection (IoC container)](./using-container) diff --git a/examples/generic-types/examples.gql b/examples/generic-types/examples.gql new file mode 100644 index 000000000..20e582ab7 --- /dev/null +++ b/examples/generic-types/examples.gql @@ -0,0 +1,16 @@ +query GetRecipes { + recipes(first: 3) { + items { + title + ratings + } + total + hasMore + } +} + +mutation AddRecipe { + addSampleRecipe { + title + } +} diff --git a/examples/generic-types/index.ts b/examples/generic-types/index.ts new file mode 100644 index 000000000..4f1dfb4ce --- /dev/null +++ b/examples/generic-types/index.ts @@ -0,0 +1,23 @@ +import "reflect-metadata"; +import { ApolloServer } from "apollo-server"; +import * as path from "path"; +import { buildSchema } from "../../src"; + +import RecipeResolver from "./recipe.resolver"; + +async function bootstrap() { + const schema = await buildSchema({ + resolvers: [RecipeResolver], + emitSchemaFile: path.resolve(__dirname, "schema.gql"), + }); + + const server = new ApolloServer({ + schema, + playground: true, + }); + + const { url } = await server.listen(4000); + console.log(`Server is running, GraphQL Playground available at ${url}`); +} + +bootstrap().catch(console.error); diff --git a/examples/generic-types/paginated-response.type.ts b/examples/generic-types/paginated-response.type.ts new file mode 100644 index 000000000..693e4fbaa --- /dev/null +++ b/examples/generic-types/paginated-response.type.ts @@ -0,0 +1,17 @@ +import { ClassType, Field, ObjectType, Int } from "../../src"; + +export default function PaginatedResponse(TItemClass: ClassType) { + // `isAbstract` decorator option is mandatory to prevent registering in schema + @ObjectType({ isAbstract: true }) + abstract class PaginatedResponseClass { + @Field(type => [TItemClass]) + items: TItem[]; + + @Field(type => Int) + total: number; + + @Field() + hasMore: boolean; + } + return PaginatedResponseClass; +} diff --git a/examples/generic-types/recipe.resolver.ts b/examples/generic-types/recipe.resolver.ts new file mode 100644 index 000000000..d1ba1c1c9 --- /dev/null +++ b/examples/generic-types/recipe.resolver.ts @@ -0,0 +1,45 @@ +import { ObjectType, Query, Mutation, Arg, Int, Resolver } from "../../src"; + +import PaginatedResponse from "./paginated-response.type"; +import Recipe from "./recipe.type"; +import createSampleRecipes from "./recipe.samples"; + +// we need to create a temporary class for the abstract, generic class "instance" +@ObjectType() +class RecipesResponse extends PaginatedResponse(Recipe) { + // simple helper for creating new instances easily + constructor(recipesResponse: RecipesResponse) { + super(); + Object.assign(this, recipesResponse); + } + + // you can add more fields here if you need +} + +@Resolver() +export default class RecipeResolver { + private readonly recipes = createSampleRecipes(); + + @Query({ name: "recipes" }) + getRecipes( + @Arg("first", type => Int, { nullable: true, defaultValue: 10 }) first: number, + ): RecipesResponse { + const total = this.recipes.length; + return new RecipesResponse({ + items: this.recipes.slice(0, first), + hasMore: total > first, + total, + }); + } + + @Mutation() + addSampleRecipe(): Recipe { + const recipe: Recipe = { + title: "Sample recipe", + description: "Sample description", + ratings: [1, 2, 3, 4], + }; + this.recipes.push(recipe); + return recipe; + } +} diff --git a/examples/generic-types/recipe.samples.ts b/examples/generic-types/recipe.samples.ts new file mode 100644 index 000000000..97839695b --- /dev/null +++ b/examples/generic-types/recipe.samples.ts @@ -0,0 +1,21 @@ +import Recipe from "./recipe.type"; + +export default function createSampleRecipes(): Recipe[] { + return [ + { + description: "Desc 1", + title: "Recipe 1", + ratings: [0, 3, 1], + }, + { + description: "Desc 2", + title: "Recipe 2", + ratings: [4, 2, 3, 1], + }, + { + description: "Desc 3", + title: "Recipe 3", + ratings: [5, 4], + }, + ]; +} diff --git a/examples/generic-types/recipe.type.ts b/examples/generic-types/recipe.type.ts new file mode 100644 index 000000000..fa54fa4d6 --- /dev/null +++ b/examples/generic-types/recipe.type.ts @@ -0,0 +1,13 @@ +import { Field, ObjectType, Int } from "../../src"; + +@ObjectType() +export default class Recipe { + @Field() + title: string; + + @Field() + description?: string; + + @Field(type => [Int]) + ratings: number[]; +} diff --git a/examples/generic-types/schema.gql b/examples/generic-types/schema.gql new file mode 100644 index 000000000..0a56b53e7 --- /dev/null +++ b/examples/generic-types/schema.gql @@ -0,0 +1,24 @@ +# ----------------------------------------------- +# !!! THIS FILE WAS GENERATED BY TYPE-GRAPHQL !!! +# !!! DO NOT MODIFY THIS FILE BY YOURSELF !!! +# ----------------------------------------------- + +type Mutation { + addSampleRecipe: Recipe! +} + +type Query { + recipes(first: Int = 10): RecipesResponse! +} + +type Recipe { + title: String! + description: String! + ratings: [Int!]! +} + +type RecipesResponse { + items: [Recipe!]! + total: Int! + hasMore: Boolean! +} From 1871aaec0cc9d24fe7cfc51b350b1daf00596c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sun, 17 Feb 2019 19:16:50 +0100 Subject: [PATCH 4/4] docs(generics): add tutorial for creating generic types --- docs/generic-types.md | 130 ++++++++++++++++++++++++++++++++++++++++++ website/i18n/en.json | 3 + website/sidebars.json | 1 + 3 files changed, 134 insertions(+) create mode 100644 docs/generic-types.md diff --git a/docs/generic-types.md b/docs/generic-types.md new file mode 100644 index 000000000..0b97cf544 --- /dev/null +++ b/docs/generic-types.md @@ -0,0 +1,130 @@ +--- +title: Generic types +--- + +[Types inheritance](inheritance.md) is a great way to reduce the code duplication by extracting common fields to the base class. But in some cases, the strict set of fields is not enough because we might need to declare the types of some fields in a more flexible way, like a type parameter (e.g. `items: T[]` in case of a pagination). + +Hence TypeGraphQL has also support for describing generic GraphQL types. + +## How to? + +Unfortunately, the limited reflection capabilities of TypeScript doesn't allow for combining decorator with the standard generic classes. To achieve a behavior like the generic types, we will use the same class-creator pattern like the one described in [resolvers inheritance](inheritance.md) docs. + +So we will start by defining a `PaginatedResponse` function that creates and returns a `PaginatedResponseClass`: + +```typescript +export default function PaginatedResponse() { + abstract class PaginatedResponseClass { + // ... + } + return PaginatedResponseClass; +} +``` + +To achieve a generic-like behavior, the function has to be generic and take some runtime argument related to the type parameter: + +```typescript +export default function PaginatedResponse(TItemClass: ClassType) { + abstract class PaginatedResponseClass { + // ... + } + return PaginatedResponseClass; +} +``` + +Then, we need to add proper decorators to the class - it might be `@ObjectType`, `@InterfaceType` or `@InputType`. +It also should have set `isAbstract: true` to prevent registering in schema: + +```typescript +export default function PaginatedResponse(TItemClass: ClassType) { + @ObjectType({ isAbstract: true }) + abstract class PaginatedResponseClass { + // ... + } + return PaginatedResponseClass; +} +``` + +After that, we can add fields like in a normal class but using the generic type and parameters: + +```typescript +export default function PaginatedResponse(TItemClass: ClassType) { + // `isAbstract` decorator option is mandatory to prevent registering in schema + @ObjectType({ isAbstract: true }) + abstract class PaginatedResponseClass { + // here we use the runtime argument + @Field(type => [TItemClass]) + // and here the generic type + items: TItem[]; + + @Field(type => Int) + total: number; + + @Field() + hasMore: boolean; + } + return PaginatedResponseClass; +} +``` + +Finally, we can use the generic function factory to create a dedicated type class: + +```typescript +@ObjectType() +class PaginatedUserResponse extends PaginatedResponse(User) { + // we can freely add more fields or overwrite the existing one's types + @Field(type => [String]) + otherInfo: string[]; +} +``` + +And then use it in our resolvers: + +```typescript +@Resolver() +class UserResolver { + @Query() + users(): PaginatedUserResponse { + const response = new PaginatedUserResponse(); + // here is your custom business logic, + // depending on underlying data source and libraries + return response; + } +} +``` + +You can also create a generic class without using `isAbstract` option or `abstract` keyword. +But types created with this kind of factory will be registered in schema, so it's not recommended to use this way to extend the types for adding some more fields. + +To avoid generating schema errors about duplicated `PaginatedResponseClass` type names, you need to provide your own, unique, generated type name: + +```typescript +export default function PaginatedResponse(TItemClass: ClassType) { + // instead of `isAbstract`, you have to provide a unique type name used in schema + @ObjectType({ name: `Paginated${TItemClass.name}Response` }) + class PaginatedResponseClass { + // the same fields as in the earlier code snippet + } + return PaginatedResponseClass; +} +``` + +Then, you can store the generated class in a variable. To be able to use it both as a runtime object and a type, you also have to create a type for this new class: + +```typescript +const PaginatedUserResponse = PaginatedResponse(User); +type PaginatedUserResponse = InstanceType; + +@Resolver() +class UserResolver { + // remember to provide a runtime type argument to the decorator + @Query(returns => PaginatedUserResponse) + users(): PaginatedUserResponse { + // the same implementation as in the earlier code snippet + } +} +``` + +## Examples + +More advanced usage example of a generic types feature you can see in [this examples folder](https://github.com/19majkel94/type-graphql/tree/master/examples/generic-types). diff --git a/website/i18n/en.json b/website/i18n/en.json index bde870468..76ecc6d3f 100644 --- a/website/i18n/en.json +++ b/website/i18n/en.json @@ -33,6 +33,9 @@ "faq": { "title": "Frequently Asked Questions" }, + "generic-types": { + "title": "Generic types" + }, "getting-started": { "title": "Getting started" }, diff --git a/website/sidebars.json b/website/sidebars.json index e6bf70ba0..72d9596fd 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -14,6 +14,7 @@ "authorization", "validation", "inheritance", + "generic-types", "middlewares", "complexity" ],