From eeddc26e52a2ef18bd34fd727eb06a7d24f3ae59 Mon Sep 17 00:00:00 2001 From: Dan Caddigan Date: Fri, 11 Sep 2020 00:57:17 -0400 Subject: [PATCH] feat(relay): update connection pattern to conform to relay (#408) * feat(relay): update connection pattern to conform to relay --- .../02-complex-example/generated/binding.ts | 4 +- .../02-complex-example/generated/classes.ts | 4 +- .../generated/schema.graphql | 4 +- .../src/modules/user/user.model.ts | 1 + package.json | 3 + src/core/BaseModel.ts | 9 + src/core/BaseService.test.ts | 279 +++++++++++++++++ src/core/BaseService.ts | 287 ++++++++++++++---- src/core/GraphQLInfoService.ts | 65 ++++ src/core/RelayService.test.ts | 222 ++++++++++++++ src/core/RelayService.ts | 247 +++++++++++++++ src/core/encoding.ts | 24 ++ src/core/index.ts | 3 +- src/core/tests/entity/MyBase.model.ts | 24 ++ src/decorators/Fields.ts | 6 + src/decorators/debug.ts | 34 +++ src/decorators/index.ts | 1 + .../__snapshots__/schema.test.ts.snap | 15 +- .../__snapshots__/server.test.ts.snap | 11 - src/test/functional/cli.test.ts | 8 +- src/test/functional/server.test.ts | 66 ++-- src/test/generated/binding.ts | 15 +- src/test/generated/schema.graphql | 15 +- src/test/modules/dish/dish.resolver.ts | 57 +++- src/test/setupFiles.ts | 18 ++ src/test/setupFilesAfterEnv.ts | 2 +- src/tgql/PageInfo.ts | 26 +- src/utils/decoratorComposer.ts | 3 +- tsconfig.eslint.json | 2 +- tsconfig.json | 1 + tsconfig.test.json | 4 +- 31 files changed, 1305 insertions(+), 155 deletions(-) create mode 100644 src/core/BaseService.test.ts create mode 100644 src/core/GraphQLInfoService.ts create mode 100644 src/core/RelayService.test.ts create mode 100644 src/core/RelayService.ts create mode 100644 src/core/encoding.ts create mode 100644 src/core/tests/entity/MyBase.model.ts create mode 100644 src/decorators/debug.ts create mode 100644 src/test/setupFiles.ts diff --git a/examples/02-complex-example/generated/binding.ts b/examples/02-complex-example/generated/binding.ts index e4054ab3..0cf2fe0a 100644 --- a/examples/02-complex-example/generated/binding.ts +++ b/examples/02-complex-example/generated/binding.ts @@ -153,7 +153,7 @@ export interface UserCreateInput { bigIntField?: Float | null jsonField?: JSONObject | null jsonFieldNoFilter?: JSONObject | null - stringField: String + stringField?: String | null noFilterField?: String | null noSortField?: String | null noFilterOrSortField?: String | null @@ -504,7 +504,7 @@ export interface User extends BaseGraphQLObject { bigIntField?: Int | null jsonField?: JSONObject | null jsonFieldNoFilter?: JSONObject | 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 3829d447..e71a6d90 100644 --- a/examples/02-complex-example/generated/classes.ts +++ b/examples/02-complex-example/generated/classes.ts @@ -784,8 +784,8 @@ export class UserCreateInput { @TypeGraphQLField(() => GraphQLJSONObject, { nullable: true }) jsonFieldNoFilter?: JsonObject; - @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 71444f62..6ef32ffc 100644 --- a/examples/02-complex-example/generated/schema.graphql +++ b/examples/02-complex-example/generated/schema.graphql @@ -129,7 +129,7 @@ type User implements BaseGraphQLObject { jsonFieldNoFilter: JSONObject """This is a string field""" - stringField: String! + stringField: String noFilterField: String noSortField: String noFilterOrSortField: String @@ -172,7 +172,7 @@ input UserCreateInput { bigIntField: Float jsonField: JSONObject jsonFieldNoFilter: JSONObject - 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 6b9b5aa8..6e328f01 100644 --- a/examples/02-complex-example/src/modules/user/user.model.ts +++ b/examples/02-complex-example/src/modules/user/user.model.ts @@ -131,6 +131,7 @@ export class User extends BaseModel { @StringField({ dataType: 'varchar', nullable: true }) varcharField: string; + // DOCUMENTATION TODO // Spacial fields // https://github.com/typeorm/typeorm/blob/master/test/functional/spatial/postgres/entity/Post.ts @CustomField({ diff --git a/package.json b/package.json index 1ea53b71..879cfd94 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,9 @@ "singleQuote": true }, "jest": { + "setupFiles": [ + "./src/test/setupFiles.ts" + ], "setupFilesAfterEnv": [ "./src/test/setupFilesAfterEnv.ts" ], diff --git a/src/core/BaseModel.ts b/src/core/BaseModel.ts index 917fd80e..50f95e11 100644 --- a/src/core/BaseModel.ts +++ b/src/core/BaseModel.ts @@ -61,6 +61,15 @@ export abstract class BaseModel implements BaseGraphQLObject { return this.id || shortid.generate(); } + // V3: DateTime should use getter to return ISO8601 string + getValue(field: any) { + const self = this as any; + if (self[field] instanceof Date) { + return self[field].toISOString(); + } + return self[field]; + } + @BeforeInsert() setId() { this.id = this.getId(); diff --git a/src/core/BaseService.test.ts b/src/core/BaseService.test.ts new file mode 100644 index 00000000..1c729ab2 --- /dev/null +++ b/src/core/BaseService.test.ts @@ -0,0 +1,279 @@ +// TODO: +// - test totalCount +// +// Good test example: https://github.com/typeorm/typeorm/blob/master/test/functional/query-builder/brackets/query-builder-brackets.ts +import 'reflect-metadata'; +import { Brackets, Connection } from 'typeorm'; +import { Container } from 'typedi'; + +import { createDBConnection } from '../torm'; + +import { MyBase, MyBaseService } from './tests/entity/MyBase.model'; + +describe('BaseService', () => { + let connection: Connection; + let service: MyBaseService; + beforeAll(async () => { + connection = await createDBConnection({ + entities: [__dirname + '/tests/entity/*{.js,.ts}'] + // logging: 'all' + }); + + service = Container.get('MyBaseService'); + }); + beforeEach(async () => { + await connection.synchronize(true); + }); + afterAll(() => connection.close()); + + test('buildFindQuery', async () => { + await service.createMany( + [ + { firstName: 'AA', lastName: '01' }, + { firstName: 'BB', lastName: '02' }, + { firstName: 'CC', lastName: '03' }, + { firstName: 'DD', lastName: '04' }, + { firstName: 'EE', lastName: '05' }, + { firstName: 'FF', lastName: '06' }, + { firstName: 'GG', lastName: '07' }, + { firstName: 'HH', lastName: '08' }, + { firstName: 'II', lastName: '09' }, + { firstName: 'JJ', lastName: '10' }, + { firstName: 'KK', lastName: '11' }, + { firstName: 'LL', lastName: '12' }, + { firstName: 'MM', lastName: '13' }, + { firstName: 'NN', lastName: '14' } + ], + '1' + ); + + const results = await service + .buildFindQuery({ + OR: [ + { firstName_contains: 'A' }, + { firstName_contains: 'B' }, + { firstName_contains: 'C' }, + { firstName_contains: 'D' }, + { firstName_contains: 'J' }, + { firstName_contains: 'K' } + ], + AND: [{ lastName_contains: '0' }] + } as any) + .getMany(); + + expect(results.length).toEqual(5); + }); + + describe('findConnection', () => { + test('returns all objects with no inputs', async () => { + await service.createMany( + [ + { firstName: 'AA', lastName: '01' }, + { firstName: 'BB', lastName: '02' }, + { firstName: 'CC', lastName: '03' } + ], + '1' + ); + + const results = await service.findConnection(); + + expect(results.edges?.length).toEqual(3); + }); + + test('returns a limited number of items if asked', async () => { + await service.createMany( + [ + { firstName: 'AA', lastName: '01' }, + { firstName: 'BB', lastName: '02' }, + { firstName: 'CC', lastName: '03' } + ], + '1' + ); + + const results = await service.findConnection( + undefined, + 'firstName_ASC', + { first: 2 }, + { edges: { node: { firstName: true } } } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['AA', 'BB']); + }); + + test('returns a limited number of items (using last)', async () => { + await service.createMany( + [ + { firstName: 'AA', lastName: '01' }, + { firstName: 'BB', lastName: '02' }, + { firstName: 'CC', lastName: '03' } + ], + '1' + ); + + const results = await service.findConnection( + undefined, + 'firstName_ASC', + { last: 2 }, + { edges: { node: { firstName: true } } } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['CC', 'BB']); + }); + + test('query with first, grab cursor and refetch', async () => { + await service.createMany( + [ + { firstName: 'AA', lastName: '01' }, + { firstName: 'BB', lastName: '02' }, + { firstName: 'CC', lastName: '03' }, + { firstName: 'DD', lastName: '04' }, + { firstName: 'EE', lastName: '05' }, + { firstName: 'FF', lastName: '06' }, + { firstName: 'GG', lastName: '07' } + ], + '1' + ); + + let results = await service.findConnection( + undefined, + 'firstName_ASC', + { first: 3 }, + { + edges: { node: { firstName: true } }, + pageInfo: { endCursor: {}, hasNextPage: {}, hasPreviousPage: {} } + } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['AA', 'BB', 'CC']); + + const cursor = results.pageInfo?.endCursor; + + results = await service.findConnection( + undefined, + 'firstName_ASC', + { first: 3, after: cursor }, + { + edges: { node: { firstName: true } }, + pageInfo: { endCursor: {}, hasNextPage: {}, hasPreviousPage: {} } + } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['DD', 'EE', 'FF']); + }); + + test('query with last, grab cursor and refetch', async () => { + await service.createMany( + [ + { firstName: 'AA', lastName: '01' }, + { firstName: 'BB', lastName: '02' }, + { firstName: 'CC', lastName: '03' }, + { firstName: 'DD', lastName: '04' }, + { firstName: 'EE', lastName: '05' }, + { firstName: 'FF', lastName: '06' }, + { firstName: 'GG', lastName: '07' } + ], + '1' + ); + + let results = await service.findConnection( + undefined, + 'firstName_ASC', + { last: 3 }, + { + edges: { node: { firstName: true } }, + pageInfo: { endCursor: {}, hasNextPage: {}, hasPreviousPage: {} } + } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['GG', 'FF', 'EE']); + + const cursor = results.pageInfo?.endCursor; + + results = await service.findConnection( + undefined, + 'firstName_ASC', + { last: 3, before: cursor }, + { + edges: { node: { firstName: true } }, + pageInfo: { endCursor: {}, hasNextPage: {}, hasPreviousPage: {} } + } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['DD', 'CC', 'BB']); + }); + }); + + test('multiple sorts, query with first, grab cursor and refetch', async () => { + await service.createMany( + [ + { registered: true, firstName: 'AA', lastName: '01' }, + { registered: false, firstName: 'BB', lastName: '02' }, + { registered: true, firstName: 'CC', lastName: '03' }, + { registered: false, firstName: 'DD', lastName: '04' }, + { registered: true, firstName: 'EE', lastName: '05' }, + { registered: false, firstName: 'FF', lastName: '06' }, + { registered: true, firstName: 'GG', lastName: '07' } + ], + '1' + ); + + let results = await service.findConnection( + undefined, + ['registered_ASC', 'firstName_ASC'], + { first: 4 }, + { + edges: { node: { firstName: true, registered: true } }, + pageInfo: { endCursor: {}, hasNextPage: {}, hasPreviousPage: {} } + } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['BB', 'DD', 'FF', 'AA']); + expect(results.pageInfo?.hasNextPage).toEqual(true); + + const cursor = results.pageInfo?.endCursor; + + results = await service.findConnection( + undefined, + ['registered_ASC', 'firstName_ASC'], + { first: 3, after: cursor }, + { + edges: { node: { firstName: true } }, + pageInfo: { endCursor: {}, hasNextPage: {}, hasPreviousPage: {} } + } + ); + + expect(results.edges?.map(edge => edge.node?.firstName)).toEqual(['CC', 'EE', 'GG']); + }); + + test.skip('fun with brackets', async () => { + await service.createMany( + [ + { firstName: 'Timber', lastName: 'Saw' }, + { firstName: 'Pleerock', lastName: 'Pleerock' }, + { firstName: 'Alex', lastName: 'Messer' } + ], + '1' + ); + + const bases = await connection + .createQueryBuilder(MyBase, 'user') + .where('user.lastName = :lastName0', { lastName0: 'Pleerock' }) + .orWhere( + new Brackets(qb => { + qb.where('user.firstName = :firstName1', { + firstName1: 'Timber' + }).andWhere('user.lastName = :lastName1', { lastName1: 'Saw' }); + }) + ) + .orWhere( + new Brackets(qb => { + qb.where('user.firstName = :firstName2', { + firstName2: 'Alex' + }).andWhere('user.lastName = :lastName2', { lastName2: 'Messer' }); + }) + ) + .getMany(); + + expect(bases.length).toEqual(3); + }); +}); diff --git a/src/core/BaseService.ts b/src/core/BaseService.ts index 340716a4..51cf5c5a 100644 --- a/src/core/BaseService.ts +++ b/src/core/BaseService.ts @@ -1,22 +1,70 @@ import { validate } from 'class-validator'; import { ArgumentValidationError } from 'type-graphql'; -import { DeepPartial, EntityManager, getRepository, Repository, SelectQueryBuilder } from 'typeorm'; +import { + Brackets, + DeepPartial, + EntityManager, + getRepository, + Repository, + SelectQueryBuilder +} from 'typeorm'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; -import { ConnectionResult, StandardDeleteResponse } from '../tgql'; +import { debug } from '../decorators'; +import { StandardDeleteResponse } from '../tgql'; import { addQueryBuilderWhereItem } from '../torm'; import { BaseModel } from './'; import { StringMap, WhereInput } from './types'; +import { isArray } from 'util'; +import { + RelayFirstAfter, + RelayLastBefore, + RelayService, + RelayPageOptions, + ConnectionResult +} from './RelayService'; +import { GraphQLInfoService, ConnectionInputFields } from './GraphQLInfoService'; export interface BaseOptions { manager?: EntityManager; // Allows consumers to pass in a TransactionManager } +interface WhereFilterAttributes { + [key: string]: string | number | null; +} + +type WhereExpression = { + AND?: WhereExpression[]; + OR?: WhereExpression[]; +} & WhereFilterAttributes; + +export type LimitOffset = { + limit: number; + offset?: number; +}; + +export type PaginationOptions = LimitOffset | RelayPageOptions; + +export type RelayPageOptionsInput = { + first?: number; + after?: string; + last?: number; + before?: string; +}; + +function isLastBefore( + pageType: PaginationOptions | RelayPageOptionsInput +): pageType is RelayLastBefore { + return (pageType as RelayLastBefore).last !== undefined; +} + export class BaseService { manager: EntityManager; columnMap: StringMap; klass: string; + relayService: RelayService; + graphQLInfoService: GraphQLInfoService; // TODO: any -> ObjectType (or something close) // V3: Only ask for entityClass, we can get repository and manager from that @@ -25,6 +73,10 @@ export class BaseService { throw new Error('BaseService requires an entity Class'); } + // TODO: use DI + this.relayService = new RelayService(); + this.graphQLInfoService = new GraphQLInfoService(); + // V3: remove the need to inject a repository, we simply need the entityClass and then we can do // everything we need to do. // For now, we'll keep the API the same so that there are no breaking changes @@ -51,59 +103,109 @@ export class BaseService { this.klass = this.repository.metadata.name.toLowerCase(); } - getPageInfo(limit: number, offset: number, totalCount: number) { - return { - hasNextPage: totalCount > offset + limit, - hasPreviousPage: offset > 0, - limit, - offset, - totalCount - }; - } - async find( - where?: any, + where?: any, // V3: WhereExpression = {}, orderBy?: string, limit?: number, offset?: number, fields?: string[] ): Promise { - return this.buildFindQuery(where, orderBy, limit, offset, fields).getMany(); + // TODO: FEATURE - make the default limit configurable + limit = limit ?? 20; + return this.buildFindQuery(where, orderBy, { limit, offset }, fields).getMany(); } + @debug('base-service:findConnection') async findConnection( - where?: any, - orderBy?: string, - limit?: number, - offset?: number, - fields?: string[] + whereUserInput: any = {}, // V3: WhereExpression = {}, + orderBy?: string | string[], + _pageOptions: RelayPageOptionsInput = {}, + fields?: ConnectionInputFields ): Promise> { - const qb = this.buildFindQuery(where, orderBy, limit, offset, fields); - const [nodes, totalCount] = await qb.getManyAndCount(); + // TODO: if the orderby items aren't included in `fields`, should we automatically include? + // TODO: FEATURE - make the default limit configurable - limit = limit ?? 50; - offset = offset ?? 0; + const DEFAULT_LIMIT = 50; + const { first, after, last, before } = _pageOptions; + + let relayPageOptions; + let limit; + let cursor; + if (isLastBefore(_pageOptions)) { + limit = last || DEFAULT_LIMIT; + cursor = before; + relayPageOptions = { + last: limit, + before + } as RelayLastBefore; + } else { + limit = first || DEFAULT_LIMIT; + cursor = after; + relayPageOptions = { + first: limit, + after + } as RelayFirstAfter; + } + + const requestedFields = this.graphQLInfoService.connectionOptions(fields); + const sorts = this.relayService.normalizeSort(orderBy); + let whereFromCursor = {}; + if (cursor) { + whereFromCursor = this.relayService.getFilters(orderBy, relayPageOptions); + } + const whereCombined: any = { AND: [whereUserInput, whereFromCursor] }; + + const qb = this.buildFindQuery( + whereCombined, + this.relayService.effectiveOrderStrings(sorts, relayPageOptions), + { limit: limit + 1 }, // We ask for 1 too many so that we know if there is an additional page + requestedFields.selectFields + ); + + let rawData; + let totalCountOption = {}; + if (requestedFields.totalCount) { + let totalCount; + [rawData, totalCount] = await qb.getManyAndCount(); + totalCountOption = { totalCount }; + } else { + rawData = await qb.getMany(); + } + + // If we got the n+1 that we requested, pluck the last item off + const returnData = rawData.length > limit ? rawData.slice(0, limit) : rawData; return { - nodes, - pageInfo: this.getPageInfo(limit, offset, totalCount) + ...totalCountOption, + edges: returnData.map((item: E) => { + return { + node: item, + cursor: this.relayService.encodeCursor(item, sorts) + }; + }), + pageInfo: this.relayService.getPageInfo(rawData, sorts, relayPageOptions) }; } - private buildFindQuery( - where?: any, - orderBy?: string, - limit?: number, - offset?: number, + @debug('base-service:buildFindQuery') + buildFindQuery( + where: WhereExpression = {}, + orderBy?: string | string[], + pageOptions?: LimitOffset, fields?: string[] ): SelectQueryBuilder { + const DEFAULT_LIMIT = 50; let qb = this.manager.createQueryBuilder(this.entityClass, this.klass); - - if (limit) { - qb = qb.take(limit); + if (!pageOptions) { + pageOptions = { + limit: DEFAULT_LIMIT + }; } - if (offset) { - qb = qb.skip(offset); + + qb = qb.take(pageOptions.limit || DEFAULT_LIMIT); + + if (pageOptions.offset) { + qb = qb.skip(pageOptions.offset); } if (fields) { @@ -116,25 +218,28 @@ export class BaseService { const selection = fields.map(field => `${this.klass}.${field}`); qb = qb.select(selection); } + if (orderBy) { - // TODO: allow multiple sorts - // See https://github.com/typeorm/typeorm/blob/master/docs/select-query-builder.md#adding-order-by-expression - const parts = orderBy.toString().split('_'); - // TODO: ensure attr is one of the properties on the model - const attr = parts[0]; - const direction: 'ASC' | 'DESC' = parts[1] as 'ASC' | 'DESC'; - - qb = qb.orderBy(this.attrToDBColumn(attr), direction); - } + if (!isArray(orderBy)) { + orderBy = [orderBy]; + } + + orderBy.forEach((orderByItem: string) => { + const parts = orderByItem.toString().split('_'); + // TODO: ensure attr is one of the properties on the model + const attr = parts[0]; + const direction: 'ASC' | 'DESC' = parts[1] as 'ASC' | 'DESC'; - where = where || {}; + qb = qb.addOrderBy(this.attrToDBColumn(attr), direction); + }); + } // Soft-deletes are filtered out by default, setting `deletedAt_all` is the only way to turn this off const hasDeletedAts = Object.keys(where).find(key => key.indexOf('deletedAt_') === 0); // If no deletedAt filters specified, hide them by default if (!hasDeletedAts) { // eslint-disable-next-line @typescript-eslint/camelcase - where = { ...where, deletedAt_eq: null }; // Filter out soft-deleted items + where.deletedAt_eq = null; // Filter out soft-deleted items } else if (typeof where.deletedAt_all !== 'undefined') { // Delete this param so that it doesn't try to filter on the magic `all` param // Put this here so that we delete it even if `deletedAt_all: false` specified @@ -144,16 +249,24 @@ export class BaseService { // do nothing because the specific deleted at filters will be added by processWhereOptions } - if (Object.keys(where).length) { + // Keep track of a counter so that TypeORM doesn't reuse our variables that get passed into the query if they + // happen to reference the same column + const paramKeyCounter = { counter: 0 }; + const processWheres = ( + qb: SelectQueryBuilder, + where: WhereFilterAttributes + ): SelectQueryBuilder => { // where is of shape { userName_contains: 'a' } - Object.keys(where).forEach((k: string, i: number) => { - const paramKey = BaseService.buildParamKey(i); + Object.keys(where).forEach((k: string) => { + const paramKey = `param${paramKeyCounter.counter}`; + // increment counter each time we add a new where clause so that TypeORM doesn't reuse our input variables + paramKeyCounter.counter = paramKeyCounter.counter + 1; const key = k as keyof W; // userName_contains const parts = key.toString().split('_'); // ['userName', 'contains'] const attr = parts[0]; // userName const operator = parts.length > 1 ? parts[1] : 'eq'; // contains - qb = addQueryBuilderWhereItem( + return addQueryBuilderWhereItem( qb, paramKey, this.attrToDBColumn(attr), @@ -161,12 +274,80 @@ export class BaseService { where[key] ); }); + return qb; + }; + + // WhereExpression comes in the following shape: + // { + // AND?: WhereInput[]; + // OR?: WhereInput[]; + // [key: string]: string | number | null; + // } + const processWhereInput = ( + qb: SelectQueryBuilder, + where: WhereExpression + ): SelectQueryBuilder => { + const { AND, OR, ...rest } = where; + + if (AND && AND.length) { + const ands = AND.filter(value => JSON.stringify(value) !== '{}'); + if (ands.length) { + qb.andWhere( + new Brackets(qb2 => { + ands.forEach((where: WhereExpression) => { + if (Object.keys(where).length === 0) { + return; // disregard empty where objects + } + qb2.andWhere( + new Brackets(qb3 => { + processWhereInput(qb3 as SelectQueryBuilder, where); + return qb3; + }) + ); + }); + }) + ); + } + } + + if (OR && OR.length) { + const ors = OR.filter(value => JSON.stringify(value) !== '{}'); + if (ors.length) { + qb.andWhere( + new Brackets(qb2 => { + ors.forEach((where: WhereExpression) => { + if (Object.keys(where).length === 0) { + return; // disregard empty where objects + } + + qb2.orWhere( + new Brackets(qb3 => { + processWhereInput(qb3 as SelectQueryBuilder, where); + return qb3; + }) + ); + }); + }) + ); + } + } + + if (rest) { + processWheres(qb, rest); + } + return qb; + }; + + if (Object.keys(where).length) { + processWhereInput(qb, where); } return qb; } - async findOne(where: W): Promise { + async findOne( + where: W // V3: WhereExpression + ): Promise { const items = await this.find(where); if (!items.length) { throw new Error(`Unable to find ${this.entityClass.name} where ${JSON.stringify(where)}`); @@ -228,7 +409,7 @@ export class BaseService { // W extends Partial async update( data: DeepPartial, - where: W, + where: W, // V3: WhereExpression, userId: string, options?: BaseOptions ): Promise { @@ -274,6 +455,4 @@ export class BaseService { attrToDBColumn = (attr: string): string => { return `"${this.klass}"."${this.columnMap[attr]}"`; }; - - static buildParamKey = (i: number): string => `param${i}`; } diff --git a/src/core/GraphQLInfoService.ts b/src/core/GraphQLInfoService.ts new file mode 100644 index 00000000..10a2fbd0 --- /dev/null +++ b/src/core/GraphQLInfoService.ts @@ -0,0 +1,65 @@ +// import { GraphQLResolveInfo } from 'graphql'; +import * as graphqlFields from 'graphql-fields'; + +import { Service } from 'typedi'; + +export type ConnectionInputFields = { + totalCount?: object; + edges?: { + node?: object; + cursor?: object; + }; + pageInfo?: { + hasNextPage: object; + hasPreviousPage: object; + startCursor?: object; + endCursor?: object; + }; +}; + +export interface Node { + [key: string]: any; +} + +@Service() +export class GraphQLInfoService { + getFields(info: any): ConnectionInputFields { + return graphqlFields(info); + } + + connectionOptions(fields?: ConnectionInputFields) { + if (!fields) { + return { + selectFields: [], + totalCount: false, + endCursor: false, + startCursor: '', + edgeCursors: '' + }; + } + + return { + selectFields: this.baseFields(fields?.edges?.node), + totalCount: isDefined(fields.totalCount), + endCursor: isDefined(fields.pageInfo?.endCursor), + startCursor: isDefined(fields.pageInfo?.startCursor), + edgeCursors: isDefined(fields?.edges?.cursor) + }; + } + + baseFields(node?: Node): string[] { + if (!node) { + return []; + } + + const scalars = Object.keys(node).filter(item => { + return Object.keys(node[item]).length === 0; + }); + + return scalars; + } +} + +function isDefined(obj: unknown): boolean { + return typeof obj !== 'undefined'; +} diff --git a/src/core/RelayService.test.ts b/src/core/RelayService.test.ts new file mode 100644 index 00000000..78c79ad5 --- /dev/null +++ b/src/core/RelayService.test.ts @@ -0,0 +1,222 @@ +import { Container } from 'typedi'; +import { Entity } from 'typeorm'; + +import { BaseModel, StringField } from '../'; + +import { EncodingService } from './encoding'; +import { RelayService, SortDirection } from './RelayService'; + +@Entity() +export class Foo extends BaseModel { + @StringField() + name?: string; +} + +describe('RelayService', () => { + const relay = Container.get(RelayService); + const e = Container.get(EncodingService); + const sortIdASC = { column: 'id', direction: 'ASC' as SortDirection }; + const sortIdDESC = { column: 'id', direction: 'DESC' as SortDirection }; + const sortCreatedAtASC = { column: 'createdAt', direction: 'ASC' as SortDirection }; + const sortCreatedAtDESC = { column: 'createdAt', direction: 'DESC' as SortDirection }; + const sortFooDESC = { column: 'foo', direction: 'DESC' as SortDirection }; + + const foo = new Foo(); + foo.id = '1'; + foo.name = 'Foo'; + foo.createdAt = new Date('1981-10-15'); + + const bar = new Foo(); + bar.id = '2'; + bar.name = 'Bar'; + bar.createdAt = new Date('1989-11-20'); + + describe('toSortArray', () => { + test('defaults to empty array', () => { + expect(relay.toSortArray()).toEqual([]); + }); + + test('turns a sort into a Sort array', () => { + expect(relay.toSortArray(sortIdASC)).toStrictEqual([sortIdASC]); + }); + + test('works with ID sort DESC', () => { + expect(relay.toSortArray('id_DESC')).toEqual([sortIdDESC]); + }); + + test('works with non-ID sorts', () => { + expect(relay.toSortArray('createdAt_ASC')).toEqual([sortCreatedAtASC]); + }); + + test('works with an array input including ID', () => { + expect(relay.toSortArray(['createdAt_ASC', 'id_DESC'])).toEqual([ + sortCreatedAtASC, + sortIdDESC + ]); + }); + + test('works with an array input not including ID', () => { + expect(relay.toSortArray(['createdAt_ASC', 'foo_DESC'])).toEqual([ + sortCreatedAtASC, + sortFooDESC + ]); + }); + }); + + describe('normalizeSort', () => { + test('defaults to ID', () => { + expect(relay.normalizeSort()).toStrictEqual([sortIdASC]); + }); + + test('Adds ID to sort', () => { + expect(relay.normalizeSort(sortFooDESC)).toEqual([sortFooDESC, sortIdASC]); + }); + + test('Does not add ID to sort if already sorting by ID', () => { + expect(relay.normalizeSort(sortIdASC)).toStrictEqual([sortIdASC]); + expect(relay.normalizeSort(sortIdDESC)).toStrictEqual([sortIdDESC]); + }); + }); + + describe('encodeCursorItem', () => { + test('Works with Dates', () => { + expect(relay.encodeCursor(foo, sortCreatedAtDESC)).toBe( + e.encode(['1981-10-15T00:00:00.000Z', '1']) + ); + }); + }); + + describe('encodeCursor', () => { + test('Works with multiple sorts', () => { + const sortNameASC = { column: 'name', direction: 'ASC' as SortDirection }; + + expect(relay.encodeCursor(foo, [sortCreatedAtDESC, sortNameASC])).toBe( + e.encode(['1981-10-15T00:00:00.000Z', 'Foo', '1']) + ); + }); + }); + + describe('decodeCursor', () => { + test('Works with multiple sorts', () => { + const obj = relay.decodeCursor( + 'W1siY3JlYXRlZEF0IiwiREVTQyIsIjE5ODEtMTAtMTVUMDA6MDA6MDAuMDAwWiJdLFsibmFtZSIsIkFTQyIsIkZvbyJdLFsiaWQiLCJBU0MiLCIxIl1d' + ); + + expect(obj).toStrictEqual([ + ['createdAt', 'DESC', '1981-10-15T00:00:00.000Z'], + ['name', 'ASC', 'Foo'], + ['id', 'ASC', '1'] + ]); + }); + }); + + describe('getFirstAndLast', () => { + test('throws if data has no items', () => { + expect(() => { + return relay.firstAndLast([], 10); + }).toThrow(); + }); + + test('Returns the same for first and last if 1 item', () => { + expect(relay.firstAndLast([foo], 10)).toStrictEqual([foo, foo]); + }); + + test('Works for 2 items', () => { + expect(relay.firstAndLast([foo, bar], 10)).toStrictEqual([foo, bar]); + }); + + test('Works for 3 items', () => { + const baz = new Foo(); + baz.name = 'Baz'; + baz.createdAt = new Date('1981-10-15'); + + // Since we always ask for 1 more than we need, `baz` gets chopped off here + expect(relay.firstAndLast([foo, bar, baz], 2)).toStrictEqual([foo, bar]); + }); + }); + + describe('getPageInfo', () => { + test('throws if data has no items', () => { + expect(() => { + return relay.getPageInfo([], sortCreatedAtASC, { first: 1 }); + }).toThrow(); + }); + + test('Returns the same for first and last if 1 item', () => { + const result = relay.getPageInfo([foo], sortCreatedAtASC, { first: 1 }); + const startDecoded = e.decode(result.startCursor); + const endDecoded = e.decode(result.endCursor); + + expect(result.hasNextPage).toEqual(false); + expect(result.hasPreviousPage).toEqual(false); + expect(startDecoded).toEqual(['1981-10-15T00:00:00.000Z', '1']); + expect(endDecoded).toEqual(['1981-10-15T00:00:00.000Z', '1']); + }); + + test('Works properly if youre on the last page', () => { + const result = relay.getPageInfo([foo, foo, foo, foo, bar, foo], sortCreatedAtASC, { + first: 5 + }); + const startDecoded = e.decode(result.startCursor); + const endDecoded = e.decode(result.endCursor); + + expect(result.hasNextPage).toEqual(true); + expect(result.hasPreviousPage).toEqual(false); + expect(startDecoded).toEqual(['1981-10-15T00:00:00.000Z', '1']); + expect(endDecoded).toEqual(['1989-11-20T00:00:00.000Z', '2']); + }); + + // TODO: Add tests for last/before + }); + + describe('effectiveOrder', () => { + test('works with no sorts and first', () => { + expect(relay.effectiveOrder(undefined, { first: 10 })).toEqual([sortIdASC]); + }); + + test('works with multiple sorts and first', () => { + expect(relay.effectiveOrder('foo_DESC', { first: 10 })).toEqual([sortFooDESC, sortIdASC]); + }); + + test('works with no sorts and last (reversed)', () => { + expect(relay.effectiveOrder(undefined, { last: 10 })).toEqual([sortIdDESC]); + }); + + test('works with multiple sorts and last (reversed)', () => { + expect(relay.effectiveOrder('foo_ASC', { last: 10 })).toEqual([sortFooDESC, sortIdDESC]); + }); + }); + + describe('getFilters', () => { + test('returns empty object if there is no cursor', () => { + expect(relay.getFilters(undefined, { first: 1 })).toEqual({}); + }); + + test('works for base ID case', () => { + const cursor = relay.encodeCursor(foo, 'id_ASC'); + expect(relay.getFilters(undefined, { first: 1, after: cursor })).toEqual({ + OR: [{ id_gt: '1' }] + }); + }); + + test('works with non-id sort', () => { + const sorts = 'name_DESC'; + const cursor = relay.encodeCursor(foo, sorts); + expect(relay.getFilters(sorts, { first: 1, after: cursor })).toEqual({ + OR: [{ name_lt: 'Foo' }, { id_gt: '1', name_eq: 'Foo' }] + }); + }); + + test.only('works several sorts', () => { + const sorts = ['createdAt_ASC', 'name_DESC', 'id_ASC']; + const cursor = relay.encodeCursor(foo, sorts); + expect(relay.getFilters(sorts, { first: 1, after: cursor })).toEqual({ + OR: [ + { createdAt_gt: '1981-10-15T00:00:00.000Z' }, + { createdAt_eq: '1981-10-15T00:00:00.000Z', name_lt: 'Foo' }, + { createdAt_eq: '1981-10-15T00:00:00.000Z', name_eq: 'Foo', id_gt: '1' } + ] + }); + }); + }); +}); diff --git a/src/core/RelayService.ts b/src/core/RelayService.ts new file mode 100644 index 00000000..a4dc86ca --- /dev/null +++ b/src/core/RelayService.ts @@ -0,0 +1,247 @@ +const assert = require('assert').strict; + +import { Service } from 'typedi'; + +import { BaseModel } from './BaseModel'; +import { EncodingService } from './encoding'; + +export type Cursor = string; + +export interface ConnectionEdge { + node?: E; + cursor?: Cursor; +} + +export interface ConnectionResult { + totalCount?: number; + edges?: ConnectionEdge[]; + pageInfo?: PageInfo; +} + +type PageInfo = { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: Cursor; + endCursor: Cursor; +}; + +export type RelayFirstAfter = { + first: number; // this is required here so that we can do pagination + after?: string; +}; + +export type RelayLastBefore = { + last: number; // this is required here so that we can do pagination + before?: string; +}; + +export type RelayPageOptions = RelayFirstAfter | RelayLastBefore; + +export type SortColumn = string; +export type SortDirection = 'ASC' | 'DESC'; +export type Sort = { + column: SortColumn; + direction: SortDirection; +}; + +type SortAndValue = [SortColumn, SortDirection, string | number]; +type SortAndValueArray = Array; + +function isFirstAfter(pageType: RelayFirstAfter | RelayLastBefore): pageType is RelayFirstAfter { + return (pageType as RelayLastBefore).last === undefined; +} + +function isSort(sort: Sortable): sort is Sort { + return (sort as Sort).column !== undefined; +} + +function isSortArray(sort: Sortable): sort is Sort[] { + const arr = sort as Sort[]; + return Array.isArray(arr) && arr.length > 0 && arr[0].column !== undefined; +} + +type Sortable = string | string[] | Sort | Sort[] | undefined; + +interface WhereExpression { + [key: string]: string | number | null; +} + +type WhereInput = { + AND?: WhereInput[]; + OR?: WhereInput[]; +} & WhereExpression; + +@Service() +export class RelayService { + encoding: EncodingService; + + constructor() { + // TODO: use DI + this.encoding = new EncodingService(); + } + + getPageInfo( + items: E[], + sortable: Sortable, + pageOptions: RelayPageOptions + ): PageInfo { + if (!items.length) { + throw new Error('Items is empty'); + } + let limit; + let cursor; + + if (isFirstAfter(pageOptions)) { + limit = pageOptions.first; + cursor = pageOptions.after; + } else { + limit = pageOptions.last; + cursor = pageOptions.before; + } + + const [firstItem, lastItem] = this.firstAndLast(items, limit); + const sort = this.normalizeSort(sortable); + + return { + hasNextPage: items.length > limit, + // Right now we assume there is a previous page if client specifies the cursor + // typically a client will not specify a cursor on the first page and would otherwise + hasPreviousPage: !!cursor, + startCursor: this.encodeCursor(firstItem, sort), + endCursor: this.encodeCursor(lastItem, sort) + }; + } + + // Given an array of items, return the first and last + // Note that this isn't as simple as returning the first and last as we've + // asked for limit+1 items (to know if there is a next page) + firstAndLast(items: E[], limit: number) { + assert(items.length, 'Items cannot be empty'); + assert(limit > 0, 'Limit must be greater than 0'); + + const onLastPage = items.length <= limit; + const lastItemIndex = onLastPage ? items.length - 1 : limit - 1; + const firstItem = items[0]; + const lastItem = items[lastItemIndex]; + + return [firstItem, lastItem]; + } + + encodeCursor(record: E, sortable: Sortable): Cursor { + assert(record, 'Record is not defined'); + assert(record.getValue, `Record must be a BaseModel: ${JSON.stringify(record, null, 2)}`); + + const sortArray = this.normalizeSort(sortable); + const payload: SortAndValueArray = sortArray.map(sort => record.getValue(sort.column)); + + return this.encoding.encode(payload); + } + + decodeCursor(cursor: Cursor): SortAndValueArray { + return this.encoding.decode(cursor); + } + + toSortArray(sort?: Sortable): Sort[] { + if (!sort) { + return []; + } else if (isSortArray(sort)) { + return sort; + } else if (isSort(sort)) { + return [sort]; + } + + // Takes sorts of the form ["name_DESC", "startAt_ASC"] and converts to relay service's internal + // representation [{ column: 'name', direction: 'DESC' }, { column: 'startAt', direction: 'ASC' }] + const stringArray = Array.isArray(sort) ? sort : [sort]; + + return stringArray.map((str: string) => { + const sorts = str.split('_'); + + return { column: sorts[0], direction: sorts[1] as SortDirection } as Sort; + }); + } + + normalizeSort(sortable?: Sortable): Sort[] { + const sort = this.toSortArray(sortable); + + if (!sort.length) { + return [{ column: 'id', direction: 'ASC' }]; + } + + const hasIdSort = sort.find(item => item.column === 'id'); + + // If we're not already sorting by ID, add this to sort to make cursor work + // When the user-specified sort isn't unique + if (!hasIdSort) { + sort.push({ column: 'id', direction: 'ASC' }); + } + return sort; + } + + flipDirection(direction: SortDirection): SortDirection { + return direction === 'ASC' ? 'DESC' : 'ASC'; + } + + effectiveOrder(sortable: Sortable, pageOptions: RelayPageOptions): Sort[] { + const sorts = this.normalizeSort(sortable); + if (isFirstAfter(pageOptions)) { + return sorts; + } + + return sorts.map(({ column, direction }) => { + return { column, direction: this.flipDirection(direction) }; + }); + } + + effectiveOrderStrings(sortable: Sortable, pageOptions: RelayPageOptions): string[] { + const sorts = this.effectiveOrder(sortable, pageOptions); + return this.toSortStrings(sorts); + } + + toSortStrings(sorts: Sort[]): string[] { + return sorts.map((sort: Sort) => { + return [sort.column, sort.direction].join('_'); + }); + } + + getFilters(sortable: Sortable, pageOptions: RelayPageOptions): WhereInput { + // Ex: [ { column: 'createdAt', direction: 'ASC' }, { column: 'name', direction: 'DESC' }, { column: 'id', direction: 'ASC' } ] + const cursor = isFirstAfter(pageOptions) ? pageOptions.after : pageOptions.before; + if (!cursor) { + return {}; + } + + const decodedCursor = this.decodeCursor(cursor); // Ex: ['1981-10-15T00:00:00.000Z', 'Foo', '1'] + const sorts = this.effectiveOrder(sortable, pageOptions); + const comparisonOperator = (sortDirection: string) => (sortDirection == 'ASC' ? 'gt' : 'lt'); + + /* + Given: + sorts = [['c', 'ASC'], ['b', 'DESC'], ['id', 'ASC']] + decodedCursor = ['1981-10-15T00:00:00.000Z', 'Foo', '1'] + + Output: + { + OR: [ + { createdAt_gt: '1981-10-15T00:00:00.000Z' }, + { createdAt_eq: '1981-10-15T00:00:00.000Z', name_lt: 'Foo' }, + { createdAt_eq: '1981-10-15T00:00:00.000Z', name_eq: 'Foo', id_gt: '1' } + ] + } + */ + return ({ + OR: sorts.map(({ column, direction }, i) => { + const allOthersEqual = sorts + .slice(0, i) + .map((other, j) => ({ [`${other.column}_eq`]: decodedCursor[j] })); + + return Object.assign( + { + [`${column}_${comparisonOperator(direction)}`]: decodedCursor[i] + }, + ...allOthersEqual + ); + }) + } as unknown) as WhereInput; + } +} diff --git a/src/core/encoding.ts b/src/core/encoding.ts new file mode 100644 index 00000000..cbef7131 --- /dev/null +++ b/src/core/encoding.ts @@ -0,0 +1,24 @@ +import { Service } from 'typedi'; +import { debug } from '../decorators'; + +@Service() +export class EncodingService { + JSON_MARKER = '__JSON__:'; + + encode64(str: string): string { + return Buffer.from(str, 'ascii').toString('base64'); + } + + encode(input: object): string { + return this.encode64(JSON.stringify(input)); + } + + decode64(str: string): string { + return Buffer.from(str, 'base64').toString('ascii'); + } + + @debug('encoding:decode') + decode(str: string): T { + return JSON.parse(this.decode64(str)); + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 6861e0d9..a2029cfc 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,7 +1,8 @@ export * from './server'; export * from './BaseModel'; +export * from './BaseService'; export * from './config'; export * from './Context'; export * from './logger'; +export * from './RelayService'; export * from './types'; -export * from './BaseService'; diff --git a/src/core/tests/entity/MyBase.model.ts b/src/core/tests/entity/MyBase.model.ts new file mode 100644 index 00000000..537ee035 --- /dev/null +++ b/src/core/tests/entity/MyBase.model.ts @@ -0,0 +1,24 @@ +import { Service } from 'typedi'; +import { Column, Entity, Repository } from 'typeorm'; +import { InjectRepository } from 'typeorm-typedi-extensions'; + +import { BaseModel, BaseService } from '../../'; + +@Entity() +export class MyBase extends BaseModel { + @Column({ nullable: true }) + registered?: boolean; + + @Column() + firstName!: string; + + @Column() + lastName!: string; +} + +@Service('MyBaseService') +export class MyBaseService extends BaseService { + constructor(@InjectRepository(MyBase) protected readonly repository: Repository) { + super(MyBase, repository); + } +} diff --git a/src/decorators/Fields.ts b/src/decorators/Fields.ts index 3b28f43a..1b4db146 100644 --- a/src/decorators/Fields.ts +++ b/src/decorators/Fields.ts @@ -19,6 +19,12 @@ export function Fields(): ParameterDecorator { }); } +export function RawFields(): ParameterDecorator { + return createParamDecorator(({ info }) => { + return graphqlFields(info); + }); +} + export function NestedFields(): ParameterDecorator { return createParamDecorator(({ info }) => { // This object will be of the form: diff --git a/src/decorators/debug.ts b/src/decorators/debug.ts new file mode 100644 index 00000000..b228d00a --- /dev/null +++ b/src/decorators/debug.ts @@ -0,0 +1,34 @@ +import * as Debug from 'debug'; +import { performance } from 'perf_hooks'; +import * as util from 'util'; + +type MethodDecorator = (target: any, propertyKey: string, descriptor: PropertyDescriptor) => any; + +export function debug(key: string): MethodDecorator { + const logger = Debug(key); + + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + + if (util.types.isAsyncFunction(originalMethod)) { + descriptor.value = async function(...args: unknown[]): Promise { + logger(`Entering ${propertyKey} with args: ${JSON.stringify(args)}`); + const start = performance.now(); + const result = await originalMethod.apply(this, args); + const end = performance.now(); + logger(`Exiting ${propertyKey} after ${(end - start).toFixed(2)} milliseconds.`); + return result; + }; + } else { + descriptor.value = function(...args: unknown[]) { + logger(`Entering ${propertyKey} with args: ${JSON.stringify(args)}`); + const start = performance.now(); + const result = originalMethod.apply(this, args); + const end = performance.now(); + logger(`Exiting ${propertyKey} after ${(end - start).toFixed(2)} milliseconds.`); + return result; + }; + } + return descriptor; + }; +} diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 67f7ca16..3e02f70a 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -3,6 +3,7 @@ export * from './CustomField'; export * from './DateField'; export * from './DateOnlyField'; export * from './DateTimeField'; +export * from './debug'; export * from './EmailField'; export * from './EnumField'; export * from './Fields'; diff --git a/src/test/functional/__snapshots__/schema.test.ts.snap b/src/test/functional/__snapshots__/schema.test.ts.snap index dbf51365..cbc7d9d7 100644 --- a/src/test/functional/__snapshots__/schema.test.ts.snap +++ b/src/test/functional/__snapshots__/schema.test.ts.snap @@ -158,7 +158,8 @@ type Dish implements BaseGraphQLObject { } type DishConnection { - nodes: [Dish!]! + totalCount: Int! + edges: [DishEdge!]! pageInfo: PageInfo! } @@ -168,6 +169,11 @@ input DishCreateInput { kitchenSinkId: ID! } +type DishEdge { + node: Dish! + cursor: String! +} + enum DishOrderByInput { createdAt_ASC createdAt_DESC @@ -512,16 +518,15 @@ type Mutation { } type PageInfo { - limit: Float! - offset: Float! - totalCount: Float! hasNextPage: Boolean! hasPreviousPage: Boolean! + startCursor: String + endCursor: String } type Query { dishes(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): [Dish!]! - dishConnection(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): DishConnection! + dishConnection(first: Int, after: String, last: Int, before: String, where: DishWhereInput, orderBy: DishOrderByInput): DishConnection! dish(where: DishWhereUniqueInput!): Dish! kitchenSinks(offset: Int, limit: Int = 50, where: KitchenSinkWhereInput, orderBy: KitchenSinkOrderByInput): [KitchenSink!]! kitchenSink(where: KitchenSinkWhereUniqueInput!): KitchenSink! diff --git a/src/test/functional/__snapshots__/server.test.ts.snap b/src/test/functional/__snapshots__/server.test.ts.snap index 216486f8..cedc7efa 100644 --- a/src/test/functional/__snapshots__/server.test.ts.snap +++ b/src/test/functional/__snapshots__/server.test.ts.snap @@ -3297,14 +3297,3 @@ Array [ }, ] `; - -exports[`server queries for dishes with pagination 1`] = ` -Array [ - Object { - "kitchenSink": Object { - "emailField": "hi@warthog.com", - }, - "name": "Dish 0", - }, -] -`; diff --git a/src/test/functional/cli.test.ts b/src/test/functional/cli.test.ts index 808e4b78..79425230 100644 --- a/src/test/functional/cli.test.ts +++ b/src/test/functional/cli.test.ts @@ -12,7 +12,7 @@ import { setTestServerEnvironmentVariables } from '../server-vars'; const root = filesystem.path(__dirname, '../../../'); -const GENERATED_FOLDER = 'tmp/generated'; +const GENERATED_FOLDER = path.join(__dirname, '../../../tmp/cli-tests'); describe('cli functional tests', () => { const spy = spyOnStd(); // Gives us access to whatever is written to stdout as part of the CLI command @@ -24,13 +24,13 @@ describe('cli functional tests', () => { }); beforeEach(() => { - jest.setTimeout(20000); setTestServerEnvironmentVariables(); spy.clear(); }); afterAll(() => { filesystem.remove(GENERATED_FOLDER); // cleanup test artifacts + filesystem.remove(path.join(__dirname, 'tmp')); openMock.mockReset(); }); @@ -230,9 +230,6 @@ describe('cli functional tests', () => { }); test('generates and runs migrations', async done => { - // jest.setTimeout(8000); - - // expect.assertions(6); const migrationDBName = 'warthog-test-generate-migrations'; // Set environment variables for a test server that writes to a separate test DB and does NOT autogenerate files @@ -321,6 +318,7 @@ describe('cli functional tests', () => { expect(packageJson.devDependencies['ts-node']).toMatch(caretDep); expect(packageJson.devDependencies['typescript']).toMatch(caretDep); + filesystem.remove(tmpFolder); done(); }); }); diff --git a/src/test/functional/server.test.ts b/src/test/functional/server.test.ts index 93b40e0b..eeaccaca 100644 --- a/src/test/functional/server.test.ts +++ b/src/test/functional/server.test.ts @@ -9,7 +9,6 @@ import { Server } from '../../core/server'; import { Binding, KitchenSinkWhereInput } from '../generated/binding'; import { KitchenSink, StringEnum, Dish } from '../modules'; -import { setTestServerEnvironmentVariables } from '../server-vars'; import { getTestServer } from '../test-server'; import { KITCHEN_SINKS } from './fixtures'; @@ -18,6 +17,7 @@ import { callAPIError, callAPISuccess } from '../utils'; import express = require('express'); import * as request from 'supertest'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; +import { EncodingService } from '../../core/encoding'; let runKey: string; let server: Server; @@ -30,14 +30,9 @@ let onAfterCalled = false; let kitchenSink: KitchenSink; describe('server', () => { - beforeEach(() => { - jest.setTimeout(20000); - }); - // Make sure to clean up server beforeAll(async done => { - jest.setTimeout(20000); - setTestServerEnvironmentVariables(); + // setTestServerEnvironmentVariables(); runKey = String(new Date().getTime()); // used to ensure test runs create unique data @@ -131,32 +126,44 @@ describe('server', () => { }); test('queries for dishes with pagination', async () => { - expect.assertions(6); - const { nodes, pageInfo } = await binding.query.dishConnection( - { offset: 0, orderBy: 'createdAt_ASC', limit: 1 }, - `{ - nodes { - name - kitchenSink { - emailField - } - } - pageInfo { - limit - offset + const { totalCount, edges, pageInfo } = await callAPISuccess( + binding.query.dishConnection( + { orderBy: 'name_ASC', first: 1 }, + `{ totalCount - hasNextPage - hasPreviousPage - } - }` + edges { + node { + name + kitchenSink { + emailField + } + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + }` + ) ); - expect(nodes).toMatchSnapshot(); - expect(pageInfo.offset).toEqual(0); - expect(pageInfo.limit).toEqual(1); + const encodingService = new EncodingService(); + const decodedCursor: [string, string] = encodingService.decode(edges[0].cursor); + + expect(decodedCursor[0]).toMatch(/Dish [0-9]+/); + expect(decodedCursor[1]).toMatch(/[A-Za-z0-9_-]{7,14}/); + expect(edges[0].node.name).toBeTruthy(); + expect(edges[0].node.kitchenSink.emailField).toBeTruthy(); expect(pageInfo.hasNextPage).toEqual(true); expect(pageInfo.hasPreviousPage).toEqual(false); - expect(pageInfo.totalCount).toEqual(20); + expect(totalCount).toEqual(20); + }); + + test.skip('Does not perform expensive totalCount operation if not needed', async () => { + // }); test('throws errors when given bad input on a single create', async done => { @@ -708,7 +715,6 @@ describe('server', () => { // TypeORM comment support is currently broken // See: https://github.com/typeorm/typeorm/issues/5906 test.skip('description maps to comment DB metadata', async done => { - console.log('stringFieldColumn', stringFieldColumn); expect(stringFieldColumn.comment).toEqual('This is a string field'); done(); }); @@ -810,5 +816,3 @@ function noSupertestRequestErrors(result: request.Response) { expect(result.clientError).toBe(false); expect(result.serverError).toBe(false); } - -/* eslint-enable @typescript-eslint/camelcase */ diff --git a/src/test/generated/binding.ts b/src/test/generated/binding.ts index 145fe9a7..5ed570b2 100644 --- a/src/test/generated/binding.ts +++ b/src/test/generated/binding.ts @@ -7,7 +7,7 @@ import * as schema from './schema.graphql' export interface Query { dishes: >(args: { offset?: Int | null, limit?: Int | null, where?: DishWhereInput | null, orderBy?: DishOrderByInput | null }, info?: GraphQLResolveInfo | string, options?: Options) => Promise , - dishConnection: (args: { offset?: Int | null, limit?: Int | null, where?: DishWhereInput | null, orderBy?: DishOrderByInput | null }, info?: GraphQLResolveInfo | string, options?: Options) => Promise , + dishConnection: (args: { first?: Int | null, after?: String | null, last?: Int | null, before?: String | null, where?: DishWhereInput | null, orderBy?: DishOrderByInput | null }, info?: GraphQLResolveInfo | string, options?: Options) => Promise , dish: (args: { where: DishWhereUniqueInput }, info?: GraphQLResolveInfo | string, options?: Options) => Promise , kitchenSinks: >(args: { offset?: Int | null, limit?: Int | null, where?: KitchenSinkWhereInput | null, orderBy?: KitchenSinkOrderByInput | null }, info?: GraphQLResolveInfo | string, options?: Options) => Promise , kitchenSink: (args: { where: KitchenSinkWhereUniqueInput }, info?: GraphQLResolveInfo | string, options?: Options) => Promise @@ -487,10 +487,16 @@ export interface Dish extends BaseGraphQLObject { } export interface DishConnection { - nodes: Array + totalCount: Int + edges: Array pageInfo: PageInfo } +export interface DishEdge { + node: Dish + cursor: String +} + export interface KitchenSink extends BaseGraphQLObject { id: ID_Output createdAt: DateTime @@ -530,11 +536,10 @@ export interface KitchenSink extends BaseGraphQLObject { } export interface PageInfo { - limit: Float - offset: Float - totalCount: Float hasNextPage: Boolean hasPreviousPage: Boolean + startCursor?: String | null + endCursor?: String | null } export interface StandardDeleteResponse { diff --git a/src/test/generated/schema.graphql b/src/test/generated/schema.graphql index aea00e66..3f3df7ba 100644 --- a/src/test/generated/schema.graphql +++ b/src/test/generated/schema.graphql @@ -155,7 +155,8 @@ type Dish implements BaseGraphQLObject { } type DishConnection { - nodes: [Dish!]! + totalCount: Int! + edges: [DishEdge!]! pageInfo: PageInfo! } @@ -165,6 +166,11 @@ input DishCreateInput { kitchenSinkId: ID! } +type DishEdge { + node: Dish! + cursor: String! +} + enum DishOrderByInput { createdAt_ASC createdAt_DESC @@ -509,16 +515,15 @@ type Mutation { } type PageInfo { - limit: Float! - offset: Float! - totalCount: Float! hasNextPage: Boolean! hasPreviousPage: Boolean! + startCursor: String + endCursor: String } type Query { dishes(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): [Dish!]! - dishConnection(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): DishConnection! + dishConnection(first: Int, after: String, last: Int, before: String, where: DishWhereInput, orderBy: DishOrderByInput): DishConnection! dish(where: DishWhereUniqueInput!): Dish! kitchenSinks(offset: Int, limit: Int = 50, where: KitchenSinkWhereInput, orderBy: KitchenSinkOrderByInput): [KitchenSink!]! kitchenSink(where: KitchenSinkWhereUniqueInput!): KitchenSink! diff --git a/src/test/modules/dish/dish.resolver.ts b/src/test/modules/dish/dish.resolver.ts index 9e5bfeae..d457e722 100644 --- a/src/test/modules/dish/dish.resolver.ts +++ b/src/test/modules/dish/dish.resolver.ts @@ -1,9 +1,11 @@ import { Arg, Args, + ArgsType, Authorized, Ctx, FieldResolver, + Int, Mutation, Query, Resolver, @@ -12,18 +14,21 @@ import { Field } from 'type-graphql'; import { Inject } from 'typedi'; +import { Min } from 'class-validator'; import { BaseContext, - ConnectionResult, Fields, + RawFields, PageInfo, StandardDeleteResponse, UserId } from '../../../'; + import { DishCreateInput, DishCreateManyArgs, + DishOrderByEnum, DishUpdateArgs, DishWhereArgs, DishWhereInput, @@ -34,17 +39,54 @@ import { KitchenSink } from '../kitchen-sink/kitchen-sink.model'; import { Dish } from './dish.model'; import { DishService } from './dish.service'; -import { NestedFields } from '../../../decorators'; @ObjectType() -export class DishConnection implements ConnectionResult { - @Field(() => [Dish], { nullable: false }) - nodes!: Dish[]; +export class DishEdge { + @Field(() => Dish, { nullable: false }) + node!: Dish; + + @Field(() => String, { nullable: false }) + cursor!: string; +} + +@ObjectType() +export class DishConnection { + @Field(() => Int, { nullable: false }) + totalCount!: number; + + @Field(() => [DishEdge], { nullable: false }) + edges!: DishEdge[]; @Field(() => PageInfo, { nullable: false }) pageInfo!: PageInfo; } +@ArgsType() +export class ConnectionPageInputOptions { + @Field(() => Int, { nullable: true }) + @Min(0) + first?: number; + + @Field(() => String, { nullable: true }) + after?: string; // V3: TODO: should we make a RelayCursor scalar? + + @Field(() => Int, { nullable: true }) + @Min(0) + last?: number; + + @Field(() => String, { nullable: true }) + before?: string; +} + +@ArgsType() +export class DishConnectionWhereArgs extends ConnectionPageInputOptions { + @Field(() => DishWhereInput, { nullable: true }) + where?: DishWhereInput; + + @Field(() => DishOrderByEnum, { nullable: true }) + orderBy?: DishOrderByEnum; +} + @Resolver(Dish) export class DishResolver { constructor(@Inject('DishService') public readonly service: DishService) {} @@ -67,9 +109,10 @@ export class DishResolver { @Authorized('dish:read') @Query(() => DishConnection) async dishConnection( - @Args() { where, orderBy, limit, offset }: DishWhereArgs + @Args() { where, orderBy, ...pageOptions }: DishConnectionWhereArgs, + @RawFields() fields: object ): Promise { - return this.service.findConnection(where, orderBy, limit, offset); + return this.service.findConnection(where, orderBy, pageOptions, fields) as any; } @Authorized('dish:read') diff --git a/src/test/setupFiles.ts b/src/test/setupFiles.ts new file mode 100644 index 00000000..0bc264bd --- /dev/null +++ b/src/test/setupFiles.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; + +import { Container } from 'typedi'; +import { useContainer as typeOrmUseContainer } from 'typeorm'; + +import { Config } from '../'; +import { setTestServerEnvironmentVariables } from '../test/server-vars'; + +if (!(global as any).__warthog_config__) { + // Tell TypeORM to use our typedi instance + typeOrmUseContainer(Container); + + setTestServerEnvironmentVariables(); + + const config = new Config({ container: Container }); + + (global as any).__warthog_config__ = config.get(); +} diff --git a/src/test/setupFilesAfterEnv.ts b/src/test/setupFilesAfterEnv.ts index d2c9bc6e..cb96a9f5 100644 --- a/src/test/setupFilesAfterEnv.ts +++ b/src/test/setupFilesAfterEnv.ts @@ -1 +1 @@ -import 'reflect-metadata'; +jest.setTimeout(20000); diff --git a/src/tgql/PageInfo.ts b/src/tgql/PageInfo.ts index 260f4e03..e318a875 100644 --- a/src/tgql/PageInfo.ts +++ b/src/tgql/PageInfo.ts @@ -1,30 +1,16 @@ import { Field, ObjectType } from 'type-graphql'; -export interface ConnectionEdge { - node: E; - cursor: string; -} - -export interface ConnectionResult { - nodes: E[]; // list of records returned from the database - edges?: ConnectionEdge[]; - pageInfo: PageInfo; -} - @ObjectType() export class PageInfo { - @Field(() => Number, { nullable: false }) - limit!: number; - - @Field(() => Number, { nullable: false }) - offset!: number; - - @Field(() => Number, { nullable: false }) - totalCount!: number; - @Field({ nullable: false }) hasNextPage!: boolean; @Field({ nullable: false }) hasPreviousPage!: boolean; + + @Field({ nullable: true }) + startCursor?: string; + + @Field({ nullable: true }) + endCursor?: string; } diff --git a/src/utils/decoratorComposer.ts b/src/utils/decoratorComposer.ts index 6fcf82f9..5b2bdd81 100644 --- a/src/utils/decoratorComposer.ts +++ b/src/utils/decoratorComposer.ts @@ -14,7 +14,8 @@ export function composeMethodDecorators(...factories: MethodDecoratorFactory[]) export type ClassDecoratorFactory = (target: ClassType) => any; -export function composeClassDecorators(...factories: ClassDecoratorFactory[]) { +// any[] -> ClassDecoratorFactory[] +export function composeClassDecorators(...factories: any[]) { return (target: ClassType): any => { // Do NOT return anything here or it will take over the class it's decorating // See: https://www.typescriptlang.org/docs/handbook/decorators.html diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 0f3a3fed..f8d79d2b 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -3,5 +3,5 @@ // so we give eslint it's own config file "extends": "./tsconfig.json", "include": ["src/**/*", "examples/**/*"], - "exclude": ["node_modules/**/*", "**/generated/*"] + "exclude": ["tmp", "node_modules/**/*", "**/generated/*"] } diff --git a/tsconfig.json b/tsconfig.json index 3e69286b..a2c1f90d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ }, "exclude": [ "node_modules", + "tmp", "**/node_modules/*", // "src/**/*.test.ts", "**/generated/*" diff --git a/tsconfig.test.json b/tsconfig.test.json index f853de32..0846ac28 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,6 +4,6 @@ "compilerOptions": { "strict": false }, - "exclude": ["node_modules", "**/node_modules/*", "examples"], - "include": ["src/**/*", "test/**/*", "typings"] + "include": ["src/**/*", "test/**/*", "typings"], + "exclude": ["node_modules", "tmp", "**/node_modules/*", "examples"] }