diff --git a/examples/1-simple-seed/package.json b/examples/1-simple-seed/package.json index 8d4aa7ba..e26e91de 100644 --- a/examples/1-simple-seed/package.json +++ b/examples/1-simple-seed/package.json @@ -13,6 +13,7 @@ "start:ts": "ts-node --type-check src/index.ts", "typeorm:cli": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ./ormconfig", "test": "dotenv -- jest", + "test:watch": "dotenv -- jest --watch", "watch:ts": "nodemon -e ts,graphql -x ts-node --type-check src/index.ts" }, "dependencies": { diff --git a/examples/1-simple-seed/src/index.test.ts b/examples/1-simple-seed/src/index.test.ts index dd7ba8af..cd5ec3d9 100644 --- a/examples/1-simple-seed/src/index.test.ts +++ b/examples/1-simple-seed/src/index.test.ts @@ -61,7 +61,7 @@ describe('Users', () => { }); test('uniqueness failure', async done => { - let error; + let error: GraphQLError = new GraphQLError(''); try { await binding.mutation.createUser( { @@ -74,9 +74,8 @@ describe('Users', () => { `{ id email createdAt createdById }` ); } catch (e) { - error = e; + error = e as GraphQLError; } - // Note: this test can also surface if you have 2 separate versions of GraphQL installed (which is bad) expect(error).toBeInstanceOf(GraphQLError); expect(error.message).toContain('duplicate'); diff --git a/examples/1-simple-seed/src/index.ts b/examples/1-simple-seed/src/index.ts index fcc30ee8..74f8fbeb 100644 --- a/examples/1-simple-seed/src/index.ts +++ b/examples/1-simple-seed/src/index.ts @@ -1,4 +1,6 @@ import 'reflect-metadata'; +import * as dotenv from 'dotenv'; +dotenv.config(); import { getApp } from './app'; diff --git a/examples/1-simple-seed/src/modules/user/user.entity.ts b/examples/1-simple-seed/src/modules/user/user.entity.ts index f8e446a0..8608b5d7 100644 --- a/examples/1-simple-seed/src/modules/user/user.entity.ts +++ b/examples/1-simple-seed/src/modules/user/user.entity.ts @@ -1,6 +1,13 @@ import { Authorized } from 'type-graphql'; -import { BaseModel, EmailField, Model, StringField } from '../../../../../src'; +import { BaseModel, EmailField, EnumField, Model, StringField } from '../../../../../src'; + +// Note: this must be exported and in the same file where it's attached with @EnumField +// Also - must use string enums +export enum StringEnum { + FOO = 'FOO', + BAR = 'BAR' +} @Model() export class User extends BaseModel { @@ -10,6 +17,9 @@ export class User extends BaseModel { @StringField({ maxLength: 50, minLength: 2 }) lastName?: string; + @EnumField('StringEnum', StringEnum, { nullable: true }) + stringEnumField?: StringEnum; + @EmailField() email?: string; diff --git a/package.json b/package.json index dea2833d..57b19cd7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "yarn tsc", "semantic-release": "semantic-release", "test": "jest --verbose --coverage", + "test:watch": "jest --verbose --watch", "test:ci": "jest --coverage --ci --forceExit --detectOpenHandles --runInBand" }, "husky": { @@ -36,6 +37,7 @@ "homepage": "https://github.com/goldcaddy77/warthog#readme", "//": "TODO: figure out which of these are dependencies, devDeps or peerDeps", "dependencies": { + "@types/caller": "^1.0.0", "@types/debug": "^0.0.31", "@types/dotenv": "^6.1.0", "@types/express": "^4.16.0", @@ -50,9 +52,11 @@ "@types/prettier": "^1.15.2", "@types/shortid": "^0.0.29", "@types/ws": "^6.0.1", + "apollo-link-error": "^1.1.5", "apollo-link-http": "^1.5.9", "apollo-server": "^2.3.1", "apollo-server-express": "^2.3.1", + "caller": "^1.0.1", "class-transformer": "^0.2.0", "class-validator": "^0.9.1", "cross-fetch": "^3.0.0", diff --git a/src/core/app.ts b/src/core/app.ts index d8ee41e0..ff397abe 100644 --- a/src/core/app.ts +++ b/src/core/app.ts @@ -128,11 +128,7 @@ export class App { }; return context; }, - schema: this.schema, - formatError: (error: Error) => { - console.log(error); - return error; - } + schema: this.schema }); const app = express(); diff --git a/src/core/binding.ts b/src/core/binding.ts index 8e19d650..0991abf3 100644 --- a/src/core/binding.ts +++ b/src/core/binding.ts @@ -1,8 +1,9 @@ +import { onError } from 'apollo-link-error'; import { HttpLink } from 'apollo-link-http'; import * as fetch from 'cross-fetch'; import * as fs from 'fs'; import * as Debug from 'debug'; -import { buildSchema, printSchema } from 'graphql'; +import { buildSchema, GraphQLError, printSchema } from 'graphql'; import { Binding, TypescriptGenerator } from 'graphql-binding'; import { introspectSchema, makeRemoteExecutableSchema } from 'graphql-tools'; import * as path from 'path'; @@ -39,8 +40,16 @@ export class Link extends HttpLink { } export class RemoteBinding extends Binding { - constructor(link: HttpLink, typeDefs: string) { - const schema = makeRemoteExecutableSchema({ link, schema: typeDefs }); + constructor(httpLink: HttpLink, typeDefs: string) { + // Workaround for issue with graphql-tools + // See https://github.com/graphql-binding/graphql-binding/issues/173#issuecomment-446366548 + const errorLink = onError((args: any) => { + if (args.graphQLErrors && args.graphQLErrors.length === 1) { + args.response.errors = args.graphQLErrors.concat(new GraphQLError('')); + } + }); + + const schema = makeRemoteExecutableSchema({ link: errorLink.concat(httpLink), schema: typeDefs }); debug('schema', JSON.stringify(schema)); super({ schema }); } diff --git a/src/core/types.ts b/src/core/types.ts index d04dcca2..5aa3ed4f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -18,3 +18,7 @@ export type WhereInput = { export type DeleteReponse = { id: ID; }; + +export interface ClassType { + new (...args: any[]): T; +} diff --git a/src/decorators/EnumField.test.ts b/src/decorators/EnumField.test.ts new file mode 100644 index 00000000..86b85cb4 --- /dev/null +++ b/src/decorators/EnumField.test.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; + +import { IntrospectionSchema, IntrospectionEnumType } from 'graphql'; +import { ObjectType, Query, Resolver } from 'type-graphql'; + +import { getSchemaInfo } from '../schema'; + +import { EnumField } from './EnumField'; + +describe('Enums', () => { + let schemaIntrospection: IntrospectionSchema; + + beforeAll(async () => { + enum StringEnum { + Foo = 'FOO', + Bar = 'BAR' + } + + @ObjectType() + class StringEnumInput { + @EnumField('StringEnum', StringEnum, { nullable: true }) + stringEnumField?: StringEnum; + } + + @Resolver(of => StringEnumInput) + class SampleResolver { + @Query(returns => StringEnum) + getStringEnumValue(): StringEnum { + return StringEnum.Foo; + } + } + + const schemaInfo = await getSchemaInfo({ + resolvers: [SampleResolver] + }); + schemaIntrospection = schemaInfo.schemaIntrospection; + }); + + describe('EnumField', () => { + it('Puts an enum in the GraphQL schema', async () => { + const myEnum = schemaIntrospection.types.find((type: any) => { + return type.kind === 'ENUM' && type.name === 'StringEnum'; + }) as IntrospectionEnumType; + + expect(myEnum).toBeDefined(); + expect(myEnum.enumValues.length).toEqual(2); + }); + }); +}); diff --git a/src/decorators/EnumField.ts b/src/decorators/EnumField.ts new file mode 100644 index 00000000..dbb610b9 --- /dev/null +++ b/src/decorators/EnumField.ts @@ -0,0 +1,31 @@ +const caller = require('caller'); +import { Field, registerEnumType } from 'type-graphql'; +import { Column } from 'typeorm'; + +import { getMetadataStorage } from '../metadata'; +import { composeMethodDecorators, MethodDecoratorFactory } from '../utils'; + +interface EnumFieldOptions { + nullable?: boolean; +} + +export function EnumField(name: string, enumeration: object, options: EnumFieldOptions = {}): any { + // Register enum with TypeGraphQL so that it lands in generated schema + registerEnumType(enumeration, { name }); + + // In order to use the enums in the generated classes file, we need to + // save their locations and import them in the generated file + const entityFileName = caller(); + + const registerEnumWithWarthog = (target: any, propertyKey: string, descriptor: PropertyDescriptor): any => { + getMetadataStorage().addEnum(target.constructor.name, propertyKey, name, enumeration, entityFileName); + }; + + const factories = [ + Field(type => enumeration, options), + Column({ enum: enumeration, ...options }) as MethodDecoratorFactory, + registerEnumWithWarthog + ]; + + return composeMethodDecorators(...factories); +} diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 2ef6fb52..e3e2cf9b 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -1,4 +1,5 @@ export * from './EmailField'; +export * from './EnumField'; export * from './Model'; export * from './StringField'; export * from './ForeignKeyField'; diff --git a/src/metadata/index.ts b/src/metadata/index.ts new file mode 100644 index 00000000..d227ea5e --- /dev/null +++ b/src/metadata/index.ts @@ -0,0 +1 @@ +export { getMetadataStorage } from './metadata-storage'; diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts new file mode 100644 index 00000000..e7796896 --- /dev/null +++ b/src/metadata/metadata-storage.ts @@ -0,0 +1,26 @@ +export function getMetadataStorage(): MetadataStorage { + if (!(global as any).WarthogMetadataStorage) { + (global as any).WarthogMetadataStorage = new MetadataStorage(); + } + return (global as any).WarthogMetadataStorage; +} + +export class MetadataStorage { + enumMap: { [table: string]: { [column: string]: any } } = {}; + + addEnum(tableName: string, columnName: string, enumName: string, enumValues: any, filename: string) { + this.enumMap[tableName] = this.enumMap[tableName] || {}; + this.enumMap[tableName][columnName] = { + name: enumName, + enumeration: enumValues, + filename + }; + } + + getEnum(tableName: string, columnName: string) { + if (!this.enumMap[tableName]) { + return undefined; + } + return this.enumMap[tableName][columnName] || undefined; + } +} diff --git a/src/schema/SchemaGenerator.ts b/src/schema/SchemaGenerator.ts index f4142697..de4a366c 100644 --- a/src/schema/SchemaGenerator.ts +++ b/src/schema/SchemaGenerator.ts @@ -4,6 +4,7 @@ import * as prettier from 'prettier'; import { EntityMetadata } from 'typeorm'; import { + entityListToEnumImports, entityToOrderByEnum, entityToWhereArgs, entityToWhereInput, @@ -27,6 +28,7 @@ export class SchemaGenerator { import { ArgsType, Field, InputType } from 'type-graphql'; import { registerEnumType } from 'type-graphql'; import { BaseWhereInput, PaginationArgs } from '${warthogImportPath}'; + ${entityListToEnumImports(entities).join('')} `; entities.forEach((entity: EntityMetadata) => { diff --git a/src/schema/TypeORMConverter.ts b/src/schema/TypeORMConverter.ts index 04a5a4b7..d6891639 100644 --- a/src/schema/TypeORMConverter.ts +++ b/src/schema/TypeORMConverter.ts @@ -1,8 +1,9 @@ -import { GraphQLInt, GraphQLScalarType, GraphQLString, GraphQLBoolean, GraphQLFloat } from 'graphql'; -import { EntityMetadata, ColumnType } from 'typeorm'; +import { GraphQLInt, GraphQLScalarType, GraphQLString, GraphQLBoolean, GraphQLFloat, GraphQLEnumType } from 'graphql'; +import { EntityMetadata } from 'typeorm'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; import { UniqueMetadata } from 'typeorm/metadata/UniqueMetadata'; import { GraphQLISODateTime } from 'type-graphql'; +import { getMetadataStorage } from '../metadata'; const SYSTEM_FIELDS = ['createdAt', 'createdById', 'updatedAt', 'updatedById', 'deletedAt', 'deletedById']; @@ -15,6 +16,23 @@ function uniquesForEntity(entity: EntityMetadata): string[] { ); } +export function entityListToEnumImports(entities: EntityMetadata[]): string[] { + let enums: string[] = []; + let enumMap = getMetadataStorage().enumMap; + + Object.keys(enumMap).forEach((tableName: string) => { + Object.keys(enumMap[tableName]).forEach((columnName: string) => { + const enumColumn = enumMap[tableName][columnName]; + const filename = enumColumn.filename.replace(/\.(j|t)s$/, ''); + enums.push(` + import { ${enumColumn.name} } from '${filename}' + `); + }); + }); + + return enums; +} + export function entityToWhereUniqueInput(entity: EntityMetadata): string { const uniques = uniquesForEntity(entity); @@ -33,7 +51,7 @@ export function entityToWhereUniqueInput(entity: EntityMetadata): string { entity.columns.forEach((column: ColumnMetadata) => { if (uniques.includes(column.propertyName) || column.isPrimary) { const nullable = uniqueFieldsAreNullable ? ', { nullable: true }' : ''; - const graphQLDataType = columnTypeToGraphQLDateType(column.type); + const graphQLDataType = columnTypeToGraphQLDataType(column); const tsType = columnToTypeScriptType(column); fieldsTemplate += ` @@ -65,13 +83,20 @@ export function entityToCreateInput(entity: EntityMetadata): string { !column.isVersion && !SYSTEM_FIELDS.includes(column.propertyName) ) { - const tsType = columnToTypeScriptType(column); + const graphQLType = columnToGraphQLType(column); const nullable = column.isNullable ? '{ nullable: true }' : ''; const tsRequired = column.isNullable ? '?' : '!'; + const tsType = columnToTypeScriptType(column); - fieldTemplates += ` - @Field(${nullable}) ${column.propertyName}${tsRequired}: ${tsType}; - `; + if (column.enum) { + fieldTemplates += ` + @Field(type => ${graphQLType}, ${nullable}) ${column.propertyName}${tsRequired}: ${graphQLType}; + `; + } else { + fieldTemplates += ` + @Field(${nullable}) ${column.propertyName}${tsRequired}: ${tsType}; + `; + } } }); @@ -97,12 +122,20 @@ export function entityToUpdateInput(entity: EntityMetadata): string { ) { // TODO: also don't allow updated foreign key fields // Example: photo.userId: String + const graphQLType = columnToGraphQLType(column); const tsType = columnToTypeScriptType(column); - fieldTemplates += ` + if (column.enum) { + fieldTemplates += ` + @Field(type => ${graphQLType}, { nullable: true }) + ${column.propertyName}?: ${graphQLType}; + `; + } else { + fieldTemplates += ` @Field({ nullable: true }) ${column.propertyName}?: ${tsType}; `; + } } }); @@ -125,6 +158,13 @@ export function entityToUpdateInputArgs(entity: EntityMetadata): string { `; } +function columnToTypes(column: ColumnMetadata) { + const graphqlType = columnToGraphQLType(column); + const tsType = columnToTypeScriptType(column); + + return { graphqlType, tsType }; +} + export function entityToWhereInput(entity: EntityMetadata): string { let fieldTemplates = ''; @@ -134,8 +174,7 @@ export function entityToWhereInput(entity: EntityMetadata): string { return; } - const graphqlType: GraphQLScalarType = convertToGraphQLType(column.type); - const tsType = columnToTypeScriptType(column); + const { graphqlType, tsType } = columnToTypes(column); // TODO: for foreign key fields, only allow the same filters as ID below // Example: photo.userId: String @@ -200,6 +239,15 @@ export function entityToWhereInput(entity: EntityMetadata): string { @Field({ nullable: true }) ${column.propertyName}_lte?: ${tsType}; `; + } else { + // Enums will fall through here + fieldTemplates += ` + @Field(type => ${graphqlType}, { nullable: true }) + ${column.propertyName}_eq?: ${graphqlType}; + + @Field(type => [${graphqlType}], { nullable: true }) + ${column.propertyName}_in?: ${graphqlType}[]; + `; } }); @@ -254,7 +302,7 @@ export function columnToTypeScriptType(column: ColumnMetadata): string { if (column.isPrimary) { return 'string'; // TODO: should this be ID_TYPE? } else { - const graphqlType = columnTypeToGraphQLDateType(column.type); + const graphqlType = columnTypeToGraphQLDataType(column); const typeMap: any = { DateTime: 'string', String: 'string', @@ -265,12 +313,18 @@ export function columnToTypeScriptType(column: ColumnMetadata): string { } } -export function columnTypeToGraphQLDateType(type: ColumnType): string { - return convertToGraphQLType(type).name; +export function columnTypeToGraphQLDataType(column: ColumnMetadata): string { + return columnToGraphQLType(column).name; } -export function convertToGraphQLType(type: ColumnType): GraphQLScalarType { +export function columnToGraphQLType(column: ColumnMetadata): GraphQLScalarType | GraphQLEnumType { + // Check to see if this column is an enum and return that + const enumObject = getMetadataStorage().getEnum(column.entityMetadata.name, column.propertyName); + if (enumObject) { + return enumObject.name; + } + // Some types have a name attribute - type = (type as any).name ? (type as any).name : type; + const type = (column.type as any).name ? (column.type as any).name : column.type; if (type instanceof GraphQLScalarType) { return type; @@ -280,6 +334,7 @@ export function convertToGraphQLType(type: ColumnType): GraphQLScalarType { case String: case 'String': case 'text': + case 'enum': // TODO: Hack for now, need to teach this about enums return GraphQLString; case Boolean: case 'Boolean': diff --git a/src/schema/getSchemaInfo.ts b/src/schema/getSchemaInfo.ts new file mode 100644 index 00000000..5cbe1061 --- /dev/null +++ b/src/schema/getSchemaInfo.ts @@ -0,0 +1,43 @@ +// Borrowed from https://github.com/19majkel94/type-graphql/blob/9778f9fab9e7f50363f2023b7ea366668e3d0ec9/tests/helpers/getSchemaInfo.ts +import { graphql, getIntrospectionQuery, IntrospectionObjectType, IntrospectionSchema } from 'graphql'; +import { buildSchema, BuildSchemaOptions } from 'type-graphql'; + +export async function getSchemaInfo(options: BuildSchemaOptions) { + // build schema from definitions + const schema = await buildSchema(options); + + // get builded schema info from retrospection + const result = await graphql(schema, getIntrospectionQuery()); + expect(result.errors).toBeUndefined(); + + const schemaIntrospection = result.data!.__schema as IntrospectionSchema; + expect(schemaIntrospection).toBeDefined(); + + const queryType = schemaIntrospection.types.find( + type => type.name === schemaIntrospection.queryType.name + ) as IntrospectionObjectType; + + const mutationTypeNameRef = schemaIntrospection.mutationType; + let mutationType: IntrospectionObjectType | undefined; + if (mutationTypeNameRef) { + mutationType = schemaIntrospection.types.find( + type => type.name === mutationTypeNameRef.name + ) as IntrospectionObjectType; + } + + const subscriptionTypeNameRef = schemaIntrospection.subscriptionType; + let subscriptionType: IntrospectionObjectType | undefined; + if (subscriptionTypeNameRef) { + subscriptionType = schemaIntrospection.types.find( + type => type.name === subscriptionTypeNameRef.name + ) as IntrospectionObjectType; + } + + return { + schema, + schemaIntrospection, + queryType, + mutationType, + subscriptionType + }; +} diff --git a/src/schema/index.ts b/src/schema/index.ts index 3217baa2..f9fcbb9d 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1 +1,2 @@ export * from './SchemaGenerator'; +export * from './getSchemaInfo'; diff --git a/src/utils/EntityCreator.ts b/src/utils/EntityCreator.ts index 34930408..773b0528 100644 --- a/src/utils/EntityCreator.ts +++ b/src/utils/EntityCreator.ts @@ -1,8 +1,5 @@ import { plainToClass } from 'class-transformer'; - -export declare type ClassType = { - new (...args: any[]): T; -}; +import { ClassType } from '../core'; export function createEntity(entityType: ClassType, data: Partial): T { return plainToClass>(entityType, data); diff --git a/yarn.lock b/yarn.lock index 922b56c4..803d3d31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -262,6 +262,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/caller@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@types/caller/-/caller-1.0.0.tgz#21044c8254e95e57c86a2079bd99430d5f892c62" + integrity sha512-zbyHsdYFn5gCQFn+fF7Ad9En1fJdj3o0YP7DPTD9J3at4M9auNfoLrVYH4hFdLfFDhnB0kTZw3RAKp4iD516uw== + "@types/connect@*": version "3.4.32" resolved "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28" @@ -623,6 +628,14 @@ apollo-env@0.2.5: core-js "^3.0.0-beta.3" node-fetch "^2.2.0" +apollo-link-error@^1.1.5: + version "1.1.5" + resolved "https://registry.npmjs.org/apollo-link-error/-/apollo-link-error-1.1.5.tgz#1d600dfa75c4e4bf017f50d60da7b375b53047ab" + integrity sha512-gE0P711K+rI3QcTzfYhzRI9axXaiuq/emu8x8Y5NHK9jl9wxh7qmEc3ZTyGpnGFDDTXfhalmX17X5lp3RCVHDQ== + dependencies: + apollo-link "^1.2.6" + apollo-link-http-common "^0.2.8" + apollo-link-http-common@^0.2.8: version "0.2.8" resolved "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.8.tgz#c6deedfc2739db8b11013c3c2d2ccd657152941f" @@ -1428,6 +1441,11 @@ caller-path@^2.0.0: dependencies: caller-callsite "^2.0.0" +caller@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/caller/-/caller-1.0.1.tgz#b851860f70e195db3d277395aa1a7e23ea30ecf5" + integrity sha1-uFGGD3Dhlds9J3OVqhp+I+ow7PU= + callsites@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"