├── .babelrc ├── .circleci └── config.yml ├── .eslintrc.yml ├── .gitignore ├── .prettierrc.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── src ├── __tests__ └── index.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | install: 5 | working_directory: ~/mongoose-cursor-pagination 6 | docker: 7 | - image: circleci/node:8.10.0 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | keys: 12 | - v1-dependencies-{{ checksum "package.json" }} 13 | # fallback to using the latest cache if no exact match is found 14 | - v1-dependencies- 15 | - attach_workspace: 16 | at: ~/mongoose-cursor-pagination 17 | - run: 18 | name: NPM Install 19 | command: npm install 20 | - save_cache: 21 | paths: 22 | - node_modules 23 | key: v1-dependencies-{{ checksum "package.json" }} 24 | - persist_to_workspace: 25 | root: ~/mongoose-cursor-pagination 26 | paths: ./node_modules 27 | build: 28 | working_directory: ~/mongoose-cursor-pagination 29 | docker: 30 | - image: circleci/node:8.10.0 31 | steps: 32 | - checkout 33 | - attach_workspace: 34 | at: ~/mongoose-cursor-pagination 35 | - run: 36 | name: Build 37 | command: | 38 | if [ "${CIRCLE_BRANCH}" == "master" ]; then 39 | NODE_ENV=production \ 40 | npm run prepare 41 | else 42 | NODE_ENV=development \ 43 | npm run prepare 44 | fi 45 | - persist_to_workspace: 46 | root: ~/mongoose-cursor-pagination 47 | paths: 48 | - ./lib 49 | lint: 50 | working_directory: ~/mongoose-cursor-pagination 51 | docker: 52 | - image: circleci/node:8.10.0 53 | steps: 54 | - checkout 55 | - attach_workspace: 56 | at: ~/mongoose-cursor-pagination 57 | - run: 58 | name: Lint 59 | command: npm run lint:ci 60 | test: 61 | working_directory: ~/mongoose-cursor-pagination 62 | docker: 63 | - image: circleci/node:8.10.0 64 | - image: circleci/mongo:3.4-jessie-ram 65 | command: [mongod] 66 | steps: 67 | - checkout 68 | - attach_workspace: 69 | at: ~/mongoose-cursor-pagination 70 | - run: 71 | name: Tests 72 | command: npm run test 73 | 74 | workflows: 75 | version: 2 76 | install_build_lint_test: 77 | jobs: 78 | - install 79 | - build: 80 | requires: 81 | - install 82 | - lint: 83 | requires: 84 | - install 85 | - test: 86 | requires: 87 | - install 88 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parser: babel-eslint 2 | extends: 3 | - '@enkidevs/eslint-config-backend' 4 | rules: 5 | node/no-unsupported-features/es-syntax: off 6 | promise/always-return: off 7 | func-names: off 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | *.pid.lock 11 | 12 | # Dependency directories 13 | node_modules/ 14 | 15 | # dotenv environment variables file 16 | .env 17 | 18 | # Output directories 19 | lib/ 20 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: es5 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Enki 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 | # mongoose-cursor-pagination 2 | [![Build Status](https://travis-ci.org/enkidevs/mongoose-cursor-pagination.svg?branch=master)](https://travis-ci.org/enkidevs/mongoose-cursor-pagination.svg?branch=master) 3 | [![npm version](https://img.shields.io/npm/v/mongoose-cursor-pagination.svg)](https://www.npmjs.com/package/mongoose-cursor-pagination) 4 | [![Dependency Status](https://david-dm.org/enkidevs/mongoose-cursor-pagination.svg)](https://david-dm.org/enkidevs/mongoose-cursor-pagination) 5 | [![devDependency Status](https://david-dm.org/enkidevs/mongoose-cursor-pagination/dev-status.svg)](https://david-dm.org/enkidevs/mongoose-cursor-pagination#info=devDependencies) 6 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/enkidevs/mongoose-cursor-pagination/issues) 7 | [![HitCount](http://hits.dwyl.io/enkidevs/mongoose-cursor-pagination.svg)](http://hits.dwyl.io/enkidevs/mongoose-cursor-pagination) 8 | 9 | > Mongoose cursor-based pagination 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install mongoose-cursor-pagination --save 15 | ``` 16 | 17 | ## Usage 18 | 19 | The plugin utilises cursor-based pagination via the `startingAfter` and `endingBefore` parameters. 20 | Both take an existing value (see below) and return objects in reverse chronological order. 21 | The `endingBefore` parameter returns objects listed before the named object. 22 | The `startingAfter` parameter returns objects listed after the named object. 23 | If both parameters are provided, only `endingBefore` is used. 24 | Moreover, an optional `limit` parameter can be passed to limit the amount of objects returned. 25 | 26 | Add the plugin to a schema: 27 | 28 | ```javascript 29 | import mongoose from 'mongoose' 30 | import paginationPlugin from 'mongoose-cursor-pagination' 31 | 32 | const AccountSchema = new mongoose.Schema({ 33 | username: { type: Number, unique: true, index: true } 34 | }) 35 | 36 | AccountSchema.plugin(paginationPlugin) 37 | 38 | mongoose.model('Account', AccountSchema) 39 | ``` 40 | 41 | and then use the model paginate method using a promise: 42 | 43 | ```javascript 44 | mongoose.model('Account').paginate({}, { 45 | sort: { '_id': 1 }, 46 | startingAfter: '59b1f7fd41cfc303859ea1c9', 47 | limit: 20 48 | }) 49 | .then(results => { /* ... */ }) 50 | .catch(error => { /* ... */ }) 51 | ``` 52 | 53 | or using a callback: 54 | 55 | ```javascript 56 | mongoose.model('Account').paginate({}, { 57 | sort: { '_id': 1 } 58 | }, (error, results) => { 59 | /* ... */ 60 | }) 61 | ``` 62 | 63 | A possible value for `results` is: 64 | 65 | ```javascript 66 | { 67 | items: [ /* ... */ ], 68 | hasMore: true 69 | } 70 | ``` 71 | 72 | where `items` is an array containing the elements, and `hasMore` is `true` if there are more elements available after this set. Or `false` otherwise. 73 | 74 | The default plugin values can be overwritten, here we show the default values: 75 | 76 | ```javascript 77 | AccountSchema.plugin(paginationPlugin, { 78 | key: '_id', 79 | limit: 20, 80 | maxLimit: 100, 81 | minLimit: 1 82 | }) 83 | ``` 84 | 85 | The `key` specified is assumed to be unique and should have an index associated. 86 | Moreover, when paginating the `key` should be sorted ascending order and the values of `startingAfter` and `endingBefore` should contained values for that `key`. 87 | 88 | ## Tests 89 | 90 | ```bash 91 | npm install 92 | npm test 93 | ``` 94 | 95 | ## License 96 | 97 | [MIT](LICENSE) 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@enkidevs/mongoose-cursor-pagination", 3 | "version": "1.0.1", 4 | "description": "Mongoose cursor-based pagination.", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib" 8 | ], 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "scripts": { 13 | "prepare": "babel src --out-dir lib", 14 | "test": "npm run prepare && mocha 'lib/**/__tests__/*' --exit", 15 | "format": "prettier --write 'src/**/*.js'", 16 | "lint:ci": "CI=true eslint . --ignore-path .gitignore --quiet", 17 | "lint": "eslint . --ignore-path .gitignore", 18 | "check-branch": "enkidevs-assert-restricted-branch" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/enkidevs/mongoose-cursor-pagination.git" 23 | }, 24 | "husky": { 25 | "hooks": { 26 | "pre-commit": "lint-staged" 27 | } 28 | }, 29 | "lint-staged": { 30 | "linters": { 31 | "*.{js,jsx}": [ 32 | "npm run check-branch", 33 | "npm run format", 34 | "npm run lint", 35 | "git add" 36 | ] 37 | } 38 | }, 39 | "keywords": [ 40 | "mongoose", 41 | "paginate", 42 | "pagination", 43 | "cursor", 44 | "mongodb", 45 | "javascript" 46 | ], 47 | "author": "Pedro da Rocha Pinto ", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/enkidevs/mongoose-cursor-pagination/issues" 51 | }, 52 | "homepage": "https://github.com/enkidevs/mongoose-cursor-pagination", 53 | "devDependencies": { 54 | "@enkidevs/assert-restricted-branch": "^1.0.2", 55 | "@enkidevs/eslint-config-backend": "^0.2.3", 56 | "babel-cli": "^6.26.0", 57 | "babel-eslint": "^10.0.1", 58 | "babel-preset-es2015": "^6.24.1", 59 | "babel-preset-stage-0": "^6.24.1", 60 | "eslint": "^5.8.0", 61 | "eslint-config-babel": "^8.0.1", 62 | "husky": "^1.1.3", 63 | "lint-staged": "^10.0.7", 64 | "mocha": "^6.1.4", 65 | "mongoose": "^5.6.4", 66 | "prettier": "^1.14.3", 67 | "should": "^13.1.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import should from 'should'; 3 | import mongoose from 'mongoose'; 4 | import paginationPlugin from '../index'; 5 | 6 | describe('mongoose-cursor-pagination', () => { 7 | before(() => { 8 | mongoose.Promise = Promise; 9 | return mongoose.connect( 10 | 'mongodb://127.0.0.1/test', 11 | { 12 | useMongoClient: true, 13 | } 14 | ); 15 | }); 16 | 17 | before(() => { 18 | const UserSchema = new mongoose.Schema({ 19 | name: { type: String }, 20 | value: { type: Number, unique: true, index: true }, 21 | }); 22 | UserSchema.plugin(paginationPlugin, { 23 | limit: 5, 24 | }); 25 | mongoose.model('User', UserSchema); 26 | }); 27 | 28 | before(() => 29 | mongoose.model('User').insertMany( 30 | Array(1000) 31 | .fill() 32 | .map((_, i) => ({ 33 | name: `User ${i}`, 34 | value: i, 35 | })) 36 | ) 37 | ); 38 | 39 | after(() => mongoose.model('User').remove()); 40 | 41 | it('pagination with no options', () => 42 | mongoose 43 | .model('User') 44 | .paginate( 45 | {}, 46 | { 47 | key: 'value', 48 | } 49 | ) 50 | .then(results => { 51 | should.equal(results.items.length, 5); 52 | })); 53 | 54 | it('pagination by key and ascendingly', () => 55 | mongoose 56 | .model('User') 57 | .paginate( 58 | {}, 59 | { 60 | key: 'value', 61 | sort: { value: 1 }, 62 | } 63 | ) 64 | .then(results => { 65 | should.equal(results.items.length, 5); 66 | should.equal(results.hasMore, true); 67 | should.equal(results.items[0].value, 0); 68 | should.equal(results.items[4].value, 4); 69 | })); 70 | 71 | it('pagination by key, ascendingly and startingAfter', () => 72 | mongoose 73 | .model('User') 74 | .paginate( 75 | {}, 76 | { 77 | key: 'value', 78 | sort: { value: 1 }, 79 | startingAfter: 4, 80 | } 81 | ) 82 | .then(results => { 83 | should.equal(results.items.length, 5); 84 | should.equal(results.hasMore, true); 85 | should.equal(results.items[0].value, 5); 86 | should.equal(results.items[4].value, 9); 87 | })); 88 | 89 | it('pagination when it reaches the end', () => 90 | mongoose 91 | .model('User') 92 | .paginate( 93 | {}, 94 | { 95 | key: 'value', 96 | sort: { value: 1 }, 97 | startingAfter: 998, 98 | } 99 | ) 100 | .then(results => { 101 | should.equal(results.items.length, 1); 102 | should.equal(results.hasMore, false); 103 | should.equal(results.items[0].value, 999); 104 | })); 105 | 106 | it('pagination by key and descendingly', () => 107 | mongoose 108 | .model('User') 109 | .paginate( 110 | {}, 111 | { 112 | key: 'value', 113 | sort: { value: -1 }, 114 | } 115 | ) 116 | .then(results => { 117 | should.equal(results.items.length, 5); 118 | should.equal(results.hasMore, true); 119 | should.equal(results.items[0].value, 999); 120 | should.equal(results.items[4].value, 995); 121 | })); 122 | 123 | it('pagination by key, ascendingly and endingBefore', () => 124 | mongoose 125 | .model('User') 126 | .paginate( 127 | {}, 128 | { 129 | key: 'value', 130 | sort: { value: -1 }, 131 | endingBefore: 995, 132 | } 133 | ) 134 | .then(results => { 135 | should.equal(results.items.length, 5); 136 | should.equal(results.hasMore, true); 137 | should.equal(results.items[0].value, 994); 138 | should.equal(results.items[4].value, 990); 139 | })); 140 | 141 | it('does not modify the provided query object', () => { 142 | const query = {}; 143 | 144 | return mongoose 145 | .model('User') 146 | .paginate(query, { 147 | key: 'value', 148 | sort: { value: -1 }, 149 | endingBefore: 995, 150 | }) 151 | .then(() => { 152 | query.should.eql({}); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export default function paginationPlugin(schema, pluginOptions) { 2 | pluginOptions = pluginOptions || {}; 3 | 4 | const minLimit = +pluginOptions.minLimit || 1; 5 | const maxLimit = +pluginOptions.maxLimit || 100; 6 | 7 | const defaultOptions = { 8 | key: pluginOptions.key || '_id', 9 | lean: pluginOptions.lean || false, 10 | limit: +pluginOptions.limit || 20, 11 | sort: {}, 12 | }; 13 | 14 | schema.statics.paginate = function(query, options, callback) { 15 | query = query ? { ...query } : {}; 16 | options = options || {}; 17 | options.limit = +options.limit || defaultOptions.limit; 18 | options = { ...defaultOptions, ...options }; 19 | 20 | if (options.limit > maxLimit || options.limit < minLimit) { 21 | options.limit = defaultOptions.limit; 22 | } 23 | 24 | const { key, select, populate, lean, limit, sort } = options; 25 | let reverse = false; 26 | 27 | if (options.startingAfter || options.endingBefore) { 28 | query[key] = {}; 29 | 30 | if (options.endingBefore) { 31 | query[key] = { $lt: options.endingBefore }; 32 | 33 | if (sort[key] > 0) { 34 | sort[key] = -1; 35 | reverse = true; 36 | } 37 | } else { 38 | query[key] = { $gt: options.startingAfter }; 39 | 40 | if (sort[key] <= 0) { 41 | sort[key] = 1; 42 | reverse = true; 43 | } 44 | } 45 | } 46 | 47 | const promise = this.find() 48 | .where(query) 49 | .select(select) 50 | .sort(sort) 51 | .limit(limit + 1) 52 | .lean(lean); 53 | 54 | if (populate) { 55 | [].concat(populate).forEach(item => promise.populate(item)); 56 | } 57 | 58 | return new Promise((resolve, reject) => 59 | promise.exec((error, items) => { 60 | if (error) { 61 | if (typeof callback === 'function') { 62 | setImmediate(() => callback(error)); 63 | } 64 | 65 | return reject(error); 66 | } 67 | 68 | items = items || []; 69 | const hasMore = items.length === limit + 1; 70 | 71 | if (hasMore) { 72 | items.pop(); 73 | } 74 | 75 | items = reverse ? items.reverse() : items; 76 | 77 | const results = { 78 | items, 79 | hasMore, 80 | }; 81 | 82 | if (typeof callback === 'function') { 83 | setImmediate(() => callback(null, results)); 84 | } 85 | 86 | return resolve(results); 87 | }) 88 | ); 89 | }; 90 | } 91 | --------------------------------------------------------------------------------