From d42d847300f8d2daf82e071b8393adb5a3fc6b8c Mon Sep 17 00:00:00 2001 From: Dan Caddigan Date: Sun, 20 Sep 2020 00:23:44 -0400 Subject: [PATCH 1/2] feat(decorators): allow concrete type on JSONField --- .../02-complex-example/generated/binding.ts | 23 +++++++ .../02-complex-example/generated/classes.ts | 10 +++ .../generated/schema.graphql | 23 +++++++ .../src/modules/user/user.model.ts | 27 ++++++++ examples/02-complex-example/tools/seed.ts | 12 ++++ src/decorators/JSONField.ts | 4 +- src/decorators/ObjectType.ts | 36 ++++++++++ src/decorators/index.ts | 1 + src/metadata/metadata-storage.ts | 12 ++++ src/schema/TypeORMConverter.ts | 16 ++--- src/schema/type-conversion.ts | 65 ++++++++----------- 11 files changed, 177 insertions(+), 52 deletions(-) create mode 100644 src/decorators/ObjectType.ts diff --git a/examples/02-complex-example/generated/binding.ts b/examples/02-complex-example/generated/binding.ts index 308ee285..ddf22198 100644 --- a/examples/02-complex-example/generated/binding.ts +++ b/examples/02-complex-example/generated/binding.ts @@ -138,6 +138,16 @@ export interface BaseWhereInput { deletedById_eq?: String | null } +export interface EventObjectInput { + params: Array +} + +export interface EventParamInput { + type: String + name: String + value: JSONObject +} + export interface UserCreateInput { booleanField?: Boolean | null dateField?: DateTime | null @@ -153,6 +163,7 @@ export interface UserCreateInput { bigIntField?: Float | null jsonField?: JSONObject | null jsonFieldNoFilter?: JSONObject | null + typedJsonField?: EventObjectInput | null stringField: String noFilterField?: String | null noSortField?: String | null @@ -197,6 +208,7 @@ export interface UserUpdateInput { bigIntField?: Float | null jsonField?: JSONObject | null jsonFieldNoFilter?: JSONObject | null + typedJsonField?: EventObjectInput | null stringField?: String | null noFilterField?: String | null noSortField?: String | null @@ -470,6 +482,16 @@ export interface BaseModelUUID extends BaseGraphQLObject { version: Int } +export interface EventObject { + params: Array +} + +export interface EventParam { + type: String + name: String + value: JSONObject +} + export interface PageInfo { hasNextPage: Boolean hasPreviousPage: Boolean @@ -504,6 +526,7 @@ export interface User extends BaseGraphQLObject { bigIntField?: Int | null jsonField?: JSONObject | null jsonFieldNoFilter?: JSONObject | null + typedJsonField?: EventObject | null stringField: String noFilterField?: String | null noSortField?: String | null diff --git a/examples/02-complex-example/generated/classes.ts b/examples/02-complex-example/generated/classes.ts index c161d64d..0d10c50b 100644 --- a/examples/02-complex-example/generated/classes.ts +++ b/examples/02-complex-example/generated/classes.ts @@ -23,6 +23,10 @@ import { BaseWhereInput, JsonObject, PaginationArgs, DateOnlyString, DateTimeStr import { StringEnum } from "../src/modules/user/user.model"; +// @ts-ignore +import { EventParam } from "../src/modules/user/user.model"; +// @ts-ignore +import { EventObject } from "../src/modules/user/user.model"; // @ts-ignore import { User } from "../src/modules/user/user.model"; @@ -787,6 +791,9 @@ export class UserCreateInput { @TypeGraphQLField(() => GraphQLJSONObject, { nullable: true }) jsonFieldNoFilter?: JsonObject; + @TypeGraphQLField(() => EventObject, { nullable: true }) + typedJsonField?: EventObject; + @TypeGraphQLField() stringField!: string; @@ -913,6 +920,9 @@ export class UserUpdateInput { @TypeGraphQLField(() => GraphQLJSONObject, { nullable: true }) jsonFieldNoFilter?: JsonObject; + @TypeGraphQLField(() => EventObject, { nullable: true }) + typedJsonField?: EventObject; + @TypeGraphQLField({ nullable: true }) stringField?: string; diff --git a/examples/02-complex-example/generated/schema.graphql b/examples/02-complex-example/generated/schema.graphql index f34fb793..888fb278 100644 --- a/examples/02-complex-example/generated/schema.graphql +++ b/examples/02-complex-example/generated/schema.graphql @@ -71,6 +71,26 @@ interface DeleteResponse { id: ID! } +type EventObject { + params: [EventParam!]! +} + +input EventObjectInput { + params: [EventParamInput!]! +} + +type EventParam { + type: String! + name: String! + value: JSONObject! +} + +input EventParamInput { + type: String! + name: String! + value: JSONObject! +} + """ The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). """ @@ -126,6 +146,7 @@ type User implements BaseGraphQLObject { bigIntField: Int jsonField: JSONObject jsonFieldNoFilter: JSONObject + typedJsonField: EventObject """This is a string field""" stringField: String! @@ -171,6 +192,7 @@ input UserCreateInput { bigIntField: Float jsonField: JSONObject jsonFieldNoFilter: JSONObject + typedJsonField: EventObjectInput stringField: String! noFilterField: String noSortField: String @@ -286,6 +308,7 @@ input UserUpdateInput { bigIntField: Float jsonField: JSONObject jsonFieldNoFilter: JSONObject + typedJsonField: EventObjectInput stringField: String noFilterField: String noSortField: String diff --git a/examples/02-complex-example/src/modules/user/user.model.ts b/examples/02-complex-example/src/modules/user/user.model.ts index 6e328f01..d780334b 100644 --- a/examples/02-complex-example/src/modules/user/user.model.ts +++ b/examples/02-complex-example/src/modules/user/user.model.ts @@ -1,3 +1,6 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { GraphQLJSONObject } = require('graphql-type-json'); +import { Field, InputType } from 'type-graphql'; import { Column, Unique } from 'typeorm'; import { BaseModel, @@ -16,6 +19,7 @@ import { JsonObject, Model, NumericField, + ObjectType, StringField, FloatField } from '../../../../../src'; @@ -27,6 +31,26 @@ export enum StringEnum { BAR = 'BAR' } +@InputType('EventParamInput') +@ObjectType() +export class EventParam { + @Field() + type!: string; + + @Field() + name?: string; + + @Field(() => GraphQLJSONObject) + value!: JsonObject; +} + +@InputType('EventObjectInput') +@ObjectType() +export class EventObject { + @Field(() => [EventParam]) + params!: EventParam[]; +} + @Model() @Unique(['stringField', 'enumField']) export class User extends BaseModel { @@ -81,6 +105,9 @@ export class User extends BaseModel { @JSONField({ filter: false, nullable: true }) jsonFieldNoFilter?: JsonObject; + @JSONField({ filter: false, nullable: true, gqlFieldType: EventObject }) + typedJsonField?: EventObject; + @StringField({ maxLength: 50, minLength: 2, diff --git a/examples/02-complex-example/tools/seed.ts b/examples/02-complex-example/tools/seed.ts index da502d77..23217fac 100644 --- a/examples/02-complex-example/tools/seed.ts +++ b/examples/02-complex-example/tools/seed.ts @@ -65,6 +65,18 @@ async function seedDatabase() { emailField, stringField, jsonField, + typedJsonField: { + params: [ + { + name: 'Foo', + type: 'Bar', + value: { + one: 1, + two: 'TWO' + } + } + ] + }, dateField, dateOnlyField, dateTimeField, diff --git a/src/decorators/JSONField.ts b/src/decorators/JSONField.ts index 0185befe..edc29e93 100644 --- a/src/decorators/JSONField.ts +++ b/src/decorators/JSONField.ts @@ -2,19 +2,21 @@ const { GraphQLJSONObject } = require('graphql-type-json'); import { composeMethodDecorators } from '../utils'; +import { ClassType } from '../core/types'; import { getCombinedDecorator } from './getCombinedDecorator'; interface JSONFieldOptions { nullable?: boolean; filter?: boolean; + gqlFieldType?: ClassType; } export function JSONField(options: JSONFieldOptions = {}): any { const factories = getCombinedDecorator({ fieldType: 'json', warthogColumnMeta: options, - gqlFieldType: GraphQLJSONObject, + gqlFieldType: options.gqlFieldType ?? GraphQLJSONObject, dbType: 'jsonb' }); diff --git a/src/decorators/ObjectType.ts b/src/decorators/ObjectType.ts new file mode 100644 index 00000000..7fdff8df --- /dev/null +++ b/src/decorators/ObjectType.ts @@ -0,0 +1,36 @@ +const caller = require('caller'); // eslint-disable-line @typescript-eslint/no-var-requires +import * as path from 'path'; +import { ObjectType as TypeGraphQLObjectType } from 'type-graphql'; +import { ObjectOptions } from 'type-graphql/dist/decorators/ObjectType.d'; + +import { ClassType } from '../core'; +import { getMetadataStorage } from '../metadata'; +import { ClassDecoratorFactory, composeClassDecorators, generatedFolderPath } from '../utils/'; + +// Allow default TypeORM and TypeGraphQL options to be used +// export function Model({ api = {}, db = {}, apiOnly = false, dbOnly = false }: ModelOptions = {}) { +export function ObjectType(options: ObjectOptions = {}) { + // 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 modelFileName = caller(); + + // Use relative paths when linking source files so that we can check the generated code in + // and it will work in any directory structure + const relativeFilePath = path.relative(generatedFolderPath(), modelFileName); + + const registerModelWithWarthog = (target: ClassType): void => { + // Save off where the model is located so that we can import it in the generated classes + getMetadataStorage().addClass(target.name, target, relativeFilePath); + }; + + const factories: any[] = []; + + // We add our own Warthog decorator regardless of dbOnly and apiOnly + factories.push(registerModelWithWarthog as ClassDecoratorFactory); + + // We shouldn't add this as it creates the GraphQL type, but there is a + // bug if we don't add it because we end up adding the Field decorators in the models + factories.push(TypeGraphQLObjectType(options as ObjectOptions) as ClassDecoratorFactory); + + return composeClassDecorators(...factories); +} diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 3e02f70a..fbf46a29 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -18,6 +18,7 @@ export * from './ManyToManyJoin'; export * from './ManyToOne'; export * from './Model'; export * from './NumericField'; +export * from './ObjectType'; export * from './OneToMany'; export * from './StringField'; export * from './UserId'; diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index f5383223..d8d303a7 100644 --- a/src/metadata/metadata-storage.ts +++ b/src/metadata/metadata-storage.ts @@ -35,6 +35,7 @@ export interface ColumnMetadata extends DecoratorCommonOptions { propertyName: string; dataType?: ColumnType; // int16, jsonb, etc... default?: any; + gqlFieldType?: Function; enum?: GraphQLEnumType; enumName?: string; unique?: boolean; @@ -159,6 +160,17 @@ export class MetadataStorage { ]; } + // Adds a class so that we can import it into classes.ts + // This is typically used when adding a strongly typed JSON column + // using JSONField with a gqlFieldType + addClass(name: string, klass: any, filename: string) { + this.classMap[name] = { + filename, + klass, + name + }; + } + addModel(name: string, klass: any, filename: string, options: Partial = {}) { if (this.interfaces.indexOf(name) > -1) { return; // Don't add interface types to model list diff --git a/src/schema/TypeORMConverter.ts b/src/schema/TypeORMConverter.ts index aec07b3b..4ec592ca 100644 --- a/src/schema/TypeORMConverter.ts +++ b/src/schema/TypeORMConverter.ts @@ -5,8 +5,8 @@ import { ColumnMetadata, getMetadataStorage, ModelMetadata } from '../metadata'; import { columnToGraphQLType, - columnTypeToGraphQLDataType, - columnInfoToTypeScriptType + columnToGraphQLDataType, + columnToTypeScriptType } from './type-conversion'; import { WhereOperator } from '../torm'; @@ -36,14 +36,6 @@ export function filenameToImportPath(filename: string): string { return filename.replace(/\.(j|t)s$/, '').replace(/\\/g, '/'); } -export function columnToGraphQLDataType(column: ColumnMetadata): string { - return columnTypeToGraphQLDataType(column.type, column.enumName); -} - -export function columnToTypeScriptType(column: ColumnMetadata): string { - return columnInfoToTypeScriptType(column.type, column.enumName); -} - export function generateEnumMapImports(): string[] { const imports: string[] = []; const enumMap = getMetadataStorage().enumMap; @@ -261,7 +253,7 @@ export function entityToUpdateInputArgs(model: ModelMetadata): string { } function columnToTypes(column: ColumnMetadata) { - const graphqlType = columnToGraphQLType(column.type, column.enumName); + const graphqlType = columnToGraphQLType(column); const tsType = columnToTypeScriptType(column); return { graphqlType, tsType }; @@ -469,7 +461,7 @@ export function entityToWhereInput(model: ModelMetadata): string { } } else if (column.type === 'json') { fieldTemplates += ` - @TypeGraphQLField(() => GraphQLJSONObject, { nullable: true }) + @TypeGraphQLField(() => ${graphQLDataType}, { nullable: true }) ${column.propertyName}_json?: JsonObject; `; } diff --git a/src/schema/type-conversion.ts b/src/schema/type-conversion.ts index 6c6b712e..8ed59cc7 100644 --- a/src/schema/type-conversion.ts +++ b/src/schema/type-conversion.ts @@ -12,41 +12,23 @@ import { GraphQLISODateTime } from 'type-graphql'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { GraphQLJSONObject } = require('graphql-type-json'); -import { FieldType } from '../metadata'; +import { ColumnMetadata, FieldType } from '../metadata'; +type GraphQLCustomType = any; + +// TODO: need to figure out how to type a custom GraphQLField type export function columnToGraphQLType( - type: FieldType, - enumName?: string -): GraphQLScalarType | string { - if (typeof enumName !== undefined && enumName) { - return String(enumName); + column: ColumnMetadata +): GraphQLScalarType | string | GraphQLCustomType { + if (typeof column.enumName !== undefined && column.enumName) { + return String(column.enumName); } - switch (type) { - case 'id': - return GraphQLID; - case 'email': - case 'string': - return GraphQLString; - case 'boolean': - return GraphQLBoolean; - case 'float': - case 'numeric': - return GraphQLFloat; - case 'integer': - return GraphQLInt; - case 'date': - return GraphQLISODateTime; - case 'datetime': - return GraphQLISODateTime; - case 'dateonly': - return DateResolver; - case 'json': - return GraphQLJSONObject; - case 'enum': - // This is to make TS happy and so that we'll get a compile time error if a new type is added - throw new Error("Will never get here because it's handled above"); + if (column.type === 'json' && typeof column.gqlFieldType !== 'undefined') { + return column.gqlFieldType; } + + return columnTypeToGraphQLType(column.type); } export function columnTypeToGraphQLType(type: FieldType): GraphQLScalarType { @@ -77,8 +59,8 @@ export function columnTypeToGraphQLType(type: FieldType): GraphQLScalarType { } } -export function columnTypeToGraphQLDataType(type: FieldType, enumName?: string): string { - const graphQLType = columnToGraphQLType(type, enumName); +export function columnToGraphQLDataType(column: ColumnMetadata): string { + const graphQLType = columnToGraphQLType(column); // Sometimes we want to return the full blow GraphQL data type, but sometimes we want to return // the more readable name. Ex: @@ -92,17 +74,22 @@ export function columnTypeToGraphQLDataType(type: FieldType, enumName?: string): } } -export function columnInfoToTypeScriptType(type: FieldType, enumName?: string): string { - if (type === 'id') { +export function columnToTypeScriptType(column: ColumnMetadata): string { + // TODO: clean this up. Ideally we'd deduce the TS type from the GraphQL type + if (column.type === 'json' && typeof column.gqlFieldType !== 'undefined') { + return column.gqlFieldType.name; + } + + if (column.type === 'id') { return 'string'; // TODO: should this be ID_TYPE? - } else if (type === 'dateonly') { + } else if (column.type === 'dateonly') { return 'DateOnlyString'; - } else if (type === 'datetime') { + } else if (column.type === 'datetime') { return 'DateTimeString'; - } else if (enumName) { - return String(enumName); + } else if (column.enumName) { + return String(column.enumName); } else { - const graphqlType = columnTypeToGraphQLDataType(type, enumName); + const graphqlType = columnToGraphQLDataType(column); const typeMap: any = { Boolean: 'boolean', DateTime: 'Date', From 3b32b4e966ccaba73f75c021f206d85df87b0664 Mon Sep 17 00:00:00 2001 From: Dan Caddigan Date: Sun, 20 Sep 2020 21:40:05 -0400 Subject: [PATCH 2/2] wip --- examples/02-complex-example/generated/binding.ts | 4 ++-- examples/02-complex-example/generated/classes.ts | 4 ++-- examples/02-complex-example/generated/schema.graphql | 4 ++-- examples/02-complex-example/src/modules/user/user.model.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/02-complex-example/generated/binding.ts b/examples/02-complex-example/generated/binding.ts index ddf22198..e43b5164 100644 --- a/examples/02-complex-example/generated/binding.ts +++ b/examples/02-complex-example/generated/binding.ts @@ -164,7 +164,7 @@ export interface UserCreateInput { jsonField?: JSONObject | null jsonFieldNoFilter?: JSONObject | null typedJsonField?: EventObjectInput | null - stringField: String + stringField?: String | null noFilterField?: String | null noSortField?: String | null noFilterOrSortField?: String | null @@ -527,7 +527,7 @@ export interface User extends BaseGraphQLObject { jsonField?: JSONObject | null jsonFieldNoFilter?: JSONObject | null typedJsonField?: EventObject | null - stringField: String + stringField?: String | null noFilterField?: String | null noSortField?: String | null noFilterOrSortField?: String | null diff --git a/examples/02-complex-example/generated/classes.ts b/examples/02-complex-example/generated/classes.ts index 0d10c50b..e5c0d711 100644 --- a/examples/02-complex-example/generated/classes.ts +++ b/examples/02-complex-example/generated/classes.ts @@ -794,8 +794,8 @@ export class UserCreateInput { @TypeGraphQLField(() => EventObject, { nullable: true }) typedJsonField?: EventObject; - @TypeGraphQLField() - stringField!: string; + @TypeGraphQLField({ nullable: true }) + stringField?: string; @TypeGraphQLField({ nullable: true }) noFilterField?: string; diff --git a/examples/02-complex-example/generated/schema.graphql b/examples/02-complex-example/generated/schema.graphql index 888fb278..19cebec0 100644 --- a/examples/02-complex-example/generated/schema.graphql +++ b/examples/02-complex-example/generated/schema.graphql @@ -149,7 +149,7 @@ type User implements BaseGraphQLObject { typedJsonField: EventObject """This is a string field""" - stringField: String! + stringField: String noFilterField: String noSortField: String noFilterOrSortField: String @@ -193,7 +193,7 @@ input UserCreateInput { jsonField: JSONObject jsonFieldNoFilter: JSONObject typedJsonField: EventObjectInput - stringField: String! + stringField: String noFilterField: String noSortField: String noFilterOrSortField: String diff --git a/examples/02-complex-example/src/modules/user/user.model.ts b/examples/02-complex-example/src/modules/user/user.model.ts index d780334b..9109e9a5 100644 --- a/examples/02-complex-example/src/modules/user/user.model.ts +++ b/examples/02-complex-example/src/modules/user/user.model.ts @@ -111,7 +111,7 @@ export class User extends BaseModel { @StringField({ maxLength: 50, minLength: 2, - nullable: false, + nullable: true, description: 'This is a string field' }) stringField: string;