From aaf6a47fe7d002905e29c90abdda22f8ac044f74 Mon Sep 17 00:00:00 2001 From: Ian Koenigsknecht Date: Mon, 6 Jan 2020 15:28:02 -0500 Subject: [PATCH 1/4] Add findAndCount to BaseService --- src/core/BaseService.ts | 34 ++++++++++++++++++++++++++++++---- src/core/types.ts | 5 +++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/core/BaseService.ts b/src/core/BaseService.ts index 31f3fc9e..3c463cb5 100644 --- a/src/core/BaseService.ts +++ b/src/core/BaseService.ts @@ -1,13 +1,13 @@ import { validate } from 'class-validator'; import { ArgumentValidationError } from 'type-graphql'; -import { DeepPartial, EntityManager, getRepository, Repository } from 'typeorm'; +import { DeepPartial, EntityManager, getRepository, Repository, SelectQueryBuilder } from 'typeorm'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; import { StandardDeleteResponse } from '../tgql'; import { addQueryBuilderWhereItem } from '../torm'; import { BaseModel } from '..'; -import { StringMap, WhereInput } from './types'; +import { StringMap, WhereInput, FindAndCountResult } from './types'; interface BaseOptions { manager?: EntityManager; // Allows consumers to pass in a TransactionManager @@ -58,7 +58,33 @@ export class BaseService { offset?: number, fields?: string[] ): Promise { - let qb = this.manager.createQueryBuilder(this.entityClass, this.klass); + const qb = this.buildFindQuery(where, orderBy, limit, offset, fields); + return qb.getMany(); + } + + async findAndCount( + where?: any, + orderBy?: string, + limit?: number, + offset?: number, + fields?: string[] + ): Promise> { + const qb = this.buildFindQuery(where, orderBy, limit, offset, fields); + const [records, total] = await qb.getManyAndCount(); + return { + records, + total + }; + } + + private buildFindQuery( + where?: any, + orderBy?: string, + limit?: number, + offset?: number, + fields?: string[] + ): SelectQueryBuilder { + let qb = this.repository.createQueryBuilder(this.klass); if (limit) { qb = qb.take(limit); @@ -124,7 +150,7 @@ export class BaseService { }); } - return qb.getMany(); + return qb; } async findOne>(where: W): Promise { diff --git a/src/core/types.ts b/src/core/types.ts index e47d2c80..ffd18cae 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -20,6 +20,11 @@ export interface WhereInput { id_in?: IDType[]; } +export interface FindAndCountResult { + records: E[]; // list of records returned from the database + total: number; // total number of records found +} + export interface DeleteReponse { id: IDType; } From 749134ea36dabe8adfd86bdc091a070af6a80a29 Mon Sep 17 00:00:00 2001 From: Ian Koenigsknecht Date: Tue, 7 Jan 2020 10:26:54 -0500 Subject: [PATCH 2/4] Add NestedField decorator and add test for pagination --- src/decorators/Fields.ts | 26 ++++++++++ .../__snapshots__/server.test.ts.snap | 11 ++++ src/test/functional/server.test.ts | 25 ++++++++++ src/test/generated/binding.ts | 12 +++++ src/test/generated/schema.graphql | 12 +++++ src/test/modules/dish/dish.resolver.ts | 50 ++++++++++++++++++- 6 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/decorators/Fields.ts b/src/decorators/Fields.ts index 10a3c660..1ae0e829 100644 --- a/src/decorators/Fields.ts +++ b/src/decorators/Fields.ts @@ -18,3 +18,29 @@ export function Fields(): ParameterDecorator { return scalars; }); } + +export function NestedFields(): ParameterDecorator { + return createParamDecorator(({ info }) => { + // This object will be of the form: + // rawFields { + // baseField: {}, + // association: { subField: "foo"} + // } + // We need to pull out items with subFields + const rawFields = graphqlFields(info); + const output: any = { scalars: [] }; + + for (const fieldKey in rawFields) { + if (Object.keys(rawFields[fieldKey]).length === 0) { + output.scalars.push(fieldKey); + } else { + const subFields = rawFields[fieldKey]; + output[fieldKey] = Object.keys(subFields).filter(subKey => { + return Object.keys(subFields[subKey]).length === 0; + }); + } + } + + return output; + }); +} diff --git a/src/test/functional/__snapshots__/server.test.ts.snap b/src/test/functional/__snapshots__/server.test.ts.snap index f0bb96dd..798f9c5a 100644 --- a/src/test/functional/__snapshots__/server.test.ts.snap +++ b/src/test/functional/__snapshots__/server.test.ts.snap @@ -1372,3 +1372,14 @@ 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/server.test.ts b/src/test/functional/server.test.ts index be6f6a4d..241a8d52 100644 --- a/src/test/functional/server.test.ts +++ b/src/test/functional/server.test.ts @@ -123,6 +123,31 @@ describe('server', () => { expect(firstResult.dishes.length).toEqual(20); }); + test('queries for dishes with pagination', async () => { + expect.assertions(4); + const { dishes, pageInfo } = await binding.query.dishesPaginated( + { offset: 0, orderBy: 'createdAt_ASC', limit: 1 }, + `{ + dishes { + name + kitchenSink { + emailField + } + } + pageInfo { + limit + offset + total + } + }` + ); + + expect(dishes).toMatchSnapshot(); + expect(pageInfo.offset).toEqual(0); + expect(pageInfo.limit).toEqual(1); + expect(pageInfo.total).toEqual(20); + }); + test('throws errors when given bad input on a single create', async done => { expect.assertions(1); diff --git a/src/test/generated/binding.ts b/src/test/generated/binding.ts index c0af976b..9613aa6c 100644 --- a/src/test/generated/binding.ts +++ b/src/test/generated/binding.ts @@ -7,6 +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 , + dishesPaginated: (args: { offset?: Int | null, limit?: Int | 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 @@ -372,6 +373,11 @@ export interface Dish extends BaseGraphQLObject { kitchenSinkId: String } +export interface DishesPaginated { + dishes: Array + pageInfo: PageInfo +} + export interface KitchenSink extends BaseGraphQLObject { id: ID_Output createdAt: DateTime @@ -403,6 +409,12 @@ export interface KitchenSink extends BaseGraphQLObject { apiOnlyField?: String | null } +export interface PageInfo { + limit: Float + offset: Float + total: Float +} + export interface StandardDeleteResponse { id: ID_Output } diff --git a/src/test/generated/schema.graphql b/src/test/generated/schema.graphql index d2d9d8f9..a286b5d5 100644 --- a/src/test/generated/schema.graphql +++ b/src/test/generated/schema.graphql @@ -83,6 +83,11 @@ input DishCreateInput { kitchenSinkId: ID! } +type DishesPaginated { + dishes: [Dish!]! + pageInfo: PageInfo! +} + enum DishOrderByInput { createdAt_ASC createdAt_DESC @@ -374,8 +379,15 @@ type Mutation { deleteKitchenSink(where: KitchenSinkWhereUniqueInput!): StandardDeleteResponse! } +type PageInfo { + limit: Float! + offset: Float! + total: Float! +} + type Query { dishes(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): [Dish!]! + dishesPaginated(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): DishesPaginated! 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 b94eabd4..ebb19181 100644 --- a/src/test/modules/dish/dish.resolver.ts +++ b/src/test/modules/dish/dish.resolver.ts @@ -7,7 +7,9 @@ import { Mutation, Query, Resolver, - Root + Root, + ObjectType, + Field } from 'type-graphql'; import { Inject } from 'typedi'; @@ -25,6 +27,28 @@ 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 PageInfo { + @Field(() => Number, { nullable: false }) + limit!: number; + + @Field(() => Number, { nullable: false }) + offset!: number; + + @Field(() => Number, { nullable: false }) + total!: number; +} + +@ObjectType() +export class DishesPaginated { + @Field(() => [Dish], { nullable: false }) + dishes!: Dish[]; + + @Field(() => PageInfo, { nullable: false }) + pageInfo!: PageInfo; +} @Resolver(Dish) export class DishResolver { @@ -45,6 +69,30 @@ export class DishResolver { return this.service.find(where, orderBy, limit, offset, fields); } + @Authorized('dish:read') + @Query(() => DishesPaginated) + async dishesPaginated( + @Args() { where, orderBy, limit, offset }: DishWhereArgs, + @NestedFields() fields: any + ): Promise { + const { records: dishes, total } = await this.service.findAndCount( + where, + orderBy, + limit, + offset, + fields.dishes || [] + ); + + return { + dishes, + pageInfo: { + limit: limit == null ? total : limit, + offset: offset == null ? 0 : offset, + total + } + }; + } + @Authorized('dish:read') @Query(() => Dish) async dish(@Arg('where') where: DishWhereUniqueInput): Promise { From fad23f8c38a605655fb8bb7e56c1fb66188e6335 Mon Sep 17 00:00:00 2001 From: Ian Koenigsknecht Date: Mon, 6 Apr 2020 08:52:52 -0400 Subject: [PATCH 3/4] Fix query builder generation to be in line with master --- src/core/BaseService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/BaseService.ts b/src/core/BaseService.ts index 3c463cb5..78a6bf84 100644 --- a/src/core/BaseService.ts +++ b/src/core/BaseService.ts @@ -84,7 +84,7 @@ export class BaseService { offset?: number, fields?: string[] ): SelectQueryBuilder { - let qb = this.repository.createQueryBuilder(this.klass); + let qb = this.manager.createQueryBuilder(this.entityClass, this.klass); if (limit) { qb = qb.take(limit); From 0a0ac5782bb172e26ba83208487bf6079c8c70a3 Mon Sep 17 00:00:00 2001 From: Dan Caddigan Date: Sun, 5 Apr 2020 21:37:07 -0400 Subject: [PATCH 4/4] feat(find-connection): adds findConnection to BaseService --- src/core/BaseService.ts | 31 +++++++---- src/core/types.ts | 5 -- .../__snapshots__/schema.test.ts.snap | 14 +++++ src/test/functional/server.test.ts | 18 ++++--- src/test/generated/binding.ts | 10 ++-- src/test/generated/schema.graphql | 16 +++--- src/test/modules/dish/dish.resolver.ts | 51 ++++++------------- src/tgql/PageInfo.ts | 30 +++++++++++ src/tgql/index.ts | 1 + 9 files changed, 109 insertions(+), 67 deletions(-) create mode 100644 src/tgql/PageInfo.ts diff --git a/src/core/BaseService.ts b/src/core/BaseService.ts index 78a6bf84..58085695 100644 --- a/src/core/BaseService.ts +++ b/src/core/BaseService.ts @@ -6,8 +6,8 @@ import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; import { StandardDeleteResponse } from '../tgql'; import { addQueryBuilderWhereItem } from '../torm'; -import { BaseModel } from '..'; -import { StringMap, WhereInput, FindAndCountResult } from './types'; +import { BaseModel, ConnectionResult } from '..'; +import { StringMap, WhereInput } from './types'; interface BaseOptions { manager?: EntityManager; // Allows consumers to pass in a TransactionManager @@ -51,6 +51,16 @@ 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, orderBy?: string, @@ -58,22 +68,25 @@ export class BaseService { offset?: number, fields?: string[] ): Promise { - const qb = this.buildFindQuery(where, orderBy, limit, offset, fields); - return qb.getMany(); + return this.buildFindQuery(where, orderBy, limit, offset, fields).getMany(); } - async findAndCount( + async findConnection( where?: any, orderBy?: string, limit?: number, offset?: number, fields?: string[] - ): Promise> { + ): Promise> { const qb = this.buildFindQuery(where, orderBy, limit, offset, fields); - const [records, total] = await qb.getManyAndCount(); + const [nodes, totalCount] = await qb.getManyAndCount(); + // TODO: FEATURE - make the default limit configurable + limit = limit ?? 50; + offset = offset ?? 0; + return { - records, - total + nodes, + pageInfo: this.getPageInfo(limit, offset, totalCount) }; } diff --git a/src/core/types.ts b/src/core/types.ts index ffd18cae..e47d2c80 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -20,11 +20,6 @@ export interface WhereInput { id_in?: IDType[]; } -export interface FindAndCountResult { - records: E[]; // list of records returned from the database - total: number; // total number of records found -} - export interface DeleteReponse { id: IDType; } diff --git a/src/test/functional/__snapshots__/schema.test.ts.snap b/src/test/functional/__snapshots__/schema.test.ts.snap index 13907192..55b9d2b7 100644 --- a/src/test/functional/__snapshots__/schema.test.ts.snap +++ b/src/test/functional/__snapshots__/schema.test.ts.snap @@ -81,6 +81,11 @@ type Dish implements BaseGraphQLObject { kitchenSinkId: String! } +type DishConnection { + nodes: [Dish!]! + pageInfo: PageInfo! +} + input DishCreateInput { name: String! kitchenSinkId: ID! @@ -377,8 +382,17 @@ type Mutation { deleteKitchenSink(where: KitchenSinkWhereUniqueInput!): StandardDeleteResponse! } +type PageInfo { + limit: Float! + offset: Float! + totalCount: Float! + hasNextPage: Boolean! + hasPreviousPage: Boolean! +} + type Query { dishes(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): [Dish!]! + dishConnection(offset: Int, limit: Int = 50, 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/server.test.ts b/src/test/functional/server.test.ts index 241a8d52..16ed65b6 100644 --- a/src/test/functional/server.test.ts +++ b/src/test/functional/server.test.ts @@ -124,11 +124,11 @@ describe('server', () => { }); test('queries for dishes with pagination', async () => { - expect.assertions(4); - const { dishes, pageInfo } = await binding.query.dishesPaginated( + expect.assertions(6); + const { nodes, pageInfo } = await binding.query.dishConnection( { offset: 0, orderBy: 'createdAt_ASC', limit: 1 }, `{ - dishes { + nodes { name kitchenSink { emailField @@ -137,15 +137,21 @@ describe('server', () => { pageInfo { limit offset - total + totalCount + hasNextPage + hasPreviousPage } }` ); - expect(dishes).toMatchSnapshot(); + console.log('test', nodes, pageInfo); + + expect(nodes).toMatchSnapshot(); expect(pageInfo.offset).toEqual(0); expect(pageInfo.limit).toEqual(1); - expect(pageInfo.total).toEqual(20); + expect(pageInfo.hasNextPage).toEqual(true); + expect(pageInfo.hasPreviousPage).toEqual(false); + expect(pageInfo.totalCount).toEqual(20); }); test('throws errors when given bad input on a single create', async done => { diff --git a/src/test/generated/binding.ts b/src/test/generated/binding.ts index 9613aa6c..6c5f07c3 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 , - dishesPaginated: (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 , 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 @@ -373,8 +373,8 @@ export interface Dish extends BaseGraphQLObject { kitchenSinkId: String } -export interface DishesPaginated { - dishes: Array +export interface DishConnection { + nodes: Array pageInfo: PageInfo } @@ -412,7 +412,9 @@ export interface KitchenSink extends BaseGraphQLObject { export interface PageInfo { limit: Float offset: Float - total: Float + totalCount: Float + hasNextPage: Boolean + hasPreviousPage: Boolean } export interface StandardDeleteResponse { diff --git a/src/test/generated/schema.graphql b/src/test/generated/schema.graphql index a286b5d5..35ace075 100644 --- a/src/test/generated/schema.graphql +++ b/src/test/generated/schema.graphql @@ -78,16 +78,16 @@ type Dish implements BaseGraphQLObject { kitchenSinkId: String! } +type DishConnection { + nodes: [Dish!]! + pageInfo: PageInfo! +} + input DishCreateInput { name: String! kitchenSinkId: ID! } -type DishesPaginated { - dishes: [Dish!]! - pageInfo: PageInfo! -} - enum DishOrderByInput { createdAt_ASC createdAt_DESC @@ -382,12 +382,14 @@ type Mutation { type PageInfo { limit: Float! offset: Float! - total: Float! + totalCount: Float! + hasNextPage: Boolean! + hasPreviousPage: Boolean! } type Query { dishes(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): [Dish!]! - dishesPaginated(offset: Int, limit: Int = 50, where: DishWhereInput, orderBy: DishOrderByInput): DishesPaginated! + dishConnection(offset: Int, limit: Int = 50, 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 ebb19181..9e5bfeae 100644 --- a/src/test/modules/dish/dish.resolver.ts +++ b/src/test/modules/dish/dish.resolver.ts @@ -13,7 +13,14 @@ import { } from 'type-graphql'; import { Inject } from 'typedi'; -import { BaseContext, Fields, StandardDeleteResponse, UserId } from '../../../'; +import { + BaseContext, + ConnectionResult, + Fields, + PageInfo, + StandardDeleteResponse, + UserId +} from '../../../'; import { DishCreateInput, DishCreateManyArgs, @@ -30,21 +37,9 @@ import { DishService } from './dish.service'; import { NestedFields } from '../../../decorators'; @ObjectType() -export class PageInfo { - @Field(() => Number, { nullable: false }) - limit!: number; - - @Field(() => Number, { nullable: false }) - offset!: number; - - @Field(() => Number, { nullable: false }) - total!: number; -} - -@ObjectType() -export class DishesPaginated { +export class DishConnection implements ConnectionResult { @Field(() => [Dish], { nullable: false }) - dishes!: Dish[]; + nodes!: Dish[]; @Field(() => PageInfo, { nullable: false }) pageInfo!: PageInfo; @@ -70,27 +65,11 @@ export class DishResolver { } @Authorized('dish:read') - @Query(() => DishesPaginated) - async dishesPaginated( - @Args() { where, orderBy, limit, offset }: DishWhereArgs, - @NestedFields() fields: any - ): Promise { - const { records: dishes, total } = await this.service.findAndCount( - where, - orderBy, - limit, - offset, - fields.dishes || [] - ); - - return { - dishes, - pageInfo: { - limit: limit == null ? total : limit, - offset: offset == null ? 0 : offset, - total - } - }; + @Query(() => DishConnection) + async dishConnection( + @Args() { where, orderBy, limit, offset }: DishWhereArgs + ): Promise { + return this.service.findConnection(where, orderBy, limit, offset); } @Authorized('dish:read') diff --git a/src/tgql/PageInfo.ts b/src/tgql/PageInfo.ts new file mode 100644 index 00000000..260f4e03 --- /dev/null +++ b/src/tgql/PageInfo.ts @@ -0,0 +1,30 @@ +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; +} diff --git a/src/tgql/index.ts b/src/tgql/index.ts index 64541962..d0f4ca0c 100644 --- a/src/tgql/index.ts +++ b/src/tgql/index.ts @@ -3,6 +3,7 @@ export { BaseModel } from '../core/BaseModel'; export * from './BaseResolver'; export * from './BaseWhereInput'; export * from './DeleteResponse'; +export * from './PageInfo'; export * from './PaginationArgs'; export { StandardDeleteResponse } from './DeleteResponse'; export { loadFromGlobArray } from './loadGlobs';