├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .markdownlint.json ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── jest.config.js ├── package.json ├── src ├── __mocks__ │ └── User.ts ├── __tests__ │ ├── composeWithPagination-test.ts │ ├── mocks-userTypeComposer-test.ts │ ├── pagination-test.ts │ └── types-test.ts ├── composeWithPagination.ts ├── index.ts ├── pagination.ts └── types.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/* 2 | es/* 3 | mjs/* 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint', 'prettier'], 6 | extends: ['plugin:@typescript-eslint/recommended', 'prettier', 'plugin:prettier/recommended'], 7 | parserOptions: { 8 | sourceType: 'module', 9 | useJSXTextNode: true, 10 | project: [path.resolve(__dirname, 'tsconfig.json')], 11 | }, 12 | rules: { 13 | 'no-underscore-dangle': 0, 14 | 'arrow-body-style': 0, 15 | 'no-unused-expressions': 0, 16 | 'no-plusplus': 0, 17 | 'no-console': 0, 18 | 'func-names': 0, 19 | 'comma-dangle': [ 20 | 'error', 21 | { 22 | arrays: 'always-multiline', 23 | objects: 'always-multiline', 24 | imports: 'always-multiline', 25 | exports: 'always-multiline', 26 | functions: 'ignore', 27 | }, 28 | ], 29 | 'no-prototype-builtins': 0, 30 | 'prefer-destructuring': 0, 31 | 'no-else-return': 0, 32 | 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 33 | '@typescript-eslint/explicit-member-accessibility': 0, 34 | '@typescript-eslint/no-explicit-any': 0, 35 | '@typescript-eslint/no-inferrable-types': 0, 36 | '@typescript-eslint/explicit-function-return-type': 0, 37 | '@typescript-eslint/no-use-before-define': 0, 38 | '@typescript-eslint/no-empty-function': 0, 39 | '@typescript-eslint/camelcase': 0, 40 | '@typescript-eslint/ban-ts-comment': 0, 41 | }, 42 | env: { 43 | jasmine: true, 44 | jest: true, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [12.x, 14.x, 16.x] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Install node_modules 21 | run: yarn 22 | - name: Test & lint 23 | run: yarn test 24 | env: 25 | CI: true 26 | - name: Send codecov.io stats 27 | if: matrix.node-version == '12.x' 28 | run: bash <(curl -s https://codecov.io/bash) || echo '' 29 | 30 | publish: 31 | if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/alpha' || github.ref == 'refs/heads/beta' 32 | needs: [tests] 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v2 36 | - name: Use Node.js 12 37 | uses: actions/setup-node@v1 38 | with: 39 | node-version: 12.x 40 | - name: Install node_modules 41 | run: yarn install 42 | - name: Build 43 | run: yarn build 44 | - name: Semantic Release (publish to npm) 45 | run: yarn semantic-release 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | 15 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 16 | .grunt 17 | 18 | # node-waf configuration 19 | .lock-wscript 20 | 21 | # Compiled binary addons (http://nodejs.org/api/addons.html) 22 | build/Release 23 | 24 | # IntelliJ Files 25 | *.iml 26 | *.ipr 27 | *.iws 28 | /out/ 29 | .idea/ 30 | .idea_modules/ 31 | 32 | # Dependency directory 33 | node_modules 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | # Transpiled code 42 | /es 43 | /lib 44 | /mjs 45 | 46 | coverage 47 | .nyc_output 48 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false, 3 | "no-trailing-punctuation": { 4 | "punctuation": ",;" 5 | }, 6 | "no-inline-html": false, 7 | "ol-prefix": false, 8 | "first-line-h1": false, 9 | "first-heading-h1": false 10 | } 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "printWidth": 100, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Jest", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/node_modules/.bin/jest", 12 | "args": ["--runInBand", "--watch"], 13 | "cwd": "${workspaceFolder}", 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen", 16 | "disableOptimisticBPs": true 17 | }, 18 | { 19 | "name": "Jest Current File", 20 | "type": "node", 21 | "request": "launch", 22 | "program": "${workspaceFolder}/node_modules/.bin/jest", 23 | "args": [ 24 | "${fileBasenameNoExtension}", 25 | "--config", 26 | "jest.config.js" 27 | ], 28 | "console": "integratedTerminal", 29 | "internalConsoleOptions": "neverOpen", 30 | "disableOptimisticBPs": true 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["javascript"], 3 | "javascript.validate.enable": false, 4 | "javascript.autoClosingTags": false, 5 | "eslint.autoFixOnSave": true, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master 2 | 3 | This package publishing automated by [semantic-release](https://github.com/semantic-release/semantic-release). 4 | Changelog is generated automatically and can be found here: 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Pavel Chertorogov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-compose-pagination 2 | 3 | [![travis build](https://img.shields.io/travis/graphql-compose/graphql-compose-pagination.svg)](https://travis-ci.org/graphql-compose/graphql-compose-pagination) 4 | [![codecov coverage](https://img.shields.io/codecov/c/github/graphql-compose/graphql-compose-pagination.svg)](https://codecov.io/github/graphql-compose/graphql-compose-pagination) 5 | [![npm](https://img.shields.io/npm/v/graphql-compose-pagination.svg)](https://www.npmjs.com/package/graphql-compose-pagination) 6 | [![trend](https://img.shields.io/npm/dt/graphql-compose-pagination.svg)](http://www.npmtrends.com/graphql-compose-pagination) 7 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 8 | 9 | This is a plugin for [graphql-compose](https://github.com/graphql-compose/graphql-compose) family, which adds to the ObjectTypeComposer `pagination` resolver. 10 | 11 | Live demo: [https://graphql-compose.herokuapp.com/](https://graphql-compose.herokuapp.com/) 12 | 13 | [CHANGELOG](https://github.com/graphql-compose/graphql-compose-pagination/blob/master/CHANGELOG.md) 14 | 15 | ## Installation 16 | 17 | ```bash 18 | npm install graphql graphql-compose graphql-compose-pagination --save 19 | ``` 20 | 21 | Modules `graphql` and `graphql-compose` are in `peerDependencies`, so should be installed explicitly in your app. They should not installed as sub-modules, cause internally checks the classes instances. 22 | 23 | ## Example 24 | 25 | ```js 26 | import { preparePaginationResolver } from 'graphql-compose-pagination'; 27 | import { UserTC, findManyResolver, countResolver } from './user'; 28 | 29 | const paginationResolver = preparePaginationResolver(UserTC, { 30 | findManyResolver, 31 | countResolver, 32 | name: 'pagination', // Default 33 | perPage: 20, // Default 34 | }); 35 | ``` 36 | 37 | Implementation of `findManyResolver` and `countResolver` can be found in [this file](./src/__mocks__/User.ts). 38 | 39 | screen shot 2017-08-07 at 23 31 46 40 | 41 | ## Used in plugins 42 | 43 | [graphql-compose-mongoose](https://github.com/graphql-compose/graphql-compose-mongoose) – converts mongoose models to graphql types 44 | 45 | ## License 46 | 47 | [MIT](https://github.com/graphql-compose/graphql-compose-pagination/blob/master/LICENSE.md) 48 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | tsConfig: '/tsconfig.json', 7 | isolatedModules: true, 8 | diagnostics: false, 9 | }, 10 | }, 11 | moduleFileExtensions: ['ts', 'js'], 12 | transform: { 13 | '^.+\\.ts$': 'ts-jest', 14 | '^.+\\.js$': 'babel-jest', 15 | }, 16 | roots: ['/src'], 17 | testPathIgnorePatterns: ['/node_modules/', '/lib/'], 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-compose-pagination", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Plugin for `graphql-compose` which provide a pagination resolver for types.", 5 | "files": [ 6 | "lib", 7 | "README.md" 8 | ], 9 | "main": "lib/index.js", 10 | "types": "lib/index.d.ts", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/graphql-compose/graphql-compose-pagination.git" 14 | }, 15 | "keywords": [ 16 | "graphql", 17 | "graphql-compose", 18 | "pagination" 19 | ], 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/graphql-compose/graphql-compose-pagination/issues" 23 | }, 24 | "homepage": "https://github.com/graphql-compose/graphql-compose-pagination", 25 | "peerDependencies": { 26 | "graphql-compose": "^7.15.0 || ^8.0.0 || ^9.0.0" 27 | }, 28 | "devDependencies": { 29 | "@types/graphql": "14.5.0", 30 | "@types/jest": "26.0.24", 31 | "@typescript-eslint/eslint-plugin": "4.28.2", 32 | "@typescript-eslint/parser": "4.28.2", 33 | "eslint": "7.30.0", 34 | "eslint-config-airbnb-base": "14.2.1", 35 | "eslint-config-prettier": "8.3.0", 36 | "eslint-plugin-import": "2.23.4", 37 | "eslint-plugin-prettier": "3.4.0", 38 | "graphql": "15.5.1", 39 | "graphql-compose": "9.0.1", 40 | "jest": "27.0.6", 41 | "prettier": "2.3.2", 42 | "rimraf": "3.0.2", 43 | "semantic-release": "17.4.4", 44 | "ts-jest": "27.0.3", 45 | "typescript": "4.3.5" 46 | }, 47 | "scripts": { 48 | "build": "rimraf lib && tsc -p ./tsconfig.build.json", 49 | "watch": "jest --watch", 50 | "coverage": "jest --coverage", 51 | "lint": "yarn eslint && yarn tscheck", 52 | "eslint": "eslint --ext .ts ./src", 53 | "tscheck": "tsc --noEmit", 54 | "test": "npm run coverage && npm run lint", 55 | "link": "yarn build && yarn link graphql-compose && yarn link", 56 | "unlink": "rimraf node_modules && yarn install", 57 | "semantic-release": "semantic-release" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/__mocks__/User.ts: -------------------------------------------------------------------------------- 1 | import { schemaComposer, ResolverResolveParams } from 'graphql-compose'; 2 | 3 | export const UserTC = schemaComposer.createObjectTC(` 4 | type User { 5 | id: Int 6 | name: String 7 | age: Int 8 | gender: String 9 | } 10 | `); 11 | 12 | export const userList = [ 13 | { id: 1, name: 'user01', age: 11, gender: 'm' }, 14 | { id: 2, name: 'user02', age: 12, gender: 'm' }, 15 | { id: 3, name: 'user03', age: 13, gender: 'f' }, 16 | { id: 4, name: 'user04', age: 14, gender: 'm' }, 17 | { id: 5, name: 'user05', age: 15, gender: 'f' }, 18 | { id: 6, name: 'user06', age: 16, gender: 'f' }, 19 | { id: 7, name: 'user07', age: 17, gender: 'f' }, 20 | { id: 8, name: 'user08', age: 18, gender: 'm' }, 21 | { id: 9, name: 'user09', age: 19, gender: 'm' }, 22 | { id: 10, name: 'user10', age: 49, gender: 'f' }, 23 | { id: 11, name: 'user11', age: 49, gender: 'm' }, 24 | { id: 12, name: 'user12', age: 47, gender: 'f' }, 25 | { id: 15, name: 'user15', age: 45, gender: 'm' }, 26 | { id: 14, name: 'user14', age: 45, gender: 'm' }, 27 | { id: 13, name: 'user13', age: 45, gender: 'f' }, 28 | ]; 29 | 30 | const filterArgConfig = { 31 | name: 'filter', 32 | type: `input FilterUserInput { 33 | gender: String 34 | age: Int 35 | }`, 36 | }; 37 | 38 | function filteredUserList(list: typeof userList, filter = {} as any) { 39 | let result = list.slice(); 40 | if (filter.gender) { 41 | result = result.filter((o) => o.gender === filter.gender); 42 | } 43 | 44 | if (filter.id) { 45 | if (filter.id.$lt) { 46 | result = result.filter((o) => o.id < filter.id.$lt); 47 | } 48 | if (filter.id.$gt) { 49 | result = result.filter((o) => o.id > filter.id.$gt); 50 | } 51 | } 52 | if (filter.age) { 53 | if (filter.age.$lt) { 54 | result = result.filter((o) => o.age < filter.age.$lt); 55 | } 56 | if (filter.age.$gt) { 57 | result = result.filter((o) => o.age > filter.age.$gt); 58 | } 59 | } 60 | 61 | return result; 62 | } 63 | 64 | function sortUserList( 65 | list: typeof userList, 66 | sortValue = {} as Record 67 | ) { 68 | const fields = Object.keys(sortValue) as Array; 69 | list.sort((a, b) => { 70 | let result = 0; 71 | fields.forEach((field) => { 72 | if (result === 0) { 73 | if (a[field] < b[field]) { 74 | result = sortValue[field] * -1; 75 | } else if (a[field] > b[field]) { 76 | result = sortValue[field]; 77 | } 78 | } 79 | }); 80 | return result; 81 | }); 82 | return list; 83 | } 84 | 85 | function prepareFilterFromArgs(resolveParams = {} as ResolverResolveParams) { 86 | const args = resolveParams.args || {}; 87 | const filter = { ...args.filter }; 88 | if (resolveParams.rawQuery) { 89 | Object.keys(resolveParams.rawQuery).forEach((k) => { 90 | filter[k] = resolveParams.rawQuery[k]; 91 | }); 92 | } 93 | return filter; 94 | } 95 | 96 | export const findManyResolver = schemaComposer.createResolver({ 97 | name: 'findMany', 98 | kind: 'query', 99 | type: UserTC, 100 | args: { 101 | filter: filterArgConfig, 102 | sort: schemaComposer.createEnumTC({ 103 | name: 'SortUserInput', 104 | values: { 105 | ID_ASC: { value: { id: 1 } }, 106 | ID_DESC: { value: { id: -1 } }, 107 | AGE_ASC: { value: { age: 1 } }, 108 | AGE_DESC: { value: { age: -1 } }, 109 | }, 110 | }), 111 | limit: 'Int', 112 | skip: 'Int', 113 | }, 114 | resolve: (resolveParams) => { 115 | const args = resolveParams.args || {}; 116 | const { sort, limit, skip } = args; 117 | 118 | let list = userList.slice(); 119 | list = sortUserList(list, sort as any); 120 | list = filteredUserList(list, prepareFilterFromArgs(resolveParams)); 121 | 122 | if (skip) { 123 | list = list.slice(skip as any); 124 | } 125 | 126 | if (limit) { 127 | list = list.slice(0, limit as any); 128 | } 129 | 130 | return Promise.resolve(list); 131 | }, 132 | }); 133 | 134 | export const countResolver = schemaComposer.createResolver({ 135 | name: 'count', 136 | kind: 'query', 137 | type: 'Int', 138 | args: { 139 | filter: filterArgConfig, 140 | }, 141 | resolve: (resolveParams) => { 142 | return Promise.resolve(filteredUserList(userList, prepareFilterFromArgs(resolveParams)).length); 143 | }, 144 | }); 145 | -------------------------------------------------------------------------------- /src/__tests__/composeWithPagination-test.ts: -------------------------------------------------------------------------------- 1 | import { ObjectTypeComposer, schemaComposer } from 'graphql-compose'; 2 | import { GraphQLList, graphql } from 'graphql-compose/lib/graphql'; 3 | import { composeWithPagination } from '../composeWithPagination'; 4 | import { UserTC, countResolver, findManyResolver } from '../__mocks__/User'; 5 | 6 | describe('composeWithPagination', () => { 7 | const userComposer = composeWithPagination(UserTC, { 8 | countResolver, 9 | findManyResolver, 10 | perPage: 5, 11 | }); 12 | 13 | describe('basic checks', () => { 14 | it('should return ObjectTypeComposer', () => { 15 | expect(userComposer).toBeInstanceOf(ObjectTypeComposer); 16 | expect(userComposer).toBe(UserTC); 17 | }); 18 | 19 | it('should throw error if first arg is not ObjectTypeComposer', () => { 20 | expect(() => { 21 | const wrongArgs = [123]; 22 | // @ts-expect-error 23 | composeWithPagination(...wrongArgs); 24 | }).toThrowError('should provide ObjectTypeComposer instance'); 25 | }); 26 | 27 | it('should throw error if options are empty', () => { 28 | expect(() => { 29 | const wrongArgs = [UserTC]; 30 | // @ts-expect-error 31 | composeWithPagination(...wrongArgs); 32 | }).toThrowError('should provide non-empty options'); 33 | }); 34 | 35 | it('should not change `pagination` resolver if it already exists', () => { 36 | let myTC = schemaComposer.createObjectTC('type Complex { a: String, b: Int }'); 37 | myTC.addResolver({ 38 | name: 'pagination', 39 | resolve: () => 'mockData', 40 | }); 41 | 42 | // try overwrite `pagination` resolver 43 | myTC = composeWithPagination(myTC, { 44 | countResolver, 45 | findManyResolver, 46 | }); 47 | 48 | expect(myTC.getResolver('pagination')).toBeTruthy(); 49 | expect(myTC.getResolver('pagination').resolve({})).toBe('mockData'); 50 | }); 51 | 52 | it('should add resolver with user-specified name', () => { 53 | let myTC = schemaComposer.createObjectTC('type CustomComplex { a: String, b: Int }'); 54 | myTC.addResolver({ 55 | name: 'count', 56 | resolve: () => 1, 57 | }); 58 | myTC.addResolver({ 59 | name: 'findMany', 60 | resolve: () => ['mockData'], 61 | }); 62 | myTC = composeWithPagination(myTC, { 63 | name: 'customPagination', 64 | countResolver, 65 | findManyResolver, 66 | }); 67 | 68 | expect(myTC.getResolver('customPagination')).toBeTruthy(); 69 | expect(myTC.hasResolver('pagination')).toBeFalsy(); 70 | }); 71 | 72 | it('should return different resolvers', () => { 73 | let myTC = schemaComposer.createObjectTC('type CustomComplex { a: String, b: Int }'); 74 | const myCountResolver = schemaComposer.createResolver({ 75 | name: 'count', 76 | resolve: () => 1, 77 | }); 78 | const myFindManyResolver = schemaComposer.createResolver({ 79 | name: 'findMany', 80 | resolve: () => ['mockData'], 81 | }); 82 | myTC = composeWithPagination(myTC, { 83 | countResolver: myCountResolver, 84 | findManyResolver: myFindManyResolver, 85 | }); 86 | myTC = composeWithPagination(myTC, { 87 | name: 'customPagination', 88 | countResolver: myCountResolver, 89 | findManyResolver: myFindManyResolver, 90 | }); 91 | 92 | expect(myTC.hasResolver('pagination')).toBeTruthy(); 93 | expect(myTC.getResolver('customPagination')).toBeTruthy(); 94 | }); 95 | }); 96 | 97 | describe('check `pagination` resolver props', () => { 98 | const rsv = userComposer.getResolver('pagination'); 99 | const type: any = rsv.getType(); 100 | const tc = schemaComposer.createObjectTC(type); 101 | 102 | it('should exists', () => { 103 | expect(rsv).toBeTruthy(); 104 | }); 105 | 106 | it('should has PaginationType as type', () => { 107 | expect(type).toBeTruthy(); 108 | expect(tc.getFieldNames()).toEqual(expect.arrayContaining(['count', 'pageInfo', 'items'])); 109 | expect(tc.getFieldType('items')).toBeInstanceOf(GraphQLList); 110 | }); 111 | }); 112 | 113 | describe('fragments fields projection of graphql-compose', () => { 114 | it('should return object', async () => { 115 | schemaComposer.Query.setField('userPagination', UserTC.getResolver('pagination')); 116 | const schema = schemaComposer.buildSchema(); 117 | const query = `{ 118 | userPagination(page: 1, perPage: 2, sort: ID_ASC) { 119 | count, 120 | pageInfo { 121 | currentPage 122 | perPage 123 | itemCount 124 | pageCount 125 | ...on PaginationInfo { 126 | hasPreviousPage 127 | hasNextPage 128 | } 129 | } 130 | items { 131 | id 132 | name 133 | ...idNameAge 134 | ...on User { 135 | age 136 | } 137 | } 138 | } 139 | } 140 | fragment idNameAge on User { 141 | gender 142 | } 143 | `; 144 | const result = await graphql(schema, query); 145 | expect(result).toEqual({ 146 | data: { 147 | userPagination: { 148 | count: 15, 149 | items: [ 150 | { age: 11, gender: 'm', id: 1, name: 'user01' }, 151 | { age: 12, gender: 'm', id: 2, name: 'user02' }, 152 | ], 153 | pageInfo: { 154 | currentPage: 1, 155 | hasNextPage: true, 156 | hasPreviousPage: false, 157 | itemCount: 15, 158 | pageCount: 8, 159 | perPage: 2, 160 | }, 161 | }, 162 | }, 163 | }); 164 | }); 165 | }); 166 | 167 | it('should pass `countResolveParams` to top resolverParams', async () => { 168 | // first build 169 | let topResolveParams: any = {}; 170 | schemaComposer.Query.setField( 171 | 'userPagination', 172 | UserTC.getResolver('pagination').wrapResolve((next) => (rp) => { 173 | const result = next(rp); 174 | topResolveParams = rp; 175 | return result; 176 | }) 177 | ); 178 | const schema = schemaComposer.buildSchema(); 179 | const query = `{ 180 | userPagination(filter: { age: 45 }) { 181 | count 182 | } 183 | }`; 184 | const res = await graphql(schema, query); 185 | expect(res).toEqual({ data: { userPagination: { count: 15 } } }); 186 | expect(Object.keys(topResolveParams.countResolveParams)).toEqual( 187 | expect.arrayContaining(['source', 'args', 'context', 'info', 'projection']) 188 | ); 189 | expect(topResolveParams.countResolveParams.args).toEqual({ 190 | filter: { age: 45 }, 191 | perPage: 5, 192 | }); 193 | 194 | // second build 195 | let topResolveParams2: any = {}; 196 | schemaComposer.Query.setField( 197 | 'userPagination', 198 | UserTC.getResolver('pagination').wrapResolve((next) => (rp) => { 199 | const result = next(rp); 200 | topResolveParams2 = rp; 201 | return result; 202 | }) 203 | ); 204 | 205 | const schema2 = schemaComposer.buildSchema(); 206 | const query2 = `{ 207 | userPagination(filter: { age: 333 }) { 208 | count 209 | } 210 | }`; 211 | const res2 = await graphql(schema2, query2); 212 | expect(res2).toEqual({ data: { userPagination: { count: 15 } } }); 213 | expect(Object.keys(topResolveParams2.countResolveParams)).toEqual( 214 | expect.arrayContaining(['source', 'args', 'context', 'info', 'projection']) 215 | ); 216 | expect(topResolveParams2.countResolveParams.args).toEqual({ 217 | filter: { age: 333 }, 218 | perPage: 5, 219 | }); 220 | }); 221 | 222 | it('should pass `findManyResolveParams` to top resolverParams', async () => { 223 | let topResolveParams: any = {}; 224 | 225 | schemaComposer.Query.setField( 226 | 'userPagination', 227 | UserTC.getResolver('pagination').wrapResolve((next) => (rp) => { 228 | const result = next(rp); 229 | topResolveParams = rp; 230 | return result; 231 | }) 232 | ); 233 | const schema = schemaComposer.buildSchema(); 234 | const query = `{ 235 | userPagination(filter: { age: 55 }) { 236 | count 237 | } 238 | }`; 239 | const res = await graphql(schema, query); 240 | expect(res).toEqual({ data: { userPagination: { count: 15 } } }); 241 | 242 | expect(Object.keys(topResolveParams.findManyResolveParams)).toEqual( 243 | expect.arrayContaining(['source', 'args', 'context', 'info', 'projection']) 244 | ); 245 | 246 | expect(topResolveParams.findManyResolveParams.args).toEqual({ 247 | filter: { age: 55 }, 248 | limit: 6, 249 | perPage: 5, 250 | }); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /src/__tests__/mocks-userTypeComposer-test.ts: -------------------------------------------------------------------------------- 1 | import { countResolver, findManyResolver } from '../__mocks__/User'; 2 | 3 | describe('mocks/UserTC', () => { 4 | it('UserTC should have `countResolver`', async () => { 5 | const cnt = await countResolver.resolve({}); 6 | expect(cnt).toBe(15); 7 | }); 8 | 9 | it('UserTC should have `findManyResolver`', async () => { 10 | const res = await findManyResolver.resolve({}); 11 | expect(res).toHaveLength(15); 12 | }); 13 | 14 | it('UserTC should have `findManyResolver` with working `filter` arg', async () => { 15 | const res = await findManyResolver.resolve({ 16 | args: { 17 | filter: { 18 | gender: 'm', 19 | }, 20 | }, 21 | rawQuery: { 22 | age: { 23 | $gt: 15, 24 | $lt: 20, 25 | }, 26 | id: { 27 | $gt: 8, 28 | $lt: 20, 29 | }, 30 | }, 31 | }); 32 | expect(res).toEqual([{ id: 9, name: 'user09', age: 19, gender: 'm' }]); 33 | }); 34 | 35 | it('UserTC should have `findManyResolver` with working `sort` arg', async () => { 36 | const res = await findManyResolver.resolve({ 37 | args: { 38 | sort: { 39 | age: -1, 40 | id: -1, 41 | }, 42 | limit: 5, 43 | }, 44 | }); 45 | expect(res).toEqual([ 46 | { id: 11, name: 'user11', age: 49, gender: 'm' }, 47 | { id: 10, name: 'user10', age: 49, gender: 'f' }, 48 | { id: 12, name: 'user12', age: 47, gender: 'f' }, 49 | { id: 15, name: 'user15', age: 45, gender: 'm' }, 50 | { id: 14, name: 'user14', age: 45, gender: 'm' }, 51 | ]); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/__tests__/pagination-test.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, ResolverResolveParams } from 'graphql-compose'; 2 | import { GraphQLInt } from 'graphql-compose/lib/graphql'; 3 | import { UserTC, countResolver, findManyResolver } from '../__mocks__/User'; 4 | import { preparePaginationResolver } from '../pagination'; 5 | 6 | describe('preparePaginationResolver()', () => { 7 | const spyFindManyResolve = jest.spyOn(findManyResolver, 'resolve'); 8 | const spyCountResolve = jest.spyOn(countResolver, 'resolve'); 9 | const paginationResolver = preparePaginationResolver(UserTC, { 10 | countResolver, 11 | findManyResolver, 12 | perPage: 5, 13 | }); 14 | 15 | describe('definition checks', () => { 16 | it('should return Resolver', () => { 17 | expect(paginationResolver).toBeInstanceOf(Resolver); 18 | }); 19 | 20 | it('should throw error if first arg is not ObjectTypeComposer', () => { 21 | expect(() => { 22 | const wrongArgs = [123]; 23 | // @ts-expect-error 24 | preparePaginationResolver(...wrongArgs); 25 | }).toThrowError('should be instance of ObjectTypeComposer'); 26 | }); 27 | 28 | it('should throw error if opts.countResolverName are empty or wrong', () => { 29 | expect(() => { 30 | const wrongArgs = [UserTC, {}]; 31 | // @ts-expect-error 32 | preparePaginationResolver(...wrongArgs); 33 | }).toThrowError("'opts.countResolver' must be a Resolver instance"); 34 | 35 | expect(() => 36 | preparePaginationResolver(UserTC, { 37 | // @ts-expect-error 38 | countResolver: 'countDoesNotExists', 39 | findManyResolver, 40 | }) 41 | ).toThrowError("'opts.countResolver' must be a Resolver instance"); 42 | }); 43 | 44 | it('should throw error if opts.findManyResolver are empty or wrong', () => { 45 | expect(() => { 46 | const wrongArgs = [UserTC, { countResolverName: 'count' }]; 47 | // @ts-expect-error 48 | preparePaginationResolver(...wrongArgs); 49 | }).toThrowError("'opts.countResolver' must be a Resolver instance"); 50 | 51 | expect(() => 52 | preparePaginationResolver(UserTC, { 53 | countResolver, 54 | // @ts-expect-error 55 | findManyResolver: 'findManyDoesNotExists', 56 | }) 57 | ).toThrowError("'opts.findManyResolver' must be a Resolver instance"); 58 | }); 59 | 60 | it('should return a separate resolver with different type', () => { 61 | const anotherPaginationResolver = preparePaginationResolver(UserTC, { 62 | countResolver, 63 | findManyResolver, 64 | name: 'otherPagination', 65 | }); 66 | expect(anotherPaginationResolver.getTypeName()).toBe('UserOtherPagination'); 67 | }); 68 | }); 69 | 70 | describe('resolver basic properties', () => { 71 | it('should have name `pagination`', () => { 72 | expect(paginationResolver.name).toBe('pagination'); 73 | }); 74 | 75 | it('should have kind `query`', () => { 76 | expect(paginationResolver.kind).toBe('query'); 77 | }); 78 | 79 | it('should have type to be ConnectionType', () => { 80 | expect(paginationResolver.getTypeName()).toBe('UserPagination'); 81 | }); 82 | }); 83 | 84 | describe('resolver args', () => { 85 | it('should have `page` arg', () => { 86 | expect(paginationResolver.getArgType('page')).toBe(GraphQLInt); 87 | }); 88 | 89 | it('should have `perPage` arg', () => { 90 | expect(paginationResolver.getArgType('perPage')).toBe(GraphQLInt); 91 | }); 92 | }); 93 | 94 | describe('call of resolvers', () => { 95 | let spyResolveParams: ResolverResolveParams; 96 | let mockedPaginationResolver: Resolver; 97 | let findManyResolverCalled: boolean; 98 | let countResolverCalled: boolean; 99 | 100 | beforeEach(() => { 101 | findManyResolverCalled = false; 102 | countResolverCalled = false; 103 | const mockedFindMany = findManyResolver.wrapResolve((next) => (resolveParams) => { 104 | findManyResolverCalled = true; 105 | spyResolveParams = resolveParams; 106 | return next(resolveParams); 107 | }); 108 | const mockedCount = countResolver.wrapResolve((next) => (resolveParams) => { 109 | countResolverCalled = true; 110 | spyResolveParams = resolveParams; 111 | return next(resolveParams); 112 | }); 113 | mockedPaginationResolver = preparePaginationResolver(UserTC, { 114 | countResolver: mockedCount, 115 | findManyResolver: mockedFindMany, 116 | }); 117 | }); 118 | 119 | it('should pass to findMany args.sort', async () => { 120 | await mockedPaginationResolver.resolve({ 121 | args: { 122 | sort: { name: 1 }, 123 | first: 3, 124 | }, 125 | projection: { 126 | items: true, 127 | }, 128 | }); 129 | expect(spyResolveParams.args.sort.name).toBe(1); 130 | }); 131 | 132 | it('should pass to findMany projection from `items` on top level', async () => { 133 | await mockedPaginationResolver.resolve({ 134 | args: {}, 135 | projection: { 136 | items: { 137 | name: true, 138 | age: true, 139 | }, 140 | }, 141 | }); 142 | expect(spyResolveParams.projection.name).toBe(true); 143 | expect(spyResolveParams.projection.age).toBe(true); 144 | }); 145 | 146 | it('should pass to findMany custom projections to top level', async () => { 147 | await mockedPaginationResolver.resolve({ 148 | args: {}, 149 | projection: { 150 | items: true, 151 | score: { $meta: 'textScore' }, 152 | }, 153 | }); 154 | expect(spyResolveParams.projection.score).toEqual({ $meta: 'textScore' }); 155 | }); 156 | 157 | it('should call count but not findMany when only count is projected', async () => { 158 | await mockedPaginationResolver.resolve({ 159 | args: {}, 160 | projection: { 161 | count: true, 162 | }, 163 | }); 164 | expect(countResolverCalled).toBe(true); 165 | expect(findManyResolverCalled).toBe(false); 166 | }); 167 | 168 | it('should call count but not findMany when only pageInfo.itemCount is projected', async () => { 169 | await mockedPaginationResolver.resolve({ 170 | args: {}, 171 | projection: { 172 | pageInfo: { 173 | itemCount: true, 174 | }, 175 | }, 176 | }); 177 | expect(countResolverCalled).toBe(true); 178 | expect(findManyResolverCalled).toBe(false); 179 | }); 180 | 181 | it('should call count but not findMany when only pageInfo.pageCount is projected', async () => { 182 | await mockedPaginationResolver.resolve({ 183 | args: {}, 184 | projection: { 185 | pageInfo: { 186 | itemCount: true, 187 | }, 188 | }, 189 | }); 190 | expect(countResolverCalled).toBe(true); 191 | expect(findManyResolverCalled).toBe(false); 192 | }); 193 | 194 | it('should call count and findMany resolver when count and items is projected', async () => { 195 | await mockedPaginationResolver.resolve({ 196 | args: {}, 197 | projection: { 198 | count: true, 199 | items: { 200 | name: true, 201 | age: true, 202 | }, 203 | }, 204 | }); 205 | expect(countResolverCalled).toBe(true); 206 | expect(findManyResolverCalled).toBe(true); 207 | }); 208 | 209 | it('should call findMany and not count when arbitrary top level fields are projected without count', async () => { 210 | await mockedPaginationResolver.resolve({ 211 | args: {}, 212 | projection: { 213 | name: true, 214 | age: true, 215 | }, 216 | }); 217 | expect(countResolverCalled).toBe(false); 218 | expect(findManyResolverCalled).toBe(true); 219 | }); 220 | 221 | it('should call findMany and count when arbitrary top level fields are projected with count', async () => { 222 | await mockedPaginationResolver.resolve({ 223 | args: {}, 224 | projection: { 225 | count: true, 226 | name: true, 227 | age: true, 228 | }, 229 | }); 230 | expect(countResolverCalled).toBe(true); 231 | expect(findManyResolverCalled).toBe(true); 232 | }); 233 | 234 | it('should call findMany but not count resolver when first arg is used', async () => { 235 | await mockedPaginationResolver.resolve({ 236 | args: { first: 1 }, 237 | projection: { 238 | edges: { 239 | node: { 240 | name: true, 241 | age: true, 242 | }, 243 | }, 244 | }, 245 | }); 246 | expect(countResolverCalled).toBe(false); 247 | expect(findManyResolverCalled).toBe(true); 248 | }); 249 | }); 250 | 251 | describe('filter tests with resolve', () => { 252 | it('should pass `filter` arg to `findMany` and `count` resolvers', async () => { 253 | spyFindManyResolve.mockClear(); 254 | spyCountResolve.mockClear(); 255 | await paginationResolver.resolve({ 256 | args: { 257 | filter: { 258 | gender: 'm', 259 | }, 260 | }, 261 | projection: { 262 | count: true, 263 | items: { 264 | name: true, 265 | }, 266 | }, 267 | }); 268 | expect(spyFindManyResolve.mock.calls).toEqual([ 269 | [ 270 | { 271 | args: { filter: { gender: 'm' }, limit: 6 }, 272 | projection: { count: true, items: { name: true }, name: true }, 273 | }, 274 | ], 275 | ]); 276 | expect(spyCountResolve.mock.calls).toEqual([ 277 | [ 278 | { 279 | args: { filter: { gender: 'm' } }, 280 | projection: { count: true, items: { name: true } }, 281 | rawQuery: undefined, 282 | }, 283 | ], 284 | ]); 285 | }); 286 | 287 | it('should add additional filtering', async () => { 288 | const result = await paginationResolver.resolve({ 289 | args: { 290 | filter: { 291 | gender: 'm', 292 | }, 293 | sort: { id: 1 }, 294 | }, 295 | projection: { 296 | count: true, 297 | items: { 298 | name: true, 299 | }, 300 | }, 301 | }); 302 | expect(result.items).toHaveLength(5); 303 | expect(result.items[0]).toEqual({ 304 | id: 1, 305 | name: 'user01', 306 | age: 11, 307 | gender: 'm', 308 | }); 309 | expect(result.items[4]).toEqual({ 310 | id: 9, 311 | name: 'user09', 312 | age: 19, 313 | gender: 'm', 314 | }); 315 | expect(result.count).toBe(8); 316 | }); 317 | }); 318 | 319 | describe('sort tests with resolve', () => { 320 | it('should pass `sort` arg to `findMany` but not to `count` resolvers', async () => { 321 | spyFindManyResolve.mockClear(); 322 | spyCountResolve.mockClear(); 323 | await paginationResolver.resolve({ 324 | args: { 325 | sort: { _id: 1 }, 326 | }, 327 | projection: { 328 | count: true, 329 | items: { 330 | name: true, 331 | }, 332 | }, 333 | }); 334 | expect(spyFindManyResolve.mock.calls).toEqual([ 335 | [ 336 | { 337 | args: { limit: 6, sort: { _id: 1 } }, 338 | projection: { count: true, items: { name: true }, name: true }, 339 | }, 340 | ], 341 | ]); 342 | expect(spyCountResolve.mock.calls).toEqual([ 343 | [ 344 | { 345 | args: { 346 | filter: {}, 347 | sort: { 348 | _id: 1, 349 | }, 350 | }, 351 | projection: { count: true, items: { name: true } }, 352 | rawQuery: undefined, 353 | }, 354 | ], 355 | ]); 356 | }); 357 | }); 358 | 359 | describe('resolver payload', () => { 360 | it('should have correct pageInfo for first page', async () => { 361 | const result = await paginationResolver.resolve({ 362 | args: {}, 363 | projection: { 364 | pageInfo: { 365 | currentPage: true, 366 | perPage: true, 367 | itemCount: true, 368 | pageCount: true, 369 | hasPreviousPage: true, 370 | hasNextPage: true, 371 | }, 372 | }, 373 | }); 374 | 375 | expect(result.pageInfo).toEqual({ 376 | currentPage: 1, 377 | hasNextPage: true, 378 | hasPreviousPage: false, 379 | itemCount: 15, 380 | pageCount: 3, 381 | perPage: 5, 382 | }); 383 | }); 384 | 385 | it('should have correct pageInfo for last page', async () => { 386 | const result = await paginationResolver.resolve({ 387 | args: { page: 3 }, 388 | projection: { 389 | pageInfo: { 390 | currentPage: true, 391 | perPage: true, 392 | itemCount: true, 393 | pageCount: true, 394 | hasPreviousPage: true, 395 | hasNextPage: true, 396 | }, 397 | }, 398 | }); 399 | 400 | expect(result.pageInfo).toEqual({ 401 | currentPage: 3, 402 | hasNextPage: false, 403 | hasPreviousPage: true, 404 | itemCount: 15, 405 | pageCount: 3, 406 | perPage: 5, 407 | }); 408 | }); 409 | }); 410 | }); 411 | -------------------------------------------------------------------------------- /src/__tests__/types-test.ts: -------------------------------------------------------------------------------- 1 | import { ObjectTypeComposer } from 'graphql-compose'; 2 | import { GraphQLNonNull, getNamedType, GraphQLInt, GraphQLList } from 'graphql-compose/lib/graphql'; 3 | import { UserTC } from '../__mocks__/User'; 4 | import { preparePaginationTC, preparePaginationInfoTC } from '../types'; 5 | 6 | describe('preparePaginationTC()', () => { 7 | it('should return ObjectTypeComposer', () => { 8 | expect(preparePaginationTC(UserTC)).toBeInstanceOf(ObjectTypeComposer); 9 | }); 10 | 11 | it('should return the same Type object when called again', () => { 12 | const firstPaginationType = preparePaginationTC(UserTC); 13 | const secondPaginationType = preparePaginationTC(UserTC); 14 | expect(firstPaginationType).toBe(secondPaginationType); 15 | }); 16 | 17 | it('should return a separate GraphQLObjectType with a different name', () => { 18 | const paginationType = preparePaginationTC(UserTC); 19 | const otherPaginationType = preparePaginationTC(UserTC, 'otherPagination'); 20 | expect(paginationType).not.toBe(otherPaginationType); 21 | }); 22 | 23 | it('should have name ending with `Pagination`', () => { 24 | expect(preparePaginationTC(UserTC).getTypeName()).toBe('UserPagination'); 25 | }); 26 | 27 | it('should have name ending with `OtherPagination` when passed lowercase otherPagination', () => { 28 | expect(preparePaginationTC(UserTC, 'otherConnection').getTypeName()).toBe( 29 | 'UserOtherConnection' 30 | ); 31 | }); 32 | 33 | it('should have field `count` with provided Type', () => { 34 | const tc = preparePaginationTC(UserTC); 35 | expect(tc.getFieldType('count')).toBe(GraphQLInt); 36 | }); 37 | 38 | it('should have field `pageInfo` with GraphQLNonNull(PaginationInfoType)', () => { 39 | const PaginationInfoTC = preparePaginationInfoTC(UserTC.schemaComposer); 40 | const tc = preparePaginationTC(UserTC); 41 | expect(tc.getFieldType('pageInfo')).toBeInstanceOf(GraphQLNonNull); 42 | 43 | const pageInfo = getNamedType(tc.getFieldType('pageInfo')); 44 | expect(pageInfo).toBe(PaginationInfoTC.getType()); 45 | }); 46 | 47 | it('should have field `items` with GraphQLList(EdgeType)', () => { 48 | const tc = preparePaginationTC(UserTC); 49 | expect(tc.getFieldType('items')).toBeInstanceOf(GraphQLList); 50 | 51 | const items: any = getNamedType(tc.getFieldType('items')); 52 | expect(items.name).toEqual('User'); 53 | }); 54 | 55 | it('should return same type for same Type in ObjectTypeComposer', () => { 56 | const t1 = preparePaginationTC(UserTC); 57 | const t2 = preparePaginationTC(UserTC); 58 | expect(t1).toEqual(t2); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/composeWithPagination.ts: -------------------------------------------------------------------------------- 1 | import { ObjectTypeComposer } from 'graphql-compose'; 2 | import { 3 | preparePaginationResolver, 4 | PaginationResolverOpts, 5 | DEFAULT_RESOLVER_NAME, 6 | } from './pagination'; 7 | 8 | /** 9 | * @deprecated use `preparePaginationResolver()` instead 10 | */ 11 | export function composeWithPagination( 12 | typeComposer: ObjectTypeComposer, 13 | opts: PaginationResolverOpts 14 | ): ObjectTypeComposer { 15 | if (!typeComposer || typeComposer.constructor.name !== 'ObjectTypeComposer') { 16 | throw new Error( 17 | 'You should provide ObjectTypeComposer instance to composeWithPagination method' 18 | ); 19 | } 20 | 21 | if (!opts) { 22 | throw new Error('You should provide non-empty options to composeWithPagination'); 23 | } 24 | 25 | const resolverName = opts.name || DEFAULT_RESOLVER_NAME; 26 | if (typeComposer.hasResolver(resolverName)) { 27 | return typeComposer; 28 | } 29 | const resolver = preparePaginationResolver(typeComposer, opts); 30 | typeComposer.setResolver(resolverName, resolver); 31 | 32 | return typeComposer; 33 | } 34 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { preparePaginationResolver } from './pagination'; 2 | import { composeWithPagination } from './composeWithPagination'; 3 | 4 | export { composeWithPagination, preparePaginationResolver }; 5 | 6 | export type { 7 | PaginationResolverOpts, 8 | PaginationTArgs, 9 | PaginationType, 10 | PaginationInfoType, 11 | } from './pagination'; 12 | -------------------------------------------------------------------------------- /src/pagination.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, inspect } from 'graphql-compose'; 2 | import type { 3 | ObjectTypeComposer, 4 | InterfaceTypeComposer, 5 | UnionTypeComposer, 6 | ScalarTypeComposer, 7 | EnumTypeComposer, 8 | ResolverResolveParams, 9 | ObjectTypeComposerArgumentConfigMap, 10 | } from 'graphql-compose'; 11 | import { preparePaginationTC } from './types'; 12 | 13 | export const DEFAULT_RESOLVER_NAME = 'pagination'; 14 | export const DEFAULT_PER_PAGE = 20; 15 | const ALLOWED_TYPE_COMPOSERS = [ 16 | 'ObjectTypeComposer', 17 | 'InterfaceTypeComposer', 18 | 'UnionTypeComposer', 19 | 'ScalarTypeComposer', 20 | 'EnumTypeComposer', 21 | ]; 22 | 23 | export type PaginationResolverOpts = { 24 | findManyResolver: Resolver; 25 | countResolver: Resolver; 26 | name?: string; 27 | perPage?: number; 28 | }; 29 | 30 | export type PaginationType = { 31 | count: number; 32 | items: any[]; 33 | pageInfo: PaginationInfoType; 34 | }; 35 | 36 | export type PaginationInfoType = { 37 | currentPage: number; 38 | perPage: number; 39 | itemCount: number; 40 | pageCount: number; 41 | hasPreviousPage: boolean; 42 | hasNextPage: boolean; 43 | }; 44 | 45 | export interface PaginationTArgs { 46 | page?: number; 47 | perPage?: number; 48 | filter?: any; 49 | sort?: any; 50 | } 51 | 52 | export function preparePaginationResolver( 53 | tc: 54 | | ObjectTypeComposer 55 | | InterfaceTypeComposer 56 | | UnionTypeComposer 57 | | ScalarTypeComposer 58 | | EnumTypeComposer, 59 | opts: PaginationResolverOpts 60 | ): Resolver { 61 | if (!tc || !ALLOWED_TYPE_COMPOSERS.includes(tc.constructor.name)) { 62 | throw new Error( 63 | `First arg for preparePaginationResolver() should be instance of ${ALLOWED_TYPE_COMPOSERS.join( 64 | ' or ' 65 | )}` 66 | ); 67 | } 68 | 69 | const resolverName = opts.name || DEFAULT_RESOLVER_NAME; 70 | 71 | if (!opts.countResolver || !(opts.countResolver instanceof Resolver)) { 72 | throw new Error( 73 | `Option 'opts.countResolver' must be a Resolver instance. Received ${inspect( 74 | opts.countResolver 75 | )}` 76 | ); 77 | } 78 | 79 | const countResolve = opts.countResolver.getResolve(); 80 | 81 | if (!opts.findManyResolver || !(opts.findManyResolver instanceof Resolver)) { 82 | throw new Error( 83 | `Option 'opts.findManyResolver' must be a Resolver instance. Received ${inspect( 84 | opts.findManyResolver 85 | )}` 86 | ); 87 | } 88 | const findManyResolver = opts.findManyResolver; 89 | const findManyResolve = findManyResolver.getResolve(); 90 | 91 | const additionalArgs: ObjectTypeComposerArgumentConfigMap = {}; 92 | if (findManyResolver.hasArg('filter')) { 93 | const filter = findManyResolver.getArg('filter'); 94 | if (filter) { 95 | additionalArgs.filter = filter; 96 | } 97 | } 98 | if (findManyResolver.hasArg('sort')) { 99 | const sort = findManyResolver.getArg('sort'); 100 | if (sort) { 101 | additionalArgs.sort = sort; 102 | } 103 | } 104 | 105 | return tc.schemaComposer.createResolver({ 106 | type: preparePaginationTC(tc, resolverName), 107 | name: resolverName, 108 | kind: 'query', 109 | args: { 110 | page: { 111 | type: 'Int', 112 | description: 'Page number for displaying', 113 | }, 114 | perPage: { 115 | type: 'Int', 116 | description: '', 117 | defaultValue: opts.perPage || DEFAULT_PER_PAGE, 118 | }, 119 | ...(additionalArgs as any), 120 | }, 121 | resolve: async (rp: ResolverResolveParams) => { 122 | let countPromise; 123 | let findManyPromise; 124 | const { projection = {}, args, rawQuery } = rp; 125 | 126 | const page = parseInt(args.page as any, 10) || 1; 127 | if (page <= 0) { 128 | throw new Error('Argument `page` should be positive number.'); 129 | } 130 | const perPage = parseInt(args.perPage as any, 10) || opts.perPage || DEFAULT_PER_PAGE; 131 | if (perPage <= 0) { 132 | throw new Error('Argument `perPage` should be positive number.'); 133 | } 134 | 135 | const countParams: ResolverResolveParams = { 136 | ...rp, 137 | rawQuery, 138 | args: { 139 | ...rp.args, 140 | filter: { ...rp.args.filter }, 141 | }, 142 | }; 143 | 144 | if ( 145 | projection.count || 146 | (projection.pageInfo && (projection.pageInfo.itemCount || projection.pageInfo.pageCount)) 147 | ) { 148 | countPromise = countResolve(countParams); 149 | } else { 150 | countPromise = Promise.resolve(0); 151 | } 152 | 153 | const findManyParams: ResolverResolveParams = { 154 | ...rp, 155 | }; 156 | 157 | if (projection && projection.items) { 158 | // combine top level projection 159 | // (maybe somebody add additional fields via rp.projection) 160 | // and items (record needed fields) 161 | findManyParams.projection = { ...projection, ...projection.items }; 162 | } else { 163 | findManyParams.projection = { ...projection }; 164 | } 165 | 166 | const limit = perPage; 167 | const skip = (page - 1) * perPage; 168 | 169 | findManyParams.args.limit = limit + 1; // +1 document, to check next page presence 170 | if (skip > 0) { 171 | findManyParams.args.skip = skip; 172 | } 173 | 174 | // pass findMany ResolveParams to top resolver 175 | rp.findManyResolveParams = findManyParams; 176 | rp.countResolveParams = countParams; 177 | 178 | // This allows to optimize and not actually call the findMany resolver 179 | // if only the count is projected 180 | if ((projection.count || projection.pageInfo) && Object.keys(projection).length === 1) { 181 | findManyPromise = Promise.resolve([]); 182 | } else { 183 | findManyPromise = findManyResolve(findManyParams); 184 | } 185 | 186 | return Promise.all([findManyPromise, countPromise]).then(([items, count]) => { 187 | const result: PaginationType = { 188 | count, 189 | items: items.length > limit ? items.slice(0, limit) : items, 190 | pageInfo: { 191 | currentPage: page, 192 | perPage, 193 | itemCount: count, 194 | pageCount: Math.ceil(count / perPage), 195 | hasPreviousPage: page > 1, 196 | hasNextPage: items.length > limit || page * perPage < count, 197 | }, 198 | }; 199 | return result; 200 | }); 201 | }, 202 | }); 203 | } 204 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | upperFirst, 3 | ObjectTypeComposer, 4 | SchemaComposer, 5 | InterfaceTypeComposer, 6 | UnionTypeComposer, 7 | ScalarTypeComposer, 8 | EnumTypeComposer, 9 | } from 'graphql-compose'; 10 | 11 | // PaginationInfo should be global 12 | const PaginationInfoTC = ObjectTypeComposer.createTemp(` 13 | # Information about pagination. 14 | type PaginationInfo { 15 | # Current page number 16 | currentPage: Int! 17 | 18 | # Number of items per page 19 | perPage: Int! 20 | 21 | # Total number of pages 22 | pageCount: Int 23 | 24 | # Total number of items 25 | itemCount: Int 26 | 27 | # When paginating forwards, are there more items? 28 | hasNextPage: Boolean 29 | 30 | # When paginating backwards, are there more items? 31 | hasPreviousPage: Boolean 32 | } 33 | `); 34 | 35 | export function preparePaginationInfoTC( 36 | sc: SchemaComposer 37 | ): ObjectTypeComposer { 38 | // Pagination Info can be overrided via SchemaComposer registry 39 | if (sc.has('PaginationInfo')) { 40 | return sc.getOTC('PaginationInfo'); 41 | } 42 | sc.set('PaginationInfo', PaginationInfoTC); 43 | return PaginationInfoTC; 44 | } 45 | 46 | export function preparePaginationTC( 47 | tc: 48 | | ObjectTypeComposer 49 | | InterfaceTypeComposer 50 | | UnionTypeComposer 51 | | ScalarTypeComposer 52 | | EnumTypeComposer, 53 | resolverName?: string 54 | ): ObjectTypeComposer { 55 | const schemaComposer = tc.schemaComposer; 56 | const name = `${tc.getTypeName()}${upperFirst(resolverName || 'pagination')}`; 57 | 58 | if (schemaComposer.has(name)) { 59 | return schemaComposer.getOTC(name); 60 | } 61 | 62 | const paginationTC = schemaComposer.createObjectTC({ 63 | name, 64 | description: 'List of items with pagination.', 65 | fields: { 66 | count: { 67 | type: 'Int', 68 | description: 'Total object count.', 69 | }, 70 | items: { 71 | type: () => tc.NonNull.List, 72 | description: 'Array of objects.', 73 | }, 74 | pageInfo: { 75 | type: preparePaginationInfoTC(schemaComposer).NonNull, 76 | description: 'Information to aid in pagination.', 77 | }, 78 | }, 79 | }); 80 | 81 | return paginationTC; 82 | } 83 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "outDir": "./lib", 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__", "**/__mocks__"] 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "sourceMap": true, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "removeComments": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUnusedParameters": true, 16 | "noUnusedLocals": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "lib": ["es2017", "esnext.asynciterable"], 19 | "types": ["node", "jest"], 20 | "baseUrl": ".", 21 | "paths": { 22 | "*" : ["types/*"] 23 | }, 24 | "rootDir": "./src", 25 | }, 26 | "include": ["src/**/*"], 27 | "exclude": [ 28 | "./node_modules" 29 | ] 30 | } 31 | --------------------------------------------------------------------------------