├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── connectiontypes.js ├── defineConnection.js ├── index.js ├── mongooseConnection.js └── util.js └── test ├── defineConnection.js ├── mocha.opts ├── mongoose.js ├── number.js └── setup.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": ["transform-runtime", "syntax-flow", "transform-flow-strip-types"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true, 5 | "es6": true 6 | }, 7 | "globals": { 8 | "__SERVER__": true, 9 | "__CLIENT__": true, 10 | "__TEST__": true, 11 | "__DEV__": true, 12 | "__PROD__": true, 13 | "__STAGING__": true 14 | }, 15 | "ecmaFeatures": { 16 | "arrowFunctions": true, 17 | "binaryLiterals": true, 18 | "blockBindings": true, 19 | "classes": true, 20 | "defaultParams": true, 21 | "destructuring": true, 22 | "forOf": true, 23 | "generators": true, 24 | "modules": true, 25 | "objectLiteralComputedProperties": true, 26 | "objectLiteralShorthandMethods": true, 27 | "objectLiteralShorthandProperties": true, 28 | "octalLiterals": true, 29 | "regexUFlag": true, 30 | "regexYFlag": true, 31 | "spread": true, 32 | "superInFunctions": true, 33 | "templateStrings": true, 34 | "unicodeCodePointEscapes": true, 35 | "jsx": true 36 | }, 37 | "parser": "babel-eslint", 38 | "rules": { 39 | "prefer-reflect": [ 40 | 0, 41 | { 42 | "exceptions": [ 43 | "apply", 44 | "call", 45 | "delete" 46 | ] 47 | } 48 | ], 49 | "babel/new-cap": 2, 50 | "no-return-assign": 2, 51 | "no-invalid-this": 0, 52 | "no-void": 2, 53 | "one-var": [2, "never"], 54 | "react/jsx-closing-bracket-location": 0, 55 | "no-undef": 2, 56 | "max-nested-callbacks": [ 57 | 2, 58 | 3 59 | ], 60 | "no-empty": 2, 61 | "no-loop-func": 2, 62 | "keyword-spacing": 2, 63 | "babel/object-shorthand": [ 64 | 2, 65 | "always" 66 | ], 67 | "wrap-iife": [ 68 | 2, 69 | "inside" 70 | ], 71 | "valid-typeof": 2, 72 | "react/jsx-no-literals": 2, 73 | "handle-callback-err": 2, 74 | "operator-linebreak": [2, "after"], 75 | "no-label-var": 2, 76 | "no-process-env": 2, 77 | "no-irregular-whitespace": 2, 78 | "block-spacing": 2, 79 | "padded-blocks": [ 80 | 2, 81 | "never" 82 | ], 83 | "react/jsx-pascal-case": 2, 84 | "no-empty-pattern": 2, 85 | "radix": 2, 86 | "no-undefined": 0, 87 | "semi-spacing": 2, 88 | "eqeqeq": [ 89 | 2, 90 | "allow-null" 91 | ], 92 | "no-negated-condition": 2, 93 | "require-yield": 2, 94 | "new-cap": 2, 95 | "no-const-assign": 2, 96 | "no-bitwise": 2, 97 | "dot-notation": 2, 98 | "camelcase": 2, 99 | "prefer-const": 2, 100 | "no-negated-in-lhs": 2, 101 | "prefer-arrow-callback": 2, 102 | "no-extra-bind": 2, 103 | "react/prefer-es6-class": 2, 104 | "no-sequences": 2, 105 | "babel/generator-star-spacing": 2, 106 | "comma-dangle": [ 107 | 2, 108 | "always-multiline" 109 | ], 110 | "no-spaced-func": 2, 111 | "react/require-extension": 2, 112 | "no-labels": 2, 113 | "no-unreachable": 2, 114 | "no-eval": 2, 115 | "react/no-did-mount-set-state": 2, 116 | "no-unneeded-ternary": 2, 117 | "no-process-exit": 2, 118 | "no-empty-character-class": 2, 119 | "constructor-super": 2, 120 | "no-dupe-class-members": 2, 121 | "strict": [ 122 | 2, 123 | "never" 124 | ], 125 | "no-case-declarations": 2, 126 | "array-bracket-spacing": 2, 127 | "react/no-set-state": 2, 128 | "block-scoped-var": 2, 129 | "arrow-body-style": 2, 130 | "space-in-parens": 2, 131 | "no-confusing-arrow": 2, 132 | "no-control-regex": 2, 133 | "consistent-return": 2, 134 | "no-console": 2, 135 | "comma-spacing": 2, 136 | "no-redeclare": 2, 137 | "computed-property-spacing": 2, 138 | "no-invalid-regexp": 2, 139 | "use-isnan": 2, 140 | "no-new-require": 2, 141 | "indent": [ 142 | 2, 143 | 2 144 | ], 145 | "react/react-in-jsx-scope": 2, 146 | "no-native-reassign": 2, 147 | "no-func-assign": 2, 148 | "max-len": [ 149 | 2, 150 | 120, 151 | 4, 152 | { 153 | "ignoreUrls": true 154 | } 155 | ], 156 | "no-shadow": [ 157 | 2, 158 | { 159 | "builtinGlobals": true 160 | } 161 | ], 162 | "no-mixed-requires": 2, 163 | "react/no-did-update-set-state": 2, 164 | "react/jsx-uses-react": 2, 165 | "max-statements": [ 166 | 2, 167 | 20 168 | ], 169 | "space-unary-ops": [ 170 | 2, 171 | { 172 | "words": true, 173 | "nonwords": false 174 | } 175 | ], 176 | "no-lone-blocks": 2, 177 | "no-debugger": 2, 178 | "arrow-parens": [ 179 | 2, 180 | "always" 181 | ], 182 | "space-before-blocks": [ 183 | 2, 184 | "always" 185 | ], 186 | "no-implied-eval": 2, 187 | "no-useless-concat": 2, 188 | "no-multi-spaces": 2, 189 | "curly": [2, "multi-line"], 190 | "no-extra-boolean-cast": 2, 191 | "space-infix-ops": 2, 192 | "babel/no-await-in-loop": 2, 193 | "react/sort-comp": 2, 194 | "react/jsx-no-undef": 2, 195 | "no-multiple-empty-lines": [ 196 | 2, 197 | { 198 | "max": 2 199 | } 200 | ], 201 | "semi": 2, 202 | "no-param-reassign": 0, 203 | "no-cond-assign": 2, 204 | "no-dupe-keys": 2, 205 | "import/named": 0, 206 | "max-params": [ 207 | 2, 208 | 4 209 | ], 210 | "linebreak-style": 2, 211 | "react/jsx-sort-props": [ 212 | 0, 213 | { 214 | "shorthandFirst": true, 215 | "callbacksLast": true 216 | } 217 | ], 218 | "no-octal-escape": 2, 219 | "no-this-before-super": 2, 220 | "no-alert": 2, 221 | "react/jsx-no-duplicate-props": [ 222 | 2, 223 | { 224 | "ignoreCase": true 225 | } 226 | ], 227 | "no-unused-expressions": 2, 228 | "react/jsx-sort-prop-types": 0, 229 | "no-class-assign": 2, 230 | "spaced-comment": 2, 231 | "no-path-concat": 2, 232 | "prefer-spread": 2, 233 | "no-self-compare": 2, 234 | "guard-for-in": 2, 235 | "no-nested-ternary": 2, 236 | "no-multi-str": 2, 237 | "react/jsx-key": 1, 238 | "import/namespace": 2, 239 | "no-warning-comments": 1, 240 | "no-delete-var": 2, 241 | "babel/arrow-parens": [ 242 | 2, 243 | "always" 244 | ], 245 | "no-with": 2, 246 | "no-extra-parens": 2, 247 | "no-trailing-spaces": 2, 248 | "import/no-unresolved": 1, 249 | "no-obj-calls": 2, 250 | "accessor-pairs": 2, 251 | "yoda": [ 252 | 2, 253 | "never", 254 | { 255 | "exceptRange": true 256 | } 257 | ], 258 | "no-continue": 1, 259 | "react/no-unknown-property": 2, 260 | "no-new": 2, 261 | "object-curly-spacing": 2, 262 | "react/jsx-curly-spacing": [ 263 | 2, 264 | "never" 265 | ], 266 | "jsx-quotes": 2, 267 | "react/no-direct-mutation-state": 2, 268 | "key-spacing": 2, 269 | "no-underscore-dangle": [ 270 | 2, 271 | { "allowAfterThis": true } 272 | ], 273 | "new-parens": 2, 274 | "no-mixed-spaces-and-tabs": 2, 275 | "no-floating-decimal": 2, 276 | "operator-assignment": [ 277 | 2, 278 | "always" 279 | ], 280 | "no-shadow-restricted-names": 2, 281 | "no-use-before-define": [ 282 | 2, 283 | "nofunc" 284 | ], 285 | "no-useless-call": 2, 286 | "no-caller": 2, 287 | "quotes": [ 288 | 2, 289 | "single", 290 | "avoid-escape" 291 | ], 292 | "react/jsx-handler-names": [ 293 | 1, 294 | { 295 | "eventHandlerPrefix": "handle", 296 | "eventHandlerPropPrefix": "on" 297 | } 298 | ], 299 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 300 | "no-unused-vars": 2, 301 | "import/default": 1, 302 | "no-lonely-if": 2, 303 | "no-extra-semi": 2, 304 | "prefer-template": 2, 305 | "react/forbid-prop-types": 1, 306 | "react/self-closing-comp": 2, 307 | "no-else-return": 2, 308 | "react/jsx-max-props-per-line": [ 309 | 2, 310 | { 311 | "maximum": 3 312 | } 313 | ], 314 | "no-dupe-args": 2, 315 | "no-new-object": 2, 316 | "callback-return": 2, 317 | "no-new-wrappers": 2, 318 | "comma-style": 2, 319 | "no-script-url": 2, 320 | "consistent-this": 2, 321 | "react/wrap-multilines": 0, 322 | "dot-location": [ 323 | 2, 324 | "property" 325 | ], 326 | "no-implicit-coercion": 2, 327 | "max-depth": [ 328 | 2, 329 | 4 330 | ], 331 | "babel/object-curly-spacing": [ 332 | 2, 333 | "never" 334 | ], 335 | "no-array-constructor": 2, 336 | "no-iterator": 2, 337 | "react/jsx-no-bind": 2, 338 | "sort-vars": 2, 339 | "no-var": 2, 340 | "no-sparse-arrays": 2, 341 | "space-before-function-paren": [ 342 | 2, 343 | "never" 344 | ], 345 | "no-throw-literal": 2, 346 | "no-proto": 2, 347 | "default-case": 2, 348 | "no-inner-declarations": 2, 349 | "react/jsx-indent-props": [ 350 | 2, 351 | 2 352 | ], 353 | "no-new-func": 2, 354 | "object-shorthand": 2, 355 | "no-ex-assign": 2, 356 | "no-unexpected-multiline": 2, 357 | "no-undef-init": 2, 358 | "no-duplicate-case": 2, 359 | "no-fallthrough": 2, 360 | "no-catch-shadow": 2, 361 | "import/export": 2, 362 | "no-constant-condition": 2, 363 | "complexity": [ 364 | 2, 365 | 25 366 | ], 367 | "react/jsx-boolean-value": [ 368 | 2, 369 | "never" 370 | ], 371 | "valid-jsdoc": 2, 372 | "no-extend-native": 2, 373 | "react/prop-types": 2, 374 | "no-regex-spaces": 2, 375 | "react/no-multi-comp": 2, 376 | "no-octal": 2, 377 | "arrow-spacing": 2, 378 | "quote-props": [ 379 | 2, 380 | "as-needed" 381 | ], 382 | "no-div-regex": 2, 383 | "react/jsx-uses-vars": 2, 384 | "react/no-danger": 1 385 | }, 386 | "settings": { 387 | "ecmascript": 6, 388 | "jsx": true, 389 | "import/parser": "babel-eslint", 390 | "import/ignore": [ 391 | "node_modules", 392 | "\\.scss$" 393 | ], 394 | "import/resolve": { 395 | "moduleDirectory": [ 396 | "node_modules" 397 | ] 398 | } 399 | }, 400 | "plugins": [ 401 | "react", 402 | "import", 403 | "babel" 404 | ] 405 | } 406 | -------------------------------------------------------------------------------- /.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 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # Compiled source 36 | .nyc_output 37 | lib 38 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintrc 3 | .gitignore 4 | .npmignore 5 | .nyc_output 6 | coverage 7 | src 8 | test 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | - "5.1" 5 | - "4" 6 | - "4.2" 7 | - "4.1" 8 | - "4.0" 9 | - "0.12" 10 | - "0.11" 11 | - "0.10" 12 | - "iojs" 13 | after_success: npm run coverage 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Joon Ho Cho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-relay-connection 2 | [![Build Status](https://travis-ci.org/joonhocho/graphql-relay-connection.svg?branch=master)](https://travis-ci.org/joonhocho/graphql-relay-connection) 3 | [![Coverage Status](https://coveralls.io/repos/github/joonhocho/graphql-relay-connection/badge.svg?branch=master)](https://coveralls.io/github/joonhocho/graphql-relay-connection?branch=master) 4 | [![npm version](https://badge.fury.io/js/graphql-relay-connection.svg)](https://badge.fury.io/js/graphql-relay-connection) 5 | [![Dependency Status](https://david-dm.org/joonhocho/graphql-relay-connection.svg)](https://david-dm.org/joonhocho/graphql-relay-connection) 6 | [![License](http://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org) 7 | 8 | A GraphQL Relay connection with custom cursor functions. 9 | Can be used for MongoDB, Mongoose, plain objects, scalars, or any data formats. 10 | 11 | 12 | ### Install 13 | ``` 14 | npm install --save graphql-relay-connection 15 | ``` 16 | 17 | ### Usage 18 | `mongooseConnection` is predefined for you. 19 | For other usage, scroll down to `Custom connection` section below. 20 | ```javascript 21 | import { 22 | connectionDefinitions, 23 | connectionArgs, 24 | } from 'graphql-relay'; 25 | 26 | import {mongooseConnection} from 'graphql-relay-connection'; 27 | 28 | const { 29 | connectionFromPromisedArray, 30 | cursorToDocument, 31 | } = mongooseConnection; 32 | 33 | const UserType = new GraphQLObjectType({ 34 | name: 'User', 35 | fields: () => ({ 36 | friends: { 37 | type: UserConnection, 38 | args: connectionArgs, 39 | resolve: (user, args) => { 40 | args.first = args.first || 10; 41 | const doc = cursorToDocument(args.after); 42 | const friends = User.find({ 43 | isFriendWith: user._id, 44 | _id: {$gt: doc && doc._id}, 45 | }) 46 | .limit(args.first + 1) // add +1 for hasNextPage 47 | .exec() 48 | return connectionFromPromisedArray(friends, args); 49 | }, 50 | }, 51 | }), 52 | }); 53 | 54 | const { 55 | connectionType: UserConnection, 56 | } = connectionDefinitions({ 57 | nodeType: UserType, 58 | }); 59 | ``` 60 | 61 | ### Custom connection 62 | Simply provide `{ 63 | comparableToCursor, 64 | cursorToComparable, 65 | comparator 66 | }` to `defineConnection`. 67 | 68 | ```javascript 69 | import defineConnection from 'graphql-relay-connection'; 70 | import { 71 | base64, 72 | unbase64, 73 | } from './util'; 74 | 75 | const PREFIX = 'number:'; 76 | 77 | // Given a comparable value, return a string cursor. 78 | function numberToCursor(num) { 79 | return base64(PREFIX + num); 80 | } 81 | 82 | // Given a string cursor, 83 | // return a comparable value for the comparator function. 84 | function cursorToNumber(cursor) { 85 | const num = parseInt(unbase64(cursor).substring(PREFIX.length), 10); 86 | return isNaN(num) ? null : num; 87 | } 88 | 89 | // Sort function for array.sort(). 90 | // Given two values, return an interger. 91 | function compareNumbers(num1, num2) { 92 | return num1 - num2; 93 | } 94 | 95 | const { 96 | connectionFromArray, 97 | connectionFromPromisedArray, 98 | } = defineConnection({ 99 | comparableToCursor: numberToCursor, 100 | cursorToComparable: cursorToNumber, 101 | comparator: compareNumbers, 102 | }); 103 | ``` 104 | 105 | ### How mongoose connection is defined 106 | ```javascript 107 | import type { 108 | ConnectionCursor, 109 | } from './connectionTypes'; 110 | import defineConnection from './defineConnection'; 111 | import { 112 | base64, 113 | unbase64, 114 | startsWith, 115 | } from './util'; 116 | 117 | 118 | export type ID = string; 119 | 120 | export type Document = { 121 | id: ID, 122 | }; 123 | 124 | 125 | const PREFIX = 'mongoose:'; 126 | 127 | 128 | function documentToCursor(doc: Document): ConnectionCursor { 129 | return base64(PREFIX + doc.id); 130 | } 131 | 132 | 133 | function cursorToDocument(cursor: ConnectionCursor): ?Document { 134 | const unbased = unbase64(cursor); 135 | if (startsWith(unbased, PREFIX)) { 136 | const id = unbased.substring(PREFIX.length); 137 | if (id) return {id}; 138 | } 139 | return null; 140 | } 141 | 142 | 143 | function compareDocuments(doc1: Document, doc2: Document): number { 144 | if (doc1.id < doc2.id) { 145 | return -1; 146 | } 147 | if (doc1.id > doc2.id) { 148 | return 1; 149 | } 150 | return 0; 151 | } 152 | 153 | 154 | const { 155 | connectionFromArray, 156 | connectionFromPromisedArray, 157 | } = defineConnection({ 158 | comparableToCursor: documentToCursor, 159 | cursorToComparable: cursorToDocument, 160 | comparator: compareDocuments, 161 | }); 162 | 163 | 164 | export { 165 | compareDocuments, 166 | connectionFromArray, 167 | connectionFromPromisedArray, 168 | cursorToDocument, 169 | documentToCursor, 170 | }; 171 | ``` 172 | 173 | 174 | ### License 175 | ``` 176 | The MIT License (MIT) 177 | 178 | Copyright (c) 2016 Joon Ho Cho 179 | 180 | Permission is hereby granted, free of charge, to any person obtaining a copy 181 | of this software and associated documentation files (the "Software"), to deal 182 | in the Software without restriction, including without limitation the rights 183 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 184 | copies of the Software, and to permit persons to whom the Software is 185 | furnished to do so, subject to the following conditions: 186 | 187 | The above copyright notice and this permission notice shall be included in all 188 | copies or substantial portions of the Software. 189 | 190 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 191 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 192 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 193 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 194 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 195 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 196 | SOFTWARE. 197 | ``` 198 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-relay-connection", 3 | "version": "0.0.4", 4 | "description": "A GraphQL Relay connection with cursor based on id, not on number offset. Can be used for MongoDB.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir lib", 8 | "build-watch": "babel src --watch --out-dir lib", 9 | "clear": "rm -rf ./lib ./coverage ./.nyc_output", 10 | "coverage": "nyc npm test && nyc report --reporter=text-lcov | coveralls", 11 | "nyc": "nyc npm test && nyc report --reporter=lcov", 12 | "prepublish": "npm run clear && npm test", 13 | "pretest": "npm run build", 14 | "start": "npm test", 15 | "test": "mocha", 16 | "test-watch": "mocha --watch", 17 | "update-D": "npm install --save-dev babel-cli@latest babel-plugin-syntax-flow@latest babel-plugin-transform-flow-strip-types@latest babel-plugin-transform-runtime@latest babel-preset-es2015@latest babel-preset-stage-0@latest babel-register@latest babel-runtime@latest chai@latest chai-as-promised@latest coveralls@latest flow-bin@latest mocha@latest nyc@latest", 18 | "watch": "npm run build-watch & npm run test-watch" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/joonhocho/graphql-relay-connection.git" 23 | }, 24 | "keywords": [ 25 | "graphql", 26 | "relay", 27 | "connection", 28 | "id", 29 | "cursor", 30 | "offset", 31 | "mongodb" 32 | ], 33 | "author": "Joon Ho Cho", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/joonhocho/graphql-relay-connection/issues" 37 | }, 38 | "homepage": "https://github.com/joonhocho/graphql-relay-connection#readme", 39 | "dependencies": {}, 40 | "devDependencies": { 41 | "babel-cli": "^6.9.0", 42 | "babel-plugin-syntax-flow": "^6.8.0", 43 | "babel-plugin-transform-flow-strip-types": "^6.8.0", 44 | "babel-plugin-transform-runtime": "^6.9.0", 45 | "babel-preset-es2015": "^6.9.0", 46 | "babel-preset-stage-0": "^6.5.0", 47 | "babel-register": "^6.9.0", 48 | "babel-runtime": "^6.9.2", 49 | "chai": "^3.5.0", 50 | "chai-as-promised": "^5.3.0", 51 | "coveralls": "^2.11.9", 52 | "flow-bin": "^0.26.0", 53 | "mocha": "^2.5.3", 54 | "mongoose": "^4.4.19", 55 | "nyc": "^6.4.4" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/connectiontypes.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | /** 4 | * A flow type alias for cursors in this implementation. 5 | */ 6 | export type ConnectionCursor = string; 7 | 8 | 9 | /** 10 | * A flow type describing the comparator that is used to sort a connection array in GraphQL. 11 | */ 12 | export type Comparator = (a: T, b: T) => number; 13 | 14 | 15 | export type ComparableToCursor = (comparable: T) => ConnectionCursor; 16 | 17 | 18 | export type CursorToComparable = (cursor: ConnectionCursor) => T; 19 | 20 | 21 | export type ConnectionDefinitionArguments = { 22 | comparator: Comparator, 23 | comparableToCursor: ComparableToCursor; 24 | cursorToComparable: CursorToComparable; 25 | }; 26 | 27 | 28 | export type ConnectionOptions = { 29 | hasPreviousPage: ?boolean, 30 | hasNextPage: ?boolean, 31 | sort: ?boolean, 32 | desc: ?boolean, 33 | }; 34 | 35 | 36 | /** 37 | * A flow type designed to be exposed as `PageInfo` over GraphQL. 38 | */ 39 | export type PageInfo = { 40 | startCursor: ?ConnectionCursor, 41 | endCursor: ?ConnectionCursor, 42 | hasPreviousPage: ?boolean, 43 | hasNextPage: ?boolean 44 | }; 45 | 46 | 47 | /** 48 | * A flow type designed to be exposed as a `Connection` over GraphQL. 49 | */ 50 | export type Connection = { 51 | edges: Array>; 52 | pageInfo: PageInfo; 53 | }; 54 | 55 | 56 | /** 57 | * A flow type designed to be exposed as a `Edge` over GraphQL. 58 | */ 59 | export type Edge = { 60 | node: T; 61 | cursor: ConnectionCursor; 62 | }; 63 | 64 | 65 | /** 66 | * A flow type describing the arguments a connection field receives in GraphQL. 67 | */ 68 | export type ConnectionArguments = { 69 | before?: ?ConnectionCursor; 70 | after?: ?ConnectionCursor; 71 | first?: ?number; 72 | last?: ?number; 73 | }; 74 | -------------------------------------------------------------------------------- /src/defineConnection.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { 3 | Connection, 4 | ConnectionArguments, 5 | ConnectionDefinitionArguments, 6 | ConnectionOptions, 7 | } from './connectionTypes'; 8 | 9 | 10 | type HasPages = { 11 | hasPreviousPage: boolean, 12 | hasNextPage: boolean, 13 | }; 14 | 15 | 16 | const edgesToConnection = (edges, { 17 | hasPreviousPage, 18 | hasNextPage, 19 | }: HasPages) => { 20 | const startEdge = edges[0]; 21 | const lastEdge = edges[edges.length - 1]; 22 | 23 | const startCursor = startEdge == null ? null : startEdge.cursor; 24 | const endCursor = lastEdge == null ? null : lastEdge.cursor; 25 | 26 | return { 27 | edges, 28 | pageInfo: { 29 | startCursor, 30 | endCursor, 31 | hasPreviousPage, 32 | hasNextPage, 33 | }, 34 | }; 35 | }; 36 | 37 | 38 | const isNotBoolean = (x) => typeof x !== 'boolean'; 39 | const isNotFunction = (x) => typeof x !== 'function'; 40 | 41 | 42 | export default ({ 43 | comparableToCursor, 44 | cursorToComparable, 45 | comparator, 46 | }: ConnectionDefinitionArguments) => { 47 | 48 | if (isNotFunction(comparableToCursor)) { 49 | throw new Error("Must provide 'comparableToCursor'"); 50 | } 51 | 52 | if (isNotFunction(cursorToComparable)) { 53 | throw new Error("Must provide 'cursorToComparable'"); 54 | } 55 | 56 | if (isNotFunction(comparator)) { 57 | throw new Error("Must provide 'comparator'"); 58 | } 59 | 60 | 61 | const comparatorDesc = (a, b) => -comparator(a, b); 62 | 63 | 64 | const nodeToEdge = (node) => ({ 65 | cursor: comparableToCursor(node), 66 | node, 67 | }); 68 | 69 | 70 | const findStartIndex = (nodes, afterNode, curComparator) => { 71 | if (afterNode == null) { 72 | return 0; 73 | } 74 | for (let i = 0; i < nodes.length; i++) { 75 | const diff = curComparator(afterNode, nodes[i]); 76 | if (diff === 0) { 77 | return i + 1; 78 | } 79 | if (diff < 0) { 80 | return i; 81 | } 82 | } 83 | return -1; 84 | }; 85 | 86 | 87 | const findEndIndex = (nodes, beforeNode, curComparator) => { 88 | if (beforeNode == null) { 89 | return nodes.length - 1; 90 | } 91 | for (let i = nodes.length - 1; i >= 0; i--) { 92 | const diff = curComparator(beforeNode, nodes[i]); 93 | if (diff === 0) { 94 | return i - 1; 95 | } 96 | if (diff > 0) { 97 | return i; 98 | } 99 | } 100 | return -1; 101 | }; 102 | 103 | 104 | function connectionFromArray( 105 | data: Array, 106 | args: ConnectionArguments, 107 | options: ?ConnectionOptions, 108 | ): Connection { 109 | const {after, before, first, last} = args; 110 | if (first != null && last != null) { 111 | throw new Error("Must not provide both 'first' and 'last'"); 112 | } 113 | if (first != null && first <= 0 || 114 | last != null && last <= 0) { 115 | throw new Error("'first' and 'last' must be 1 or greater"); 116 | } 117 | 118 | let { 119 | hasPreviousPage, 120 | hasNextPage, 121 | sort, 122 | desc, 123 | } = options || {}; 124 | 125 | if (!data.length) { 126 | if (isNotBoolean(hasPreviousPage)) hasPreviousPage = false; 127 | if (isNotBoolean(hasNextPage)) hasNextPage = false; 128 | return edgesToConnection([], { 129 | hasPreviousPage, 130 | hasNextPage, 131 | }); 132 | } 133 | 134 | 135 | const curComparator = desc ? comparatorDesc : comparator; 136 | 137 | const afterNode = after && cursorToComparable(after) || null; 138 | const beforeNode = before && cursorToComparable(before) || null; 139 | if (afterNode != null && beforeNode != null && curComparator(afterNode, beforeNode) > 0) { 140 | throw new Error("'before' must be after 'after'"); 141 | } 142 | 143 | let nodes = data.slice(); 144 | if (sort) nodes = nodes.sort(curComparator); 145 | 146 | let startIndex = findStartIndex(nodes, afterNode, curComparator); 147 | if (startIndex < 0) { 148 | // no nodes after afterNode 149 | if (isNotBoolean(hasPreviousPage)) hasPreviousPage = nodes.length > 0; 150 | if (isNotBoolean(hasNextPage)) hasNextPage = false; 151 | return edgesToConnection([], { 152 | hasPreviousPage, 153 | hasNextPage, 154 | }); 155 | } 156 | 157 | let endIndex = findEndIndex(nodes, beforeNode, curComparator); 158 | if (endIndex < 0) { 159 | // no nodes before beforeNode 160 | if (isNotBoolean(hasPreviousPage)) hasPreviousPage = false; 161 | if (isNotBoolean(hasNextPage)) hasNextPage = nodes.length > 0; 162 | return edgesToConnection([], { 163 | hasPreviousPage, 164 | hasNextPage, 165 | }); 166 | } 167 | 168 | if (startIndex > endIndex) { 169 | throw new Error("'before' must be after 'after'"); 170 | } 171 | 172 | let edges = nodes.slice(startIndex, endIndex + 1).map(nodeToEdge); 173 | 174 | if (first != null && first < edges.length) { 175 | endIndex = first - 1; 176 | edges = edges.slice(0, first); 177 | } else if (last != null && last < edges.length) { 178 | startIndex = edges.length - last; 179 | edges = edges.slice(-last); 180 | } 181 | 182 | if (isNotBoolean(hasPreviousPage)) hasPreviousPage = startIndex > 0; 183 | if (isNotBoolean(hasNextPage)) hasNextPage = endIndex < nodes.length - 1; 184 | 185 | return edgesToConnection(edges, { 186 | hasPreviousPage, 187 | hasNextPage, 188 | }); 189 | } 190 | 191 | 192 | function connectionFromPromisedArray( 193 | dataPromise: Promise>, 194 | args: ConnectionArguments, 195 | options: ?ConnectionOptions, 196 | ): Promise> { 197 | return dataPromise.then((data) => connectionFromArray(data, args, options)); 198 | } 199 | 200 | 201 | return { 202 | connectionFromArray, 203 | connectionFromPromisedArray, 204 | }; 205 | }; 206 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export {default} from './defineConnection'; 3 | import mongooseConnection from './mongooseConnection'; 4 | 5 | export {mongooseConnection}; 6 | -------------------------------------------------------------------------------- /src/mongooseConnection.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { 3 | ConnectionCursor, 4 | } from './connectionTypes'; 5 | import mongoose from 'mongoose'; 6 | import defineConnection from './defineConnection'; 7 | import { 8 | base64, 9 | unbase64, 10 | startsWith, 11 | } from './util'; 12 | 13 | 14 | export type ID = string; 15 | 16 | export type Document = { 17 | id: ID, 18 | }; 19 | 20 | 21 | const PREFIX = 'mongoose:'; 22 | 23 | const {ObjectId} = mongoose.Types; 24 | 25 | 26 | function documentToCursor(doc: Document): ConnectionCursor { 27 | return base64(PREFIX + doc.id); 28 | } 29 | 30 | 31 | function cursorToDocument(cursor: ConnectionCursor): ?Document { 32 | const unbased = unbase64(cursor); 33 | if (startsWith(unbased, PREFIX)) { 34 | const id = unbased.substring(PREFIX.length); 35 | if (id && ObjectId.isValid(id)) { 36 | return { 37 | _id: ObjectId.createFromHexString(id), 38 | id, 39 | }; 40 | } 41 | } 42 | return null; 43 | } 44 | 45 | 46 | function compareDocuments(doc1: Document, doc2: Document): number { 47 | if (doc1.id < doc2.id) { 48 | return -1; 49 | } 50 | if (doc1.id > doc2.id) { 51 | return 1; 52 | } 53 | return 0; 54 | } 55 | 56 | 57 | const { 58 | connectionFromArray, 59 | connectionFromPromisedArray, 60 | } = defineConnection({ 61 | comparableToCursor: documentToCursor, 62 | cursorToComparable: cursorToDocument, 63 | comparator: compareDocuments, 64 | }); 65 | 66 | 67 | export { 68 | compareDocuments, 69 | connectionFromArray, 70 | connectionFromPromisedArray, 71 | cursorToDocument, 72 | documentToCursor, 73 | }; 74 | 75 | export default { 76 | compareDocuments, 77 | connectionFromArray, 78 | connectionFromPromisedArray, 79 | cursorToDocument, 80 | documentToCursor, 81 | }; 82 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export type Base64String = string; 3 | 4 | 5 | export function base64(i: string): Base64String { 6 | return ((new Buffer(i, 'ascii')).toString('base64')); 7 | } 8 | 9 | 10 | export function unbase64(i: Base64String): string { 11 | return ((new Buffer(i, 'base64')).toString('ascii')); 12 | } 13 | 14 | 15 | export function startsWith(str: string, prefix: string): boolean { 16 | return str.lastIndexOf(prefix, 0) === 0; 17 | } 18 | -------------------------------------------------------------------------------- /test/defineConnection.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {describe, it} from 'mocha'; 3 | import {expect} from 'chai'; 4 | import defineConnection from '../lib'; 5 | 6 | describe('defineConnection()', () => { 7 | it('requires comparableToCursor', () => { 8 | expect(() => 9 | defineConnection({ 10 | comparableToCursor: null, 11 | cursorToComparable: () => {}, 12 | comparator: () => {}, 13 | }) 14 | ).to.throw(/comparableToCursor/); 15 | }); 16 | 17 | 18 | it('requires cursorToComparable', () => { 19 | expect(() => 20 | defineConnection({ 21 | comparableToCursor: () => {}, 22 | cursorToComparable: null, 23 | comparator: () => {}, 24 | }) 25 | ).to.throw(/cursorToComparable/); 26 | }); 27 | 28 | 29 | it('requires comparator', () => { 30 | expect(() => 31 | defineConnection({ 32 | comparableToCursor: () => {}, 33 | cursorToComparable: () => {}, 34 | comparator: null, 35 | }) 36 | ).to.throw(/comparator/); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel-register 2 | --require ./test/setup.js 3 | --reporter spec 4 | --timeout 5000 5 | -------------------------------------------------------------------------------- /test/mongoose.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {describe, it} from 'mocha'; 3 | import {expect} from 'chai'; 4 | import {mongooseConnection} from '../lib'; 5 | import mongoose from 'mongoose'; 6 | 7 | 8 | const { 9 | documentToCursor, 10 | connectionFromArray, 11 | connectionFromPromisedArray, 12 | } = mongooseConnection; 13 | 14 | const {ObjectId} = mongoose.Types; 15 | 16 | const lastItem = (arr) => arr[arr.length - 1]; 17 | const ids = [ 18 | ObjectId.createFromTime(Date.now() / 1000), 19 | ObjectId.createFromTime(Date.now() / 1000 + 1), 20 | ObjectId.createFromTime(Date.now() / 1000 + 2), 21 | ObjectId.createFromTime(Date.now() / 1000 + 3), 22 | ObjectId.createFromTime(Date.now() / 1000 + 4), 23 | ]; 24 | const nodes = ids.map((id) => ({id})); 25 | const nodesDesc = nodes.slice().reverse(); 26 | const nodesPromise = Promise.resolve(nodes); 27 | const cursors = nodes.map(documentToCursor); 28 | const edges = nodes.map((node, i) => ({ 29 | node, 30 | cursor: cursors[i], 31 | })); 32 | 33 | 34 | describe('connectionFromArray()', () => { 35 | describe('basic slicing', () => { 36 | it('returns all elements without filters', () => { 37 | const c = connectionFromArray(nodes, {}); 38 | return expect(c).to.deep.equal({ 39 | edges, 40 | pageInfo: { 41 | startCursor: cursors[0], 42 | endCursor: lastItem(cursors), 43 | hasPreviousPage: false, 44 | hasNextPage: false, 45 | }, 46 | }); 47 | }); 48 | 49 | it('respects a smaller first', () => { 50 | const c = connectionFromArray(nodes, {first: 2}); 51 | return expect(c).to.deep.equal({ 52 | edges: edges.slice(0, 2), 53 | pageInfo: { 54 | startCursor: cursors[0], 55 | endCursor: cursors[1], 56 | hasPreviousPage: false, 57 | hasNextPage: true, 58 | }, 59 | }); 60 | }); 61 | 62 | it('respects an overly large first', () => { 63 | const c = connectionFromArray(nodes, {first: 10}); 64 | return expect(c).to.deep.equal({ 65 | edges, 66 | pageInfo: { 67 | startCursor: cursors[0], 68 | endCursor: lastItem(cursors), 69 | hasPreviousPage: false, 70 | hasNextPage: false, 71 | }, 72 | }); 73 | }); 74 | 75 | it('respects a smaller last', () => { 76 | const c = connectionFromArray(nodes, {last: 2}); 77 | return expect(c).to.deep.equal({ 78 | edges: edges.slice(-2), 79 | pageInfo: { 80 | startCursor: cursors[3], 81 | endCursor: cursors[4], 82 | hasPreviousPage: true, 83 | hasNextPage: false, 84 | }, 85 | }); 86 | }); 87 | 88 | it('respects an overly large last', () => { 89 | const c = connectionFromArray(nodes, {last: 10}); 90 | return expect(c).to.deep.equal({ 91 | edges, 92 | pageInfo: { 93 | startCursor: cursors[0], 94 | endCursor: lastItem(cursors), 95 | hasPreviousPage: false, 96 | hasNextPage: false, 97 | }, 98 | }); 99 | }); 100 | }); 101 | 102 | describe('pagination', () => { 103 | it('respects first and after', () => { 104 | const c = connectionFromArray( 105 | nodes, 106 | {first: 2, after: cursors[1]} 107 | ); 108 | return expect(c).to.deep.equal({ 109 | edges: edges.slice(2, 4), 110 | pageInfo: { 111 | startCursor: cursors[2], 112 | endCursor: cursors[3], 113 | hasPreviousPage: true, 114 | hasNextPage: true, 115 | }, 116 | }); 117 | }); 118 | 119 | it('respects first and after with long first', () => { 120 | const c = connectionFromArray( 121 | nodes, 122 | {first: 10, after: cursors[1]} 123 | ); 124 | return expect(c).to.deep.equal({ 125 | edges: edges.slice(2), 126 | pageInfo: { 127 | startCursor: cursors[2], 128 | endCursor: cursors[4], 129 | hasPreviousPage: true, 130 | hasNextPage: false, 131 | }, 132 | }); 133 | }); 134 | 135 | it('respects last and before', () => { 136 | const c = connectionFromArray( 137 | nodes, 138 | {last: 2, before: cursors[3]} 139 | ); 140 | return expect(c).to.deep.equal({ 141 | edges: edges.slice(1, 3), 142 | pageInfo: { 143 | startCursor: cursors[1], 144 | endCursor: cursors[2], 145 | hasPreviousPage: true, 146 | hasNextPage: true, 147 | }, 148 | }); 149 | }); 150 | 151 | it('respects last and before with long last', () => { 152 | const c = connectionFromArray( 153 | nodes, 154 | {last: 10, before: cursors[3]} 155 | ); 156 | return expect(c).to.deep.equal({ 157 | edges: edges.slice(0, 3), 158 | pageInfo: { 159 | startCursor: cursors[0], 160 | endCursor: cursors[2], 161 | hasPreviousPage: false, 162 | hasNextPage: true, 163 | }, 164 | }); 165 | }); 166 | 167 | it('respects first and after and before, too few', () => { 168 | const c = connectionFromArray( 169 | nodes, 170 | { 171 | first: 2, 172 | after: cursors[0], 173 | before: cursors[4], 174 | } 175 | ); 176 | return expect(c).to.deep.equal({ 177 | edges: edges.slice(1, 3), 178 | pageInfo: { 179 | startCursor: cursors[1], 180 | endCursor: cursors[2], 181 | hasPreviousPage: true, 182 | hasNextPage: true, 183 | }, 184 | }); 185 | }); 186 | 187 | it('respects first and after and before, too many', () => { 188 | const c = connectionFromArray( 189 | nodes, 190 | { 191 | first: 4, 192 | after: cursors[0], 193 | before: cursors[4], 194 | } 195 | ); 196 | return expect(c).to.deep.equal({ 197 | edges: edges.slice(1, 4), 198 | pageInfo: { 199 | startCursor: cursors[1], 200 | endCursor: cursors[3], 201 | hasPreviousPage: true, 202 | hasNextPage: true, 203 | }, 204 | }); 205 | }); 206 | 207 | it('respects first and after and before, exactly right', () => { 208 | const c = connectionFromArray( 209 | nodes, 210 | { 211 | first: 3, 212 | after: cursors[0], 213 | before: cursors[4], 214 | } 215 | ); 216 | return expect(c).to.deep.equal({ 217 | edges: edges.slice(1, 4), 218 | pageInfo: { 219 | startCursor: cursors[1], 220 | endCursor: cursors[3], 221 | hasPreviousPage: true, 222 | hasNextPage: true, 223 | }, 224 | }); 225 | }); 226 | 227 | it('respects last and after and before, too few', () => { 228 | const c = connectionFromArray( 229 | nodes, 230 | { 231 | last: 2, 232 | after: cursors[0], 233 | before: cursors[4], 234 | } 235 | ); 236 | return expect(c).to.deep.equal({ 237 | edges: edges.slice(2, 4), 238 | pageInfo: { 239 | startCursor: cursors[2], 240 | endCursor: cursors[3], 241 | hasPreviousPage: true, 242 | hasNextPage: true, 243 | }, 244 | }); 245 | }); 246 | 247 | it('respects last and after and before, too many', () => { 248 | const c = connectionFromArray( 249 | nodes, 250 | { 251 | last: 4, 252 | after: cursors[0], 253 | before: cursors[4], 254 | } 255 | ); 256 | return expect(c).to.deep.equal({ 257 | edges: edges.slice(1, 4), 258 | pageInfo: { 259 | startCursor: cursors[1], 260 | endCursor: cursors[3], 261 | hasPreviousPage: true, 262 | hasNextPage: true, 263 | }, 264 | }); 265 | }); 266 | 267 | it('respects last and after and before, exactly right', () => { 268 | const c = connectionFromArray( 269 | nodes, 270 | { 271 | last: 3, 272 | after: cursors[0], 273 | before: cursors[4], 274 | } 275 | ); 276 | return expect(c).to.deep.equal({ 277 | edges: edges.slice(1, 4), 278 | pageInfo: { 279 | startCursor: cursors[1], 280 | endCursor: cursors[3], 281 | hasPreviousPage: true, 282 | hasNextPage: true, 283 | }, 284 | }); 285 | }); 286 | }); 287 | 288 | describe('cursor edge cases', () => { 289 | it('throws if first is 0', () => { 290 | expect(() => connectionFromArray( 291 | nodes, 292 | {first: 0} 293 | )).to.throw(); 294 | }); 295 | 296 | it('throws if last is 0', () => { 297 | expect(() => connectionFromArray( 298 | nodes, 299 | {last: 0} 300 | )).to.throw(); 301 | }); 302 | 303 | it('throws if both first and last are set', () => { 304 | expect(() => connectionFromArray( 305 | nodes, 306 | {first: 1, last: 1} 307 | )).to.throw(); 308 | }); 309 | 310 | it('returns all elements if cursors are invalid', () => { 311 | const c = connectionFromArray( 312 | nodes, 313 | {before: 'invalid', after: 'invalid'} 314 | ); 315 | return expect(c).to.deep.equal({ 316 | edges, 317 | pageInfo: { 318 | startCursor: cursors[0], 319 | endCursor: cursors[4], 320 | hasPreviousPage: false, 321 | hasNextPage: false, 322 | }, 323 | }); 324 | }); 325 | 326 | it('returns all elements if cursors are on the outside', () => { 327 | const c = connectionFromArray( 328 | nodes, 329 | { 330 | before: documentToCursor({id: 'F'}), 331 | after: documentToCursor({id: '0'}), 332 | } 333 | ); 334 | return expect(c).to.deep.equal({ 335 | edges, 336 | pageInfo: { 337 | startCursor: cursors[0], 338 | endCursor: cursors[4], 339 | hasPreviousPage: false, 340 | hasNextPage: false, 341 | }, 342 | }); 343 | }); 344 | 345 | it('throws if cursors cross', () => { 346 | expect(() => connectionFromArray( 347 | nodes, 348 | {before: cursors[2], after: cursors[4]} 349 | )).to.throw(); 350 | }); 351 | }); 352 | 353 | describe('pageInfo', () => { 354 | it('overrides hasPreviousPage', () => { 355 | const c = connectionFromArray(nodes, {}, {hasPreviousPage: true}); 356 | return expect(c).to.deep.equal({ 357 | edges, 358 | pageInfo: { 359 | startCursor: cursors[0], 360 | endCursor: lastItem(cursors), 361 | hasPreviousPage: true, 362 | hasNextPage: false, 363 | }, 364 | }); 365 | }); 366 | 367 | it('overrides hasNextPage', () => { 368 | const c = connectionFromArray(nodes, {}, {hasNextPage: true}); 369 | return expect(c).to.deep.equal({ 370 | edges, 371 | pageInfo: { 372 | startCursor: cursors[0], 373 | endCursor: lastItem(cursors), 374 | hasPreviousPage: false, 375 | hasNextPage: true, 376 | }, 377 | }); 378 | }); 379 | }); 380 | 381 | describe('options', () => { 382 | it('sort', () => { 383 | const unsortedNodes = [2, 0, 3, 4, 1].map((i) => nodes[i]); 384 | const c = connectionFromArray( 385 | unsortedNodes, 386 | {first: 2, after: cursors[1]}, 387 | {sort: true} 388 | ); 389 | return expect(c).to.deep.equal({ 390 | edges: edges.slice(2, 4), 391 | pageInfo: { 392 | startCursor: cursors[2], 393 | endCursor: cursors[3], 394 | hasPreviousPage: true, 395 | hasNextPage: true, 396 | }, 397 | }); 398 | }); 399 | 400 | it('desc', () => { 401 | const c = connectionFromArray( 402 | nodesDesc, 403 | {first: 2, after: cursors[3]}, 404 | {desc: true} 405 | ); 406 | return expect(c).to.deep.equal({ 407 | edges: edges.slice(1, 3).reverse(), 408 | pageInfo: { 409 | startCursor: cursors[2], 410 | endCursor: cursors[1], 411 | hasPreviousPage: true, 412 | hasNextPage: true, 413 | }, 414 | }); 415 | }); 416 | }); 417 | }); 418 | 419 | describe('connectionFromPromisedArray()', () => { 420 | it('returns all elements without filters', async () => { 421 | const c = await connectionFromPromisedArray(nodesPromise, {}); 422 | return expect(c).to.deep.equal({ 423 | edges, 424 | pageInfo: { 425 | startCursor: cursors[0], 426 | endCursor: cursors[4], 427 | hasPreviousPage: false, 428 | hasNextPage: false, 429 | }, 430 | }); 431 | }); 432 | 433 | it('respects a smaller first', async () => { 434 | const c = await connectionFromPromisedArray(nodesPromise, {first: 2}); 435 | return expect(c).to.deep.equal({ 436 | edges: edges.slice(0, 2), 437 | pageInfo: { 438 | startCursor: cursors[0], 439 | endCursor: cursors[1], 440 | hasPreviousPage: false, 441 | hasNextPage: true, 442 | }, 443 | }); 444 | }); 445 | }); 446 | -------------------------------------------------------------------------------- /test/number.js: -------------------------------------------------------------------------------- 1 | import type { 2 | ConnectionCursor, 3 | } from '../src/connectionTypes'; 4 | import defineConnection from '../lib'; 5 | import { 6 | base64, 7 | unbase64, 8 | startsWith, 9 | } from '../lib/util'; 10 | import {describe, it} from 'mocha'; 11 | import {expect} from 'chai'; 12 | 13 | 14 | const PREFIX = 'number:'; 15 | 16 | function numberToCursor(num: number): ConnectionCursor { 17 | return base64(PREFIX + num); 18 | } 19 | 20 | 21 | function cursorToNumber(cursor: ConnectionCursor): ?number { 22 | const unbased = unbase64(cursor); 23 | if (startsWith(unbased, PREFIX)) { 24 | const num = unbased.substring(PREFIX.length); 25 | if (num) return parseInt(num, 10); 26 | } 27 | return null; 28 | } 29 | 30 | 31 | function compareNumbers(num1: number, num2: number): number { 32 | return num1 - num2; 33 | } 34 | 35 | const { 36 | connectionFromArray, 37 | } = defineConnection({ 38 | comparableToCursor: numberToCursor, 39 | cursorToComparable: cursorToNumber, 40 | comparator: compareNumbers, 41 | }); 42 | 43 | 44 | const lastItem = (arr) => arr[arr.length - 1]; 45 | 46 | const nodes = [1, 2, 3, 4, 5]; 47 | const cursors = nodes.map(numberToCursor); 48 | const edges = nodes.map((node, i) => ({ 49 | node, 50 | cursor: cursors[i], 51 | })); 52 | 53 | 54 | describe('connectionFromArray()', () => { 55 | describe('basic slicing', () => { 56 | it('returns all elements without filters', () => { 57 | const c = connectionFromArray(nodes, {}); 58 | return expect(c).to.deep.equal({ 59 | edges, 60 | pageInfo: { 61 | startCursor: cursors[0], 62 | endCursor: lastItem(cursors), 63 | hasPreviousPage: false, 64 | hasNextPage: false, 65 | }, 66 | }); 67 | }); 68 | 69 | it('respects a smaller first', () => { 70 | const c = connectionFromArray(nodes, {first: 2}); 71 | return expect(c).to.deep.equal({ 72 | edges: edges.slice(0, 2), 73 | pageInfo: { 74 | startCursor: cursors[0], 75 | endCursor: cursors[1], 76 | hasPreviousPage: false, 77 | hasNextPage: true, 78 | }, 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | chai.use(require('chai-as-promised')); 3 | --------------------------------------------------------------------------------