├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── schema.graphql ├── src ├── _script │ └── seed.ts ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── cats │ ├── cats.module.ts │ ├── cats.resolvers.ts │ ├── cats.service.ts │ ├── dto │ │ ├── cat-order-by.input.ts │ │ ├── cat-where.input.ts │ │ ├── cats-connection.args.ts │ │ └── create-cat.input.ts │ ├── graphql-types │ │ ├── connection-types.ts │ │ └── create-cat.payload.ts │ └── models │ │ └── cat.model.ts ├── common │ ├── connection-paging.ts │ └── order-by-direction.ts ├── main.ts ├── nodes │ ├── models │ │ └── node.model.ts │ ├── nodes.module.ts │ └── nodes.reslovers.ts ├── ormconfig.ts └── users │ ├── dto │ ├── create-user.input.ts │ ├── update-user.input.ts │ └── user-where-unique.input.ts │ ├── graphql-types │ ├── connection-types.ts │ └── create-user.payload.ts │ ├── models │ └── user.model.ts │ ├── users.module.ts │ ├── users.resolvers.ts │ └── users.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:prettier/recommended', 6 | 'prettier/@typescript-eslint', 7 | ], 8 | env: { 9 | es6: true, 10 | node: true, 11 | }, 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | sourceType: 'module', 15 | project: './tsconfig.json', 16 | tsconfigRootDir: '.', 17 | }, 18 | plugins: ['@typescript-eslint', 'prettier'], 19 | rules: { 20 | '@typescript-eslint/prefer-interface': 'off', 21 | '@typescript-eslint/explicit-member-accessibility': 'off', 22 | '@typescript-eslint/explicit-function-return-type': 'off', 23 | '@typescript-eslint/no-parameter-properties': 'off', 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | '@typescript-eslint/no-non-null-assertion': 'off', 26 | '@typescript-eslint/no-unused-vars': [ 27 | 'error', 28 | { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nestjs-graphql-relay 2 | 3 | Example backend server for relay : 4 | 5 | - NestJS 7 6 | - Relay Style 7 | - Identification, Connections 8 | - using relay-js 9 | - Code first approach 10 | - TypeORM 11 | 12 | ## Running the app 13 | 14 | ```bash 15 | # start mysql server 16 | $ docker-compose up 17 | 18 | # development 19 | $ npm run start 20 | 21 | # watch mode 22 | $ npm run start:dev 23 | 24 | # run if initial data needed 25 | $ npx ts-node ./src/_script/seed.ts 26 | 27 | # drop all data 28 | $ npm run typeorm schema:drop 29 | ``` 30 | 31 | Go to `http://localhost:3000/graphql` 32 | 33 | example query 34 | 35 | ``` 36 | query { 37 | getUsers { 38 | id 39 | name 40 | catsConnection(where: {name: "Leo"}) { 41 | aggregate { 42 | count 43 | } 44 | edges { 45 | node { 46 | id 47 | name 48 | age 49 | user { 50 | id 51 | name 52 | } 53 | } 54 | } 55 | } 56 | } 57 | getCats { 58 | id 59 | name 60 | } 61 | } 62 | ``` 63 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | mysql: 5 | image: mysql:5.7 6 | restart: always 7 | environment: 8 | MYSQL_ROOT_PASSWORD: root 9 | MYSQL_DATABASE: test 10 | ports: 11 | - 33306:3306 12 | volumes: 13 | - mysql-data:/var/lib/mysql 14 | 15 | volumes: 16 | mysql-data: 17 | driver: local 18 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-graphql-relay", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "rimraf dist && tsc -p tsconfig.build.json", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 11 | "start:dev": "tsc-watch -p tsconfig.build.json --onSuccess \"node dist/main.js\"", 12 | "start:debug": "tsc-watch -p tsconfig.build.json --onSuccess \"node --inspect-brk dist/main.js\"", 13 | "start:prod": "node dist/main.js", 14 | "lint": "tslint -p tsconfig.json -c tslint.json", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json", 20 | "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config src/ormconfig.ts" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^7.0.11", 24 | "@nestjs/core": "^7.0.11", 25 | "@nestjs/graphql": "^7.3.7", 26 | "@nestjs/platform-express": "^7.0.11", 27 | "@nestjs/typeorm": "^7.0.0", 28 | "apollo-server-express": "^2.13.1", 29 | "class-transformer": "^0.3.1", 30 | "graphql": "^15.0.0", 31 | "graphql-relay": "^0.6.0", 32 | "graphql-tools": "^5.0.0", 33 | "mysql": "^2.18.1", 34 | "reflect-metadata": "^0.1.13", 35 | "rimraf": "^3.0.2", 36 | "rxjs": "^6.5.5", 37 | "type-graphql": "^0.17.6", 38 | "typeorm": "^0.2.24" 39 | }, 40 | "devDependencies": { 41 | "@nestjs/testing": "^7.0.11", 42 | "@types/express": "^4.17.6", 43 | "@types/graphql-relay": "^0.6.0", 44 | "@types/jest": "^25.2.2", 45 | "@types/node": "^14.0.1", 46 | "@types/supertest": "^2.0.9", 47 | "@typescript-eslint/eslint-plugin": "^2.33.0", 48 | "@typescript-eslint/parser": "^2.33.0", 49 | "eslint": "^7.0.0", 50 | "eslint-config-prettier": "^6.11.0", 51 | "eslint-plugin-prettier": "^3.1.3", 52 | "jest": "^26.0.1", 53 | "prettier": "^2.0.5", 54 | "supertest": "^4.0.2", 55 | "ts-jest": "^25.5.1", 56 | "ts-node": "^8.10.1", 57 | "tsc-watch": "^4.2.5", 58 | "tsconfig-paths": "^3.9.0", 59 | "typescript": "^3.9.2" 60 | }, 61 | "jest": { 62 | "moduleFileExtensions": [ 63 | "js", 64 | "json", 65 | "ts" 66 | ], 67 | "rootDir": "src", 68 | "testRegex": ".spec.ts$", 69 | "transform": { 70 | "^.+\\.(t|j)s$": "ts-jest" 71 | }, 72 | "coverageDirectory": "../coverage", 73 | "testEnvironment": "node" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | interface Node { 6 | id: ID! 7 | } 8 | 9 | type User implements Node { 10 | id: ID! 11 | name: String! 12 | catsConnection( 13 | """Paginate before opaque cursor""" 14 | before: String 15 | 16 | """Paginate after opaque cursor""" 17 | after: String 18 | 19 | """Paginate first""" 20 | first: Int 21 | 22 | """Paginate last""" 23 | last: Int 24 | where: CatWhereInput 25 | orderBy: CatOrderByInput 26 | ): CatConnection! 27 | } 28 | 29 | input CatWhereInput { 30 | name: String 31 | } 32 | 33 | input CatOrderByInput { 34 | createdAt: OrderByDirection 35 | updatedAt: OrderByDirection 36 | } 37 | 38 | enum OrderByDirection { 39 | ASC 40 | DESC 41 | } 42 | 43 | type Cat implements Node { 44 | id: ID! 45 | createdAt: DateTime! 46 | updatedAt: DateTime! 47 | name: String! 48 | age: Float! 49 | user: User! 50 | } 51 | 52 | """ 53 | A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. 54 | """ 55 | scalar DateTime 56 | 57 | type PageInfo { 58 | hasNextPage: Boolean 59 | hasPreviousPage: Boolean 60 | startCursor: String 61 | endCursor: String 62 | } 63 | 64 | type CatEdge { 65 | node: Cat! 66 | 67 | """Used in `before` and `after` args""" 68 | cursor: String! 69 | } 70 | 71 | type AggregateCat { 72 | count: Float! 73 | } 74 | 75 | type CatConnection { 76 | pageInfo: PageInfo! 77 | edges: [CatEdge!]! 78 | aggregate: AggregateCat! 79 | } 80 | 81 | type CreateCatPayload { 82 | catEdge: CatEdge! 83 | } 84 | 85 | type UserEdge { 86 | node: User! 87 | 88 | """Used in `before` and `after` args""" 89 | cursor: String! 90 | } 91 | 92 | type CreateUserPayload { 93 | userEdge: UserEdge! 94 | } 95 | 96 | type Query { 97 | getCats: [Cat!]! 98 | getUsers: [User!]! 99 | node(id: ID!): Node 100 | } 101 | 102 | type Mutation { 103 | createCat(data: CreateCatInput!): CreateCatPayload! 104 | updateUser(where: UserWhereUniqueInput!, data: UpdateUserInput!): User 105 | createUser(data: CreateUserInput!): CreateUserPayload! 106 | } 107 | 108 | input CreateCatInput { 109 | name: String! 110 | age: Int! 111 | userId: String! 112 | } 113 | 114 | input UserWhereUniqueInput { 115 | id: ID! 116 | } 117 | 118 | input UpdateUserInput { 119 | name: String! 120 | } 121 | 122 | input CreateUserInput { 123 | name: String! 124 | } 125 | -------------------------------------------------------------------------------- /src/_script/seed.ts: -------------------------------------------------------------------------------- 1 | import { INestApplicationContext } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from '../app.module'; 4 | import { UsersService } from '../users/users.service'; 5 | import { UsersModule } from '../users/users.module'; 6 | import { CatsService } from '../cats/cats.service'; 7 | import { CatsModule } from '../cats/cats.module'; 8 | 9 | const createApp = async (): Promise => { 10 | return await NestFactory.createApplicationContext(AppModule); 11 | }; 12 | 13 | const main = async (): Promise => { 14 | const app = await createApp(); 15 | console.log('start...'); 16 | 17 | const usersService: UsersService = app 18 | .select(UsersModule) 19 | .get(UsersService, { strict: true }); 20 | const alice = await usersService.create({ name: 'Alice' }); 21 | 22 | const catsService: CatsService = app 23 | .select(CatsModule) 24 | .get(CatsService, { strict: true }); 25 | 26 | console.log(alice.id); 27 | await catsService.create({ 28 | name: 'Leo', 29 | age: 1, 30 | userId: alice.id, 31 | }); 32 | 33 | await app.close(); 34 | }; 35 | 36 | main() 37 | .then(() => { 38 | console.log('done.'); 39 | process.exit(); 40 | }) 41 | .catch((err) => { 42 | console.error(err); 43 | process.exit(1); 44 | }); 45 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GraphQLModule } from '@nestjs/graphql'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { AppController } from './app.controller'; 5 | import { AppService } from './app.service'; 6 | import { CatsModule } from './cats/cats.module'; 7 | import { NodesModule } from './nodes/nodes.module'; 8 | import * as ormconfig from './ormconfig'; 9 | import { UsersModule } from './users/users.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | CatsModule, 14 | UsersModule, 15 | NodesModule, 16 | GraphQLModule.forRoot({ 17 | autoSchemaFile: 'schema.graphql', 18 | }), 19 | TypeOrmModule.forRoot(ormconfig), 20 | ], 21 | controllers: [AppController], 22 | providers: [AppService], 23 | }) 24 | export class AppModule {} 25 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/cats/cats.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { CatsResolvers } from './cats.resolvers'; 4 | import { CatsService } from './cats.service'; 5 | import { Cat } from './models/cat.model'; 6 | import { UsersModule } from '../users/users.module'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Cat]), forwardRef(() => UsersModule)], 10 | providers: [CatsService, CatsResolvers], 11 | exports: [CatsService], 12 | }) 13 | export class CatsModule {} 14 | -------------------------------------------------------------------------------- /src/cats/cats.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Args, 3 | Mutation, 4 | Query, 5 | Resolver, 6 | ResolveField, 7 | Parent, 8 | } from '@nestjs/graphql'; 9 | import { Cat } from './models/cat.model'; 10 | import { CatsService } from './cats.service'; 11 | import { CreateCatInput } from './dto/create-cat.input'; 12 | import { CreateCatPayload } from './graphql-types/create-cat.payload'; 13 | import * as Relay from 'graphql-relay'; 14 | import { User } from '../users/models/user.model'; 15 | import { UsersService } from '../users/users.service'; 16 | 17 | @Resolver(() => Cat) 18 | export class CatsResolvers { 19 | constructor( 20 | private readonly catsService: CatsService, 21 | private readonly usersService: UsersService, 22 | ) {} 23 | 24 | @Query((_returns) => [Cat]) 25 | async getCats() { 26 | return await this.catsService.findAll(); 27 | } 28 | 29 | @Mutation((_returns) => CreateCatPayload) 30 | async createCat( 31 | @Args('data') data: CreateCatInput, 32 | ): Promise { 33 | const { userId, ...rest } = data; 34 | const databaseUserId = Relay.fromGlobalId(userId).id; 35 | const createdCat = await this.catsService.create({ 36 | ...rest, 37 | userId: databaseUserId, 38 | }); 39 | return { 40 | catEdge: { node: createdCat, cursor: `temp:${createdCat.relayId}` }, 41 | }; 42 | } 43 | 44 | @ResolveField((_returns) => User) 45 | async user(@Parent() cat: Cat): Promise { 46 | const user = await this.usersService.findOneById(cat.userId); 47 | return user!; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/cats/cats.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { CatConnection } from 'src/cats/graphql-types/connection-types'; 4 | import { FindManyOptions, Repository } from 'typeorm'; 5 | import { CreateCatInput } from '../cats/dto/create-cat.input'; 6 | import { ConnectionArgs, findAndPaginate } from '../common/connection-paging'; 7 | import { Cat } from './models/cat.model'; 8 | 9 | @Injectable() 10 | export class CatsService { 11 | constructor( 12 | @InjectRepository(Cat) private readonly catRepository: Repository, 13 | ) {} 14 | 15 | async create(data: CreateCatInput): Promise { 16 | const { userId, ...restData } = data; 17 | const cat = this.catRepository.create({ 18 | ...restData, 19 | user: { id: userId }, 20 | }); 21 | return await this.catRepository.save(cat); 22 | } 23 | 24 | async findAll(): Promise { 25 | return this.catRepository.find(); 26 | } 27 | 28 | async findOneById(internalId: string): Promise { 29 | return await this.catRepository.findOne(internalId); 30 | } 31 | 32 | async findAndPaginate( 33 | where: FindManyOptions['where'], 34 | order: FindManyOptions['order'], 35 | connArgs: ConnectionArgs, 36 | ): Promise { 37 | const connection = await findAndPaginate( 38 | { where, order }, 39 | connArgs, 40 | this.catRepository, 41 | ); 42 | const count = await this.catRepository.count({ where }); 43 | return { 44 | ...connection, 45 | aggregate: { count }, 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/cats/dto/cat-order-by.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { OrderByDirection } from '../../common/order-by-direction'; 3 | 4 | @InputType() 5 | export class CatOrderByInput { 6 | @Field((_type) => OrderByDirection, { nullable: true }) 7 | createdAt?: OrderByDirection; 8 | 9 | @Field((_type) => OrderByDirection, { nullable: true }) 10 | updatedAt?: OrderByDirection; 11 | } 12 | -------------------------------------------------------------------------------- /src/cats/dto/cat-where.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class CatWhereInput { 5 | @Field((_type) => String, { nullable: true }) 6 | name?: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/cats/dto/cats-connection.args.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field } from '@nestjs/graphql'; 2 | import { ConnectionArgs } from '../../common/connection-paging'; 3 | import { CatOrderByInput } from './cat-order-by.input'; 4 | import { CatWhereInput } from './cat-where.input'; 5 | 6 | @ArgsType() 7 | export class CatsConnectionArgs extends ConnectionArgs { 8 | @Field((_type) => CatWhereInput, { nullable: true }) 9 | where?: CatWhereInput; 10 | 11 | @Field((_type) => CatOrderByInput, { nullable: true }) 12 | orderBy?: CatOrderByInput; 13 | } 14 | -------------------------------------------------------------------------------- /src/cats/dto/create-cat.input.ts: -------------------------------------------------------------------------------- 1 | import { Min, MaxLength } from 'class-validator'; 2 | import { InputType, Field, Int } from '@nestjs/graphql'; 3 | 4 | @InputType() 5 | export class CreateCatInput { 6 | @Field() 7 | @MaxLength(30) 8 | name: string; 9 | 10 | @Field((_type) => Int) 11 | @Min(1) 12 | age: number; 13 | 14 | @Field() 15 | userId: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/cats/graphql-types/connection-types.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from '@nestjs/graphql'; 2 | import { ConnectionType, EdgeType } from '../../common/connection-paging'; 3 | import { Cat } from '../models/cat.model'; 4 | 5 | @ObjectType() 6 | export class CatEdge extends EdgeType(Cat) {} 7 | 8 | @ObjectType() 9 | class AggregateCat { 10 | @Field((_type) => Number) 11 | count: number; 12 | } 13 | 14 | @ObjectType() 15 | export class CatConnection extends ConnectionType(Cat, CatEdge) { 16 | @Field((_type) => AggregateCat) 17 | aggregate: AggregateCat; 18 | } 19 | -------------------------------------------------------------------------------- /src/cats/graphql-types/create-cat.payload.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from '@nestjs/graphql'; 2 | import { CatEdge } from './connection-types'; 3 | 4 | @ObjectType() 5 | export class CreateCatPayload { 6 | @Field((_type) => CatEdge) 7 | catEdge: CatEdge; 8 | } 9 | -------------------------------------------------------------------------------- /src/cats/models/cat.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql'; 2 | import { toGlobalId } from 'graphql-relay'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | RelationId, 11 | } from 'typeorm'; 12 | import { Node } from '../../nodes/models/node.model'; 13 | import { User } from '../../users/models/user.model'; 14 | 15 | @Entity() 16 | @ObjectType({ implements: Node }) 17 | export class Cat implements Node { 18 | @PrimaryGeneratedColumn('uuid') 19 | readonly id: string; 20 | 21 | @CreateDateColumn() 22 | @Field((_type) => GraphQLISODateTime) 23 | readonly createdAt: Date; 24 | 25 | @UpdateDateColumn() 26 | @Field((_type) => GraphQLISODateTime) 27 | readonly updatedAt: Date; 28 | 29 | @Column() 30 | @Field() 31 | name: string; 32 | 33 | @Column() 34 | @Field() 35 | age: number; 36 | 37 | @ManyToOne((_type) => User, (user) => user.cats) 38 | user: User; 39 | 40 | @RelationId((cat: Cat) => cat.user) 41 | userId: string; 42 | 43 | @Field((_type) => ID, { name: 'id' }) 44 | get relayId(): string { 45 | return toGlobalId('Cat', this.id); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/common/connection-paging.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field, Int, ObjectType } from '@nestjs/graphql'; 2 | import { Type } from '@nestjs/common'; 3 | import * as Relay from 'graphql-relay'; 4 | import { 5 | Min, 6 | Validate, 7 | ValidateIf, 8 | ValidationArguments, 9 | ValidatorConstraint, 10 | ValidatorConstraintInterface, 11 | } from 'class-validator'; 12 | import { FindManyOptions, Repository, SelectQueryBuilder } from 'typeorm'; 13 | 14 | @ObjectType() 15 | export class PageInfo implements Relay.PageInfo { 16 | @Field((_type) => Boolean, { nullable: true }) 17 | hasNextPage?: boolean | null; 18 | @Field((_type) => Boolean, { nullable: true }) 19 | hasPreviousPage?: boolean | null; 20 | @Field((_type) => String, { nullable: true }) 21 | startCursor?: Relay.ConnectionCursor | null; 22 | @Field((_type) => String, { nullable: true }) 23 | endCursor?: Relay.ConnectionCursor | null; 24 | } 25 | 26 | @ValidatorConstraint({ async: false }) 27 | class CannotUseWithout implements ValidatorConstraintInterface { 28 | validate(value: any, args: ValidationArguments) { 29 | const object = args.object as any; 30 | const required = args.constraints[0] as string; 31 | return object[required] !== undefined; 32 | } 33 | 34 | defaultMessage(args: ValidationArguments) { 35 | return `Cannot be used without \`${args.constraints[0]}\`.`; 36 | } 37 | } 38 | 39 | @ValidatorConstraint({ async: false }) 40 | class CannotUseWith implements ValidatorConstraintInterface { 41 | validate(value: any, args: ValidationArguments) { 42 | const object = args.object as any; 43 | const result = args.constraints.every((propertyName) => { 44 | return object[propertyName] === undefined; 45 | }); 46 | return result; 47 | } 48 | 49 | defaultMessage(args: ValidationArguments) { 50 | return `Cannot be used with \`${args.constraints.join('` , `')}\`.`; 51 | } 52 | } 53 | 54 | @ArgsType() 55 | export class ConnectionArgs implements Relay.ConnectionArguments { 56 | @Field((_type) => String, { 57 | nullable: true, 58 | description: 'Paginate before opaque cursor', 59 | }) 60 | @ValidateIf((o) => o.before !== undefined) 61 | @Validate(CannotUseWithout, ['last']) 62 | @Validate(CannotUseWith, ['after', 'first']) 63 | before?: Relay.ConnectionCursor; 64 | 65 | @Field((_type) => String, { 66 | nullable: true, 67 | description: 'Paginate after opaque cursor', 68 | }) 69 | @ValidateIf((o) => o.after !== undefined) 70 | @Validate(CannotUseWithout, ['first']) 71 | @Validate(CannotUseWith, ['before', 'last']) 72 | after?: Relay.ConnectionCursor; 73 | 74 | @Field((_type) => Int, { nullable: true, description: 'Paginate first' }) 75 | @ValidateIf((o) => o.first !== undefined) 76 | @Min(1) 77 | @Validate(CannotUseWith, ['before', 'last']) 78 | first?: number; 79 | 80 | @Field((_type) => Int, { nullable: true, description: 'Paginate last' }) 81 | @ValidateIf((o) => o.last !== undefined) 82 | // Required `before`. This is a weird corner case. 83 | // We'd have to invert the ordering of query to get the last few items then re-invert it when emitting the results. 84 | // We'll just ignore it for now. 85 | @Validate(CannotUseWithout, ['before']) 86 | @Validate(CannotUseWith, ['after', 'first']) 87 | @Min(1) 88 | last?: number; 89 | } 90 | 91 | export function EdgeType(classRef: Type) { 92 | @ObjectType({ isAbstract: true }) 93 | abstract class Edge implements Relay.Edge { 94 | @Field(() => classRef) 95 | node: T; 96 | 97 | @Field((_type) => String, { 98 | description: 'Used in `before` and `after` args', 99 | }) 100 | cursor: Relay.ConnectionCursor; 101 | } 102 | 103 | return Edge; 104 | } 105 | 106 | export function ConnectionType(classRef: Type, Edge: any) { 107 | @ObjectType({ isAbstract: true }) 108 | abstract class Connection implements Relay.Connection { 109 | @Field() 110 | pageInfo: PageInfo; 111 | 112 | @Field(() => [Edge]) 113 | edges: Array>; 114 | } 115 | 116 | return Connection; 117 | } 118 | 119 | type PagingMeta = 120 | | { pagingType: 'forward'; after?: string; first: number } 121 | | { pagingType: 'backward'; before?: string; last: number } 122 | | { pagingType: 'none' }; 123 | 124 | function getMeta(args: ConnectionArgs): PagingMeta { 125 | const { first = 0, last = 0, after, before } = args; 126 | const isForwardPaging = !!first || !!after; 127 | const isBackwardPaging = !!last || !!before; 128 | 129 | return isForwardPaging 130 | ? { pagingType: 'forward', after, first } 131 | : isBackwardPaging 132 | ? { pagingType: 'backward', before, last } 133 | : { pagingType: 'none' }; 134 | } 135 | 136 | /** 137 | * Create a 'paging parameters' object with 'limit' and 'offset' fields based on the incoming 138 | * cursor-paging arguments. 139 | */ 140 | export function getPagingParameters(args: ConnectionArgs) { 141 | const meta = getMeta(args); 142 | 143 | switch (meta.pagingType) { 144 | case 'forward': { 145 | return { 146 | limit: meta.first, 147 | offset: meta.after ? Relay.cursorToOffset(meta.after) + 1 : 0, 148 | }; 149 | } 150 | case 'backward': { 151 | const { last, before } = meta; 152 | let limit = last; 153 | let offset = Relay.cursorToOffset(before!) - last; 154 | 155 | // Check to see if our before-page is underflowing past the 0th item 156 | if (offset < 0) { 157 | // Adjust the limit with the underflow value 158 | limit = Math.max(last + offset, 0); 159 | offset = 0; 160 | } 161 | 162 | return { offset, limit }; 163 | } 164 | default: 165 | return {}; 166 | } 167 | } 168 | 169 | export async function findAndPaginate( 170 | condition: FindManyOptions, 171 | connArgs: ConnectionArgs, 172 | repository: Repository, 173 | ) { 174 | const { limit, offset } = getPagingParameters(connArgs); 175 | const [entities, count] = await repository.findAndCount({ 176 | ...condition, 177 | skip: offset, 178 | take: limit, 179 | }); 180 | 181 | const res = Relay.connectionFromArraySlice(entities, connArgs, { 182 | arrayLength: count, 183 | sliceStart: offset || 0, 184 | }); 185 | return res; 186 | } 187 | 188 | export async function getManyAndPaginate( 189 | queryBuilder: SelectQueryBuilder, 190 | connArgs: ConnectionArgs, 191 | ) { 192 | const { limit, offset } = getPagingParameters(connArgs); 193 | const [entities, count] = await queryBuilder 194 | .offset(offset) 195 | .limit(limit) 196 | .getManyAndCount(); 197 | 198 | const res = Relay.connectionFromArraySlice(entities, connArgs, { 199 | arrayLength: count, 200 | sliceStart: offset || 0, 201 | }); 202 | return res; 203 | } 204 | 205 | export { 206 | connectionFromArray, 207 | connectionFromPromisedArray, 208 | connectionFromArraySlice, 209 | connectionFromPromisedArraySlice, 210 | } from 'graphql-relay'; 211 | -------------------------------------------------------------------------------- /src/common/order-by-direction.ts: -------------------------------------------------------------------------------- 1 | import { registerEnumType } from '@nestjs/graphql'; 2 | 3 | export enum OrderByDirection { 4 | ASC = 'ASC', 5 | DESC = 'DESC', 6 | } 7 | 8 | registerEnumType(OrderByDirection, { name: 'OrderByDirection' }); 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.useGlobalPipes(new ValidationPipe()); 8 | await app.listen(3000); 9 | } 10 | bootstrap(); 11 | -------------------------------------------------------------------------------- /src/nodes/models/node.model.ts: -------------------------------------------------------------------------------- 1 | import { InterfaceType, Field, ID } from '@nestjs/graphql'; 2 | 3 | @InterfaceType() 4 | export abstract class Node { 5 | @Field((_type) => ID, { name: 'id' }) 6 | relayId: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/nodes/nodes.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CatsModule } from '../cats/cats.module'; 3 | import { NodesResolvers } from './nodes.reslovers'; 4 | import { UsersModule } from '../users/users.module'; 5 | 6 | @Module({ 7 | imports: [CatsModule, UsersModule], 8 | providers: [NodesResolvers], 9 | }) 10 | export class NodesModule {} 11 | -------------------------------------------------------------------------------- /src/nodes/nodes.reslovers.ts: -------------------------------------------------------------------------------- 1 | import { isUUID } from '@nestjs/common/utils/is-uuid'; 2 | import { Args, ID, Query, Resolver } from '@nestjs/graphql'; 3 | import { fromGlobalId } from 'graphql-relay'; 4 | import { CatsService } from '../cats/cats.service'; 5 | import { UsersService } from '../users/users.service'; 6 | import { Node } from './models/node.model'; 7 | 8 | @Resolver() 9 | export class NodesResolvers { 10 | constructor( 11 | private readonly catsService: CatsService, 12 | private readonly usersService: UsersService, 13 | ) {} 14 | 15 | @Query((_returns) => Node, { nullable: true }) 16 | async node( 17 | @Args({ name: 'id', type: () => ID }) id: string, 18 | ): Promise { 19 | const resolvedGlobalId = fromGlobalId(id); 20 | if (!isUUID(resolvedGlobalId.id)) { 21 | return null; 22 | } 23 | switch (resolvedGlobalId.type) { 24 | case 'Cat': 25 | return await this.catsService.findOneById(resolvedGlobalId.id); 26 | case 'User': 27 | return await this.usersService.findOneById(resolvedGlobalId.id); 28 | default: 29 | break; 30 | } 31 | return null; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionOptions } from 'typeorm'; 2 | 3 | const config: ConnectionOptions = { 4 | type: 'mysql', 5 | host: '127.0.0.1', 6 | port: 33306, 7 | username: 'root', 8 | password: 'root', 9 | database: 'test', 10 | synchronize: true, 11 | charset: 'utf8mb4', 12 | entities: [__dirname + '/**/models/*.model.{js,ts}'], 13 | logging: true, 14 | }; 15 | 16 | export = config; 17 | -------------------------------------------------------------------------------- /src/users/dto/create-user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { MaxLength } from 'class-validator'; 3 | 4 | @InputType() 5 | export class CreateUserInput { 6 | @Field() 7 | @MaxLength(30) 8 | name: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/users/dto/update-user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { MaxLength } from 'class-validator'; 3 | 4 | @InputType() 5 | export class UpdateUserInput { 6 | @Field() 7 | @MaxLength(30) 8 | name: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/users/dto/user-where-unique.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field, ID } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class UserWhereUniqueInput { 5 | @Field((_type) => ID) 6 | id: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/users/graphql-types/connection-types.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from '@nestjs/graphql'; 2 | import { EdgeType } from '../../common/connection-paging'; 3 | import { User } from '../models/user.model'; 4 | 5 | @ObjectType() 6 | export class UserEdge extends EdgeType(User) {} 7 | -------------------------------------------------------------------------------- /src/users/graphql-types/create-user.payload.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | import { UserEdge } from './connection-types'; 3 | 4 | @ObjectType() 5 | export class CreateUserPayload { 6 | @Field((_type) => UserEdge) 7 | userEdge: UserEdge; 8 | } 9 | -------------------------------------------------------------------------------- /src/users/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from '@nestjs/graphql'; 2 | import { toGlobalId } from 'graphql-relay'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { Cat } from '../../cats/models/cat.model'; 12 | import { Node } from '../../nodes/models/node.model'; 13 | 14 | @Entity() 15 | @ObjectType({ implements: Node }) 16 | export class User implements Node { 17 | // NOTE : I like uuid, but it has nothing to do with the relay specification. 18 | @PrimaryGeneratedColumn('uuid') 19 | readonly id: string; 20 | 21 | @Field((_type) => ID, { name: 'id' }) 22 | get relayId(): string { 23 | return toGlobalId('User', this.id); 24 | } 25 | 26 | @CreateDateColumn() 27 | readonly createdAt: Date; 28 | 29 | @UpdateDateColumn() 30 | readonly updatedAt: Date; 31 | 32 | @Column() 33 | @Field() 34 | name: string; 35 | 36 | @OneToMany((_type) => Cat, (cat) => cat.user) 37 | cats: Cat[]; 38 | } 39 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { CatsModule } from '../cats/cats.module'; 4 | import { User } from './models/user.model'; 5 | import { UsersResolvers } from './users.resolvers'; 6 | import { UsersService } from './users.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User]), forwardRef(() => CatsModule)], 10 | providers: [UsersService, UsersResolvers], 11 | exports: [UsersService], 12 | }) 13 | export class UsersModule {} 14 | -------------------------------------------------------------------------------- /src/users/users.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Args, 3 | Mutation, 4 | Parent, 5 | Query, 6 | ResolveField, 7 | Resolver, 8 | } from '@nestjs/graphql'; 9 | import { CatsService } from '../cats/cats.service'; 10 | import { CatsConnectionArgs } from '../cats/dto/cats-connection.args'; 11 | import { CatConnection } from '../cats/graphql-types/connection-types'; 12 | import { UpdateUserInput } from '../users/dto/update-user.input'; 13 | import { UserWhereUniqueInput } from '../users/dto/user-where-unique.input'; 14 | import { CreateUserInput } from './dto/create-user.input'; 15 | import { CreateUserPayload } from './graphql-types/create-user.payload'; 16 | import { User } from './models/user.model'; 17 | import { UsersService } from './users.service'; 18 | 19 | @Resolver(() => User) 20 | export class UsersResolvers { 21 | constructor( 22 | private readonly usersService: UsersService, 23 | private readonly catsService: CatsService, 24 | ) {} 25 | 26 | @Query((_returns) => [User]) 27 | async getUsers() { 28 | return await this.usersService.findAll(); 29 | } 30 | 31 | @Mutation((_returns) => User, { nullable: true }) 32 | async updateUser( 33 | @Args('data') data: UpdateUserInput, 34 | @Args('where') where: UserWhereUniqueInput, 35 | ): Promise { 36 | return await this.usersService.update(data, where); 37 | } 38 | 39 | @Mutation((_returns) => CreateUserPayload) 40 | async createUser( 41 | @Args('data') data: CreateUserInput, 42 | ): Promise { 43 | const user = await this.usersService.create(data); 44 | return { 45 | userEdge: { node: user, cursor: `temp:${user.relayId}` }, 46 | }; 47 | } 48 | 49 | @ResolveField((_returns) => CatConnection) 50 | async catsConnection( 51 | @Parent() user: User, 52 | @Args() connectionArgs: CatsConnectionArgs, 53 | ): Promise { 54 | const { where, orderBy: order, ...args } = connectionArgs; 55 | return await this.catsService.findAndPaginate( 56 | { ...where, user: { id: user.id } }, 57 | order, 58 | args, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { isUUID } from '@nestjs/common/utils/is-uuid'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import * as Relay from 'graphql-relay'; 5 | import { Repository } from 'typeorm'; 6 | import { UpdateUserInput } from '../users/dto/update-user.input'; 7 | import { UserWhereUniqueInput } from '../users/dto/user-where-unique.input'; 8 | import { CreateUserInput } from './dto/create-user.input'; 9 | import { User } from './models/user.model'; 10 | 11 | @Injectable() 12 | export class UsersService { 13 | constructor( 14 | @InjectRepository(User) private readonly userRepository: Repository, 15 | ) {} 16 | 17 | async create(data: CreateUserInput): Promise { 18 | const user = this.userRepository.create(data); 19 | return await this.userRepository.save(user); 20 | } 21 | 22 | async update( 23 | data: UpdateUserInput, 24 | where: UserWhereUniqueInput, 25 | ): Promise { 26 | const parsedUserId = Relay.fromGlobalId(where.id); 27 | if (!isUUID(parsedUserId.id)) { 28 | return undefined; 29 | } 30 | const user = await this.userRepository.findOne(parsedUserId.id); 31 | if (!user) { 32 | return user; 33 | } 34 | this.userRepository.merge(user, data); 35 | return await this.userRepository.save(user); 36 | } 37 | 38 | async findAll(): Promise { 39 | return this.userRepository.find(); 40 | } 41 | 42 | async findOneById(internalId: string): Promise { 43 | return await this.userRepository.findOne(internalId); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": false, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "strict": true, 14 | "strictPropertyInitialization": false, 15 | "esModuleInterop": true 16 | }, 17 | "include": ["**/src/**/*.ts"], 18 | "exclude": ["node_modules", "dist", ".eslintrc.js"] 19 | } 20 | --------------------------------------------------------------------------------