diff --git a/src/core/BaseService.ts b/src/core/BaseService.ts index 31f3fc9e..58085695 100644 --- a/src/core/BaseService.ts +++ b/src/core/BaseService.ts @@ -1,12 +1,12 @@ 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 { BaseModel, ConnectionResult } from '..'; import { StringMap, WhereInput } from './types'; interface BaseOptions { @@ -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,6 +68,35 @@ export class BaseService { offset?: number, fields?: string[] ): Promise { + return this.buildFindQuery(where, orderBy, limit, offset, fields).getMany(); + } + + async findConnection( + where?: any, + orderBy?: string, + limit?: number, + offset?: number, + fields?: string[] + ): Promise> { + const qb = this.buildFindQuery(where, orderBy, limit, offset, fields); + const [nodes, totalCount] = await qb.getManyAndCount(); + // TODO: FEATURE - make the default limit configurable + limit = limit ?? 50; + offset = offset ?? 0; + + return { + nodes, + pageInfo: this.getPageInfo(limit, offset, totalCount) + }; + } + + private buildFindQuery( + where?: any, + orderBy?: string, + limit?: number, + offset?: number, + fields?: string[] + ): SelectQueryBuilder { let qb = this.manager.createQueryBuilder(this.entityClass, this.klass); if (limit) { @@ -124,7 +163,7 @@ export class BaseService { }); } - return qb.getMany(); + return qb; } async findOne>(where: W): Promise { 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__/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/__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..16ed65b6 100644 --- a/src/test/functional/server.test.ts +++ b/src/test/functional/server.test.ts @@ -123,6 +123,37 @@ describe('server', () => { expect(firstResult.dishes.length).toEqual(20); }); + 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 + totalCount + hasNextPage + hasPreviousPage + } + }` + ); + + console.log('test', nodes, pageInfo); + + expect(nodes).toMatchSnapshot(); + expect(pageInfo.offset).toEqual(0); + expect(pageInfo.limit).toEqual(1); + 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 => { expect.assertions(1); diff --git a/src/test/generated/binding.ts b/src/test/generated/binding.ts index c0af976b..6c5f07c3 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 , + 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 @@ -372,6 +373,11 @@ export interface Dish extends BaseGraphQLObject { kitchenSinkId: String } +export interface DishConnection { + nodes: Array + pageInfo: PageInfo +} + export interface KitchenSink extends BaseGraphQLObject { id: ID_Output createdAt: DateTime @@ -403,6 +409,14 @@ export interface KitchenSink extends BaseGraphQLObject { apiOnlyField?: String | null } +export interface PageInfo { + limit: Float + offset: Float + totalCount: Float + hasNextPage: Boolean + hasPreviousPage: Boolean +} + export interface StandardDeleteResponse { id: ID_Output } diff --git a/src/test/generated/schema.graphql b/src/test/generated/schema.graphql index d2d9d8f9..35ace075 100644 --- a/src/test/generated/schema.graphql +++ b/src/test/generated/schema.graphql @@ -78,6 +78,11 @@ type Dish implements BaseGraphQLObject { kitchenSinkId: String! } +type DishConnection { + nodes: [Dish!]! + pageInfo: PageInfo! +} + input DishCreateInput { name: String! kitchenSinkId: ID! @@ -374,8 +379,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/modules/dish/dish.resolver.ts b/src/test/modules/dish/dish.resolver.ts index b94eabd4..9e5bfeae 100644 --- a/src/test/modules/dish/dish.resolver.ts +++ b/src/test/modules/dish/dish.resolver.ts @@ -7,11 +7,20 @@ import { Mutation, Query, Resolver, - Root + Root, + ObjectType, + Field } from 'type-graphql'; import { Inject } from 'typedi'; -import { BaseContext, Fields, StandardDeleteResponse, UserId } from '../../../'; +import { + BaseContext, + ConnectionResult, + Fields, + PageInfo, + StandardDeleteResponse, + UserId +} from '../../../'; import { DishCreateInput, DishCreateManyArgs, @@ -25,6 +34,16 @@ 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[]; + + @Field(() => PageInfo, { nullable: false }) + pageInfo!: PageInfo; +} @Resolver(Dish) export class DishResolver { @@ -45,6 +64,14 @@ export class DishResolver { return this.service.find(where, orderBy, limit, offset, fields); } + @Authorized('dish:read') + @Query(() => DishConnection) + async dishConnection( + @Args() { where, orderBy, limit, offset }: DishWhereArgs + ): Promise { + return this.service.findConnection(where, orderBy, limit, offset); + } + @Authorized('dish:read') @Query(() => Dish) async dish(@Arg('where') where: DishWhereUniqueInput): Promise { 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';