├── .babelrc ├── .npmignore ├── test ├── mocha.opts ├── index.js ├── .eslintrc ├── fixtures │ ├── schema.sql │ └── data.sql ├── common.js ├── next-foreach.test.js ├── null-vals.test.js └── main.test.js ├── .travis.yml ├── .eslintrc ├── docker-compose.yml ├── .gitignore ├── package.json ├── README.md └── src └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | {"presets": ["binded"]} 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | test/ 3 | docker-compose.yml 4 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel-register 2 | --require babel-polyfill 3 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import './main.test' 2 | import './null-vals.test' 3 | import './next-foreach.test' 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | services: 3 | - postgresql 4 | node_js: 5 | - '6' 6 | - '7' 7 | - '8' 8 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "describe": true, 4 | "it": true, 5 | "xit": true, 6 | "expect": true, 7 | "before": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: 'airbnb-base', 3 | parser: 'babel-eslint', 4 | rules: { 5 | 'consistent-return': 0, 6 | 'no-param-reassign': [2, { props: false }], 7 | semi: [2, 'never'], 8 | camelcase: 0, 9 | 'no-underscore-dangle': 0, 10 | 'arrow-parens': 0, 11 | 'class-methods-use-this': 0, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | db: 2 | image: postgres:9.5 3 | ports: 4 | - "5432:5432" 5 | 6 | createdb: 7 | image: postgres:9.5 8 | links: 9 | - db 10 | command: > 11 | /bin/bash -c " 12 | while ! psql --host=db --username=postgres; do sleep 1; done; 13 | psql --host=db --username=postgres -c 'CREATE DATABASE \"bookshelf-test\";'; 14 | " 15 | -------------------------------------------------------------------------------- /test/fixtures/schema.sql: -------------------------------------------------------------------------------- 1 | -- DROP DATABASE cursor_pagination_test; 2 | -- CREATE DATABASE cursor_pagination_test; 3 | -- \c cursor_pagination_test; 4 | 5 | CREATE TABLE manufacturers ( 6 | id bigserial primary key, 7 | name text, 8 | country text 9 | ); 10 | 11 | CREATE TYPE energy_source AS ENUM ( 12 | 'electric', 13 | 'petrol', 14 | 'diesel' 15 | ); 16 | 17 | CREATE TABLE engines ( 18 | id bigserial primary key, 19 | name text, 20 | energy_source energy_source, 21 | description text 22 | ); 23 | 24 | CREATE TABLE cars ( 25 | id bigserial primary key, 26 | manufacturer_id bigint references manufacturers(id), 27 | engine_id bigint references engines(id), 28 | description text 29 | ); 30 | -------------------------------------------------------------------------------- /.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 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 28 | node_modules 29 | 30 | # Environment variable files 31 | .envrc 32 | .env 33 | 34 | # Temporary / data folders 35 | .tmp 36 | data/ 37 | 38 | lib/ 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookshelf-cursor-pagination", 3 | "version": "1.4.2", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "babel -q -D ./src/ --out-dir ./lib/", 8 | "lint": "eslint src/ test/", 9 | "test": "npm run lint && npm run test:fast", 10 | "test:fast": "mocha test/index.js", 11 | "test:watch": "nodemon --exec mocha test/index.js", 12 | "prepublish": "npm test && npm run build" 13 | }, 14 | "author": "Oli Lalonde (https://binded.com/)", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "babel-cli": "^6.24.1", 18 | "babel-eslint": "^7.2.3", 19 | "babel-preset-binded": "^1.1.0", 20 | "bookshelf": "^0.10.3", 21 | "chai": "^4.0.2", 22 | "es6-promisify": "^5.0.0", 23 | "eslint": "^3.19.0", 24 | "eslint-config-airbnb-base": "^11.2.0", 25 | "eslint-plugin-import": "^2.3.0", 26 | "knex": "^0.13.0", 27 | "mocha": "^3.4.2", 28 | "pg": "^6.2.3", 29 | "pgtools": "^0.2.3" 30 | }, 31 | "dependencies": { 32 | "lodash": "^4.17.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | import { createdb, dropdb } from 'pgtools' 2 | import initKnex from 'knex' 3 | import initBookshelf from 'bookshelf' 4 | 5 | import fetchCursorPagePlugin from '../src' 6 | 7 | const createKnex = (database) => initKnex({ 8 | connection: { 9 | database, 10 | host: process.env.DATABASE_HOST || 'localhost', 11 | }, 12 | client: 'pg', 13 | debug: !!process.env.DEBUG_SQL, 14 | }) 15 | 16 | export default async (testName) => { 17 | const database = `cursor_pagination_test_${testName}` 18 | const config = { 19 | host: process.env.DATABASE_HOST || 'localhost', 20 | } 21 | try { 22 | await dropdb(config, database) 23 | } catch (err) { 24 | if (err.pgErr && err.pgErr.code === '3D000') { 25 | // ignore 'database does not exist error' 26 | } else { 27 | throw err 28 | } 29 | } 30 | await createdb(config, database) 31 | 32 | const knex = createKnex(database) 33 | const bookshelf = initBookshelf(knex) 34 | bookshelf.plugin('pagination') 35 | bookshelf.plugin(fetchCursorPagePlugin) 36 | return { knex, bookshelf } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /test/fixtures/data.sql: -------------------------------------------------------------------------------- 1 | -- \c cursor_pagination_test; 2 | 3 | INSERT INTO manufacturers(name, country) VALUES 4 | ('Volvo', 'Sweden'), 5 | ('Ford', 'United States'), 6 | ('Buick', 'United States'), 7 | ('Dodge', 'United States'), 8 | ('Jeep', 'United States'), 9 | ('GMC', 'United States'), 10 | ('Chrysler', 'United States'), 11 | ('Chevrolet', 'United States'), 12 | ('Cadillac', 'United States'), 13 | ('Acura', 'Japan'), 14 | ('Infiniti', 'Japan'), 15 | ('Honda', 'Japan'), 16 | ('Lexus', 'Japan'), 17 | ('Mazda', 'Japan'), 18 | ('Mitsubishi', 'Japan'), 19 | ('Nissan', 'Japan'), 20 | ('Subaru', 'Japan'), 21 | ('Suzuki', 'Japan'), 22 | ('Toyota', 'Japan'), 23 | ('Audi', 'Germany'), 24 | ('BMW', 'Germany'), 25 | ('Mercedes-Benz', 'Germany'), 26 | ('Porsche', 'Germany'), 27 | ('Volkswagen', 'Germany'), 28 | ('Tesla', 'United States') 29 | ; 30 | 31 | INSERT INTO engines(name, energy_source) VALUES 32 | ('Diesel', 'diesel'), 33 | ('Electric', 'electric'), 34 | ('Internal Combustion', 'petrol') 35 | ; 36 | 37 | INSERT INTO cars(manufacturer_id, engine_id, description) VALUES 38 | (1, 3, 'Volvo V40'), 39 | (2, 3, 'Mustang'), 40 | (2, 3, 'Focus'), 41 | (3, 3, 'Regal'), 42 | (4, 3, 'Challenger'), 43 | (5, 3, 'Wrangler'), 44 | (6, 3, 'Yukon'), 45 | (7, 3, '300'), 46 | (8, 3, 'Cruze'), 47 | (8, 3, 'Impala'), 48 | (9, 3, 'Escalade'), 49 | (10, 3, 'NSX'), 50 | (11, 3, 'Q50'), 51 | (12, 3, 'Civic'), 52 | (13, 3, 'RX'), 53 | (14, 3, 'Miata'), 54 | (15, 3, 'Lancer'), 55 | (16, 3, 'GT-R'), 56 | (17, 3, 'Impreza'), 57 | (18, 3, 'Swift'), 58 | (19, 3, 'Prius'), 59 | (20, 3, 'A6'), 60 | (21, 3, '3 Series'), 61 | (22, 3, 'E-Class'), 62 | (23, 3, '911'), 63 | (24, 1, 'Jetta'), 64 | (25, 2, 'Model S') 65 | ; 66 | -------------------------------------------------------------------------------- /test/next-foreach.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import promisify from 'es6-promisify' 3 | import fs from 'fs' 4 | import path from 'path' 5 | 6 | import setup from './common' 7 | 8 | const readFile = promisify(fs.readFile) 9 | 10 | const initModels = (bookshelf) => { 11 | class Car extends bookshelf.Model { 12 | get tableName() { return 'cars' } 13 | } 14 | return { Car } 15 | } 16 | 17 | const setupDb = async (knex) => { 18 | const [ 19 | schemaSql, 20 | dataSql, 21 | ] = await Promise.all([ 22 | path.join(__dirname, 'fixtures/schema.sql'), 23 | path.join(__dirname, 'fixtures/data.sql'), 24 | ].map((filePath) => readFile(filePath, 'utf8'))) 25 | await knex.raw(schemaSql) 26 | await knex.raw(dataSql) 27 | } 28 | 29 | describe('Cursor pagination', () => { 30 | let Car 31 | // let knex 32 | // let bookshelf 33 | 34 | before(async () => { 35 | const result = await setup('next') 36 | await setupDb(result.knex) 37 | const models = initModels(result.bookshelf) 38 | Car = models.Car 39 | // bookshelf = result.bookshelf 40 | // knex = result.knex 41 | }) 42 | 43 | it('next()', async () => { 44 | const beautify = coll => coll.models.map(m => m.get('description')) 45 | let coll 46 | coll = await Car.collection() 47 | .orderBy('description') 48 | .orderBy('-id') 49 | .fetchCursorPage({ limit: 6 }) 50 | assert.equal(typeof coll.next, 'function') 51 | assert.deepEqual(beautify(coll), [ 52 | '300', 53 | '3 Series', 54 | '911', 55 | 'A6', 56 | 'Challenger', 57 | 'Civic', 58 | ]) 59 | coll = await coll.next() 60 | assert.deepEqual(beautify(coll), [ 61 | 'Cruze', 62 | 'E-Class', 63 | 'Escalade', 64 | 'Focus', 65 | 'GT-R', 66 | 'Impala', 67 | ]) 68 | coll = await coll.next() 69 | assert.deepEqual(beautify(coll), [ 70 | 'Impreza', 71 | 'Jetta', 72 | 'Lancer', 73 | 'Miata', 74 | 'Model S', 75 | 'Mustang', 76 | ]) 77 | coll = await coll.next() 78 | assert.deepEqual(beautify(coll), [ 79 | 'NSX', 80 | 'Prius', 81 | 'Q50', 82 | 'Regal', 83 | 'RX', 84 | 'Swift', 85 | ]) 86 | coll = await coll.next() 87 | assert.deepEqual(beautify(coll), [ 88 | 'Volvo V40', 89 | 'Wrangler', 90 | 'Yukon', 91 | ]) 92 | assert.equal(coll.next, false) 93 | }) 94 | it('forEach()', async () => { 95 | const beautify = coll => coll.models.map(m => m.get('description')) 96 | const expectedResults = [ 97 | [ 98 | '300', 99 | '3 Series', 100 | '911', 101 | 'A6', 102 | 'Challenger', 103 | 'Civic', 104 | ], 105 | [ 106 | 'Cruze', 107 | 'E-Class', 108 | 'Escalade', 109 | 'Focus', 110 | 'GT-R', 111 | 'Impala', 112 | ], 113 | [ 114 | 'Impreza', 115 | 'Jetta', 116 | 'Lancer', 117 | 'Miata', 118 | 'Model S', 119 | 'Mustang', 120 | ], 121 | [ 122 | 'NSX', 123 | 'Prius', 124 | 'Q50', 125 | 'Regal', 126 | 'RX', 127 | 'Swift', 128 | ], 129 | [ 130 | 'Volvo V40', 131 | 'Wrangler', 132 | 'Yukon', 133 | ], 134 | ] 135 | 136 | let i = 0 137 | await Car.collection() 138 | .orderBy('description') 139 | .orderBy('-id') 140 | .forEach({ limit: 6 }, coll => { 141 | assert.deepEqual(beautify(coll), expectedResults[i]) 142 | i += 1 143 | }) 144 | 145 | await Car.collection() 146 | .forEach({ limit: 500 }, coll => { 147 | assert.deepEqual( 148 | beautify(coll).sort(), 149 | expectedResults.reduce((acc, arr) => acc.concat(arr), []).sort(), 150 | ) 151 | }) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/binded/bookshelf-cursor-pagination.svg?branch=master)](https://travis-ci.org/binded/bookshelf-cursor-pagination) 2 | 3 | # bookshelf-cursor-pagination 4 | 5 | Bookshelf plugin that implements [cursor based pagination](https://www.sitepoint.com/paginating-real-time-data-cursor-based-pagination/) (also known as [keyset pagination](http://use-the-index-luke.com/no-offset)). 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install bookshelf-cursor-pagination 11 | ``` 12 | 13 | ## Usage 14 | 15 | `fetchCursorPage` is the same as 16 | [fetchPage](http://bookshelfjs.org/#Model-instance-fetchPage) but with 17 | cursors instead. A cursor is a series of column values that uniquely 18 | identify the position of a row in a result set. If only the primary ID 19 | is sorted a cursor is simply the primary ID of a row. 20 | Arguments: 21 | - *limit*: size of page (defaults to 10) 22 | - *before*: array of values that correspond to sorted columns 23 | - *after*: array of values that correspond to sorted columns 24 | 25 | If there is no sorting and the cursor (before or after) has one element, 26 | we implicitly sort by the id attribute. 27 | 28 | `before` and `after` are mutually exclusive. `before` means we fetch the 29 | page of results before the row represented by the cursor. `after` means 30 | we fetch the page of results before the row represented by the cursor. 31 | 32 | ```javascript 33 | import cursorPagination from 'bookshelf-cursor-pagination' 34 | 35 | // ... 36 | 37 | bookshelf.plugin(cursorPagination) 38 | 39 | // ... 40 | class Car extends Bookshelf.Model { 41 | get tableName() { return 'cars' } 42 | } 43 | 44 | const result = await Car.collection() 45 | .orderBy('manufacturer_id') 46 | .orderBy('description') 47 | .fetchCursorPage({ 48 | after: [/* manufacturer_id */ '8', /* description */ 'Cruze'], 49 | }) 50 | 51 | console.log(result.models) 52 | 53 | // ... 54 | 55 | console.log(result.pagination) 56 | 57 | /* 58 | { limit: 10, 59 | rowCount: 27, 60 | hasMore: true, 61 | cursors: { after: [ '17', 'Impreza' ], before: [ '8', 'Impala' ] }, 62 | orderedBy: 63 | [ { name: 'manufacturer_id', direction: 'asc', tableName: 'cars' }, 64 | { name: 'description', direction: 'asc', tableName: 'cars' } ] } 65 | */ 66 | 67 | // A next() method is also available on the collection to fetch the next 68 | // set of result 69 | ``` 70 | 71 | Example of stable iteration with cursors: 72 | 73 | ```javascript 74 | // will iterate by batches of 5 until the end 75 | const iter = async (doSomething, after) => { 76 | const coll = await Car.collection() 77 | .orderBy('id') 78 | .fetchCursorPage({ after, limit: 5 }) 79 | await doSomething(coll) 80 | if (coll.pagination.hasMore) { 81 | return iter(doSomething, coll.pagination.cursors.after) 82 | } 83 | } 84 | 85 | iter((collection) => { 86 | console.log(collection.models.length) 87 | // 5 88 | }) 89 | ``` 90 | 91 | This plugin also adds a `forEach` method that takes the same arguments 92 | as `fethPage` and a callback which is called for every result set. 93 | 94 | For example: 95 | 96 | ```javascript 97 | const main = async () => { 98 | await Car 99 | .collection() 100 | .orderBy('id') 101 | .forEach({ limit: 5 }, async (coll) => { 102 | // do something with collection 103 | }) 104 | console.log('iterated over all rows!') 105 | } 106 | ``` 107 | 108 | ### Joins and/or .format 109 | 110 | `fetchCursorPage` will break if one of the sorted columns is not 111 | accessible via `model.get(colName)` (either because the column is not 112 | returned by the select or because the bookshelf object implements a 113 | `.format()` method). 114 | 115 | In order to avoid this issue, you can implement a `toCursorValue` on 116 | your model that will handle those edge cases. For example: 117 | 118 | ```javascript 119 | Car.prototype.toCursorValue = function ({ name, tableName }) { 120 | if (tableName === this.tableName) return this.get(name) 121 | if (tableName === 'engines' && name === 'name') { 122 | return this.get('engine_name') 123 | } 124 | throw new Error(`cannot extract cursor for ${tableName}.${name}`) 125 | } 126 | ``` 127 | 128 | -------------------------------------------------------------------------------- /test/null-vals.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import setup from './common' 4 | 5 | const schemaSql = ` 6 | CREATE TABLE movies ( 7 | id bigserial primary key, 8 | name text, 9 | description text 10 | ); 11 | ` 12 | const dataSql = ` 13 | INSERT INTO movies(name, description) VALUES 14 | ('Moon', 'Some movie about the moon'), 15 | ('Terminator', 'Movie about a terminator'), 16 | ('The Avengers 2', null), 17 | ('The Avengers 1', null), 18 | ('A Beautiful Mind', null), 19 | ('Forrest Gump', null), 20 | ('Some Empty Movie', '') 21 | ; 22 | ` 23 | 24 | const initModels = (bookshelf) => { 25 | class Movie extends bookshelf.Model { get tableName() { return 'movies' } } 26 | return { Movie } 27 | } 28 | 29 | const setupDb = async () => { 30 | const { bookshelf, knex } = await setup('nullvals') 31 | await knex.raw(schemaSql) 32 | await knex.raw(dataSql) 33 | const models = initModels(bookshelf) 34 | return { models, bookshelf, knex } 35 | } 36 | 37 | describe('Cursor pagination', () => { 38 | let Movie 39 | // let knex 40 | // let bookshelf 41 | 42 | before(async () => { 43 | const result = await setupDb() 44 | Movie = result.models.Movie 45 | // bookshelf = result.bookshelf 46 | // knex = result.knex 47 | }) 48 | 49 | it('Model#fetchCursorPage() works with null value cursors', async () => { 50 | const result = await Movie.collection() 51 | .orderBy('description') 52 | .orderBy('name') 53 | .fetchCursorPage({ 54 | limit: 2, 55 | }) 56 | assert.equal(result.models.length, 2) 57 | assert.equal(result.pagination.rowCount, 7) 58 | assert.equal(result.pagination.limit, 2) 59 | const { cursors, orderedBy } = result.pagination 60 | assert.deepEqual(cursors.before, [ 61 | '', 62 | 'Some Empty Movie', 63 | ]) 64 | assert.deepEqual(cursors.after, [ 65 | 'Movie about a terminator', 66 | 'Terminator', 67 | ]) 68 | // why is this inversed? 69 | assert.deepEqual(orderedBy, [ 70 | { name: 'description', direction: 'asc', tableName: 'movies' }, 71 | { name: 'name', direction: 'asc', tableName: 'movies' }, 72 | ]) 73 | assert.deepEqual(result.models.map(m => m.get('name')), [ 74 | 'Some Empty Movie', 75 | 'Terminator', 76 | ]) 77 | const { after } = cursors 78 | const result2 = await Movie.collection() 79 | .orderBy('description') 80 | .orderBy('name') 81 | .fetchCursorPage({ 82 | limit: 2, 83 | after, 84 | }) 85 | assert.deepEqual(result2.models.map(m => m.get('name')), [ 86 | 'Moon', 87 | 'A Beautiful Mind', 88 | ]) 89 | const result3 = await Movie.collection() 90 | .orderBy('description') 91 | .orderBy('name') 92 | .fetchCursorPage({ 93 | limit: 2, 94 | after: result2.pagination.cursors.after, 95 | }) 96 | assert.deepEqual(result3.models.map(m => m.get('name')), [ 97 | 'Forrest Gump', 98 | 'The Avengers 1', 99 | ]) 100 | const result4 = await Movie.collection() 101 | .orderBy('description') 102 | .orderBy('name') 103 | .fetchCursorPage({ 104 | limit: 2, 105 | after: result3.pagination.cursors.after, 106 | }) 107 | assert.deepEqual(result4.models.map(m => m.get('name')), [ 108 | 'The Avengers 2', 109 | ]) 110 | }) 111 | 112 | it('Model#fetchCursorPage() works with null value cursors and DESC', async () => { 113 | const next = (after) => Movie.collection() 114 | .orderBy('-description') 115 | .orderBy('name') 116 | .fetchCursorPage({ 117 | after, 118 | limit: 2, 119 | }) 120 | 121 | const eql = (result, arr) => { 122 | assert.deepEqual(result.models.map(m => m.get('name')), arr) 123 | } 124 | 125 | let res 126 | res = await next() 127 | eql(res, ['A Beautiful Mind', 'Forrest Gump']) 128 | res = await next(res.pagination.cursors.after) 129 | eql(res, ['The Avengers 1', 'The Avengers 2']) 130 | res = await next(res.pagination.cursors.after) 131 | eql(res, ['Moon', 'Terminator']) 132 | res = await next(res.pagination.cursors.after) 133 | eql(res, ['Some Empty Movie']) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /test/main.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import promisify from 'es6-promisify' 3 | import fs from 'fs' 4 | import path from 'path' 5 | 6 | import setup from './common' 7 | 8 | const readFile = promisify(fs.readFile) 9 | 10 | const initModels = (bookshelf) => { 11 | class Car extends bookshelf.Model { 12 | get tableName() { return 'cars' } 13 | } 14 | return { Car } 15 | } 16 | 17 | const setupDb = async (knex) => { 18 | const [ 19 | schemaSql, 20 | dataSql, 21 | ] = await Promise.all([ 22 | path.join(__dirname, 'fixtures/schema.sql'), 23 | path.join(__dirname, 'fixtures/data.sql'), 24 | ].map((filePath) => readFile(filePath, 'utf8'))) 25 | await knex.raw(schemaSql) 26 | await knex.raw(dataSql) 27 | } 28 | 29 | describe('Cursor pagination', () => { 30 | let Car 31 | // let knex 32 | let bookshelf 33 | 34 | before(async () => { 35 | const result = await setup('main') 36 | await setupDb(result.knex) 37 | const models = initModels(result.bookshelf) 38 | Car = models.Car 39 | bookshelf = result.bookshelf 40 | // knex = result.knex 41 | }) 42 | 43 | it('should have fetchCursorPage function', () => { 44 | assert.equal(typeof bookshelf.Model.prototype.fetchCursorPage, 'function') 45 | }) 46 | 47 | it('Model#fetchCursorPage() with no opts', async () => { 48 | const result = await Car.collection().fetchCursorPage() 49 | assert.equal(result.models.length, 10) 50 | assert.equal(result.pagination.rowCount, 27) 51 | assert.equal(result.pagination.limit, 10) 52 | const { cursors, orderedBy } = result.pagination 53 | assert.equal(typeof cursors, 'object') 54 | assert.deepEqual(cursors.before, ['1']) 55 | assert.deepEqual(cursors.after, ['10']) 56 | assert.deepEqual(orderedBy, [ 57 | { name: 'id', direction: 'asc', tableName: 'cars' }, 58 | ]) 59 | }) 60 | 61 | it('Model#fetchCursorPage() with where clause', async () => { 62 | const result = await Car.collection() 63 | .query(qb => { 64 | qb.where('engine_id', '=', 3) 65 | }) 66 | .fetchCursorPage() 67 | assert.equal(result.models.length, 10) 68 | assert.equal(result.pagination.rowCount, 25) 69 | assert.equal(result.pagination.limit, 10) 70 | const { cursors, orderedBy } = result.pagination 71 | assert.equal(typeof cursors, 'object') 72 | assert.deepEqual(cursors.before, ['1']) 73 | assert.deepEqual(cursors.after, ['10']) 74 | assert.deepEqual(orderedBy, [ 75 | { name: 'id', direction: 'asc', tableName: 'cars' }, 76 | ]) 77 | }) 78 | 79 | it('Model#fetchCursorPage() with where clause and before', async () => { 80 | const result = await Car.collection() 81 | .query(qb => { 82 | qb.where('engine_id', '=', 3) 83 | }) 84 | .fetchCursorPage({ after: ['25'] }) 85 | assert.equal(result.models.length, 0) 86 | assert.equal(result.pagination.rowCount, 25) 87 | assert.equal(result.pagination.limit, 10) 88 | }) 89 | 90 | it('Model#fetchCursorPage() with limit', async () => { 91 | const result = await Car.collection().fetchCursorPage({ 92 | limit: 5, 93 | }) 94 | assert.equal(result.models.length, 5) 95 | assert.equal(result.pagination.rowCount, 27) 96 | assert.equal(result.pagination.limit, 5) 97 | const { cursors, orderedBy } = result.pagination 98 | assert.equal(typeof cursors, 'object') 99 | assert.deepEqual(cursors.before, ['1']) 100 | assert.deepEqual(cursors.after, ['5']) 101 | assert.deepEqual(orderedBy, [ 102 | { name: 'id', direction: 'asc', tableName: 'cars' }, 103 | ]) 104 | }) 105 | 106 | it('Model#fetchCursorPage() with orderBy and after', async () => { 107 | const result = await Car.collection() 108 | .orderBy('manufacturer_id') 109 | .orderBy('description') 110 | .fetchCursorPage({ 111 | after: ['8', 'Cruze'], 112 | }) 113 | assert.equal(result.models.length, 10) 114 | assert.equal(result.pagination.rowCount, 27) 115 | assert.equal(result.pagination.limit, 10) 116 | const { cursors, orderedBy } = result.pagination 117 | assert.equal(typeof cursors, 'object') 118 | assert.deepEqual(cursors.before, ['8', 'Impala']) 119 | assert.deepEqual(cursors.after, ['17', 'Impreza']) 120 | assert.deepEqual(orderedBy, [ 121 | { name: 'manufacturer_id', direction: 'asc', tableName: 'cars' }, 122 | { name: 'description', direction: 'asc', tableName: 'cars' }, 123 | ]) 124 | }) 125 | 126 | it('Model#fetchCursorPage() with after', async () => { 127 | const result = await Car.collection().fetchCursorPage({ 128 | after: ['5'], 129 | }) 130 | assert.equal(result.models.length, 10) 131 | assert.equal(result.pagination.rowCount, 27) 132 | assert.equal(result.pagination.limit, 10) 133 | const { cursors, orderedBy } = result.pagination 134 | assert.equal(typeof cursors, 'object') 135 | assert.deepEqual(result.models.map(m => m.get('id')), [ 136 | '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', 137 | ]) 138 | assert.deepEqual(cursors.before, ['6']) 139 | assert.deepEqual(cursors.after, ['15']) 140 | assert.deepEqual(orderedBy, [ 141 | { name: 'id', direction: 'asc', tableName: 'cars' }, 142 | ]) 143 | }) 144 | 145 | it('Model#fetchCursorPage() with before', async () => { 146 | const result = await Car.collection().fetchCursorPage({ 147 | before: ['12'], 148 | }) 149 | assert.equal(result.models.length, 10) 150 | assert.equal(result.pagination.rowCount, 27) 151 | assert.equal(result.pagination.limit, 10) 152 | const { cursors, orderedBy } = result.pagination 153 | assert.equal(typeof cursors, 'object') 154 | assert.deepEqual(result.models.map(m => m.get('id')), [ 155 | '11', '10', '9', '8', '7', '6', '5', '4', '3', '2', 156 | ]) 157 | assert.deepEqual(cursors.before, ['2']) 158 | assert.deepEqual(cursors.after, ['11']) 159 | assert.deepEqual(orderedBy, [ 160 | { name: 'id', direction: 'asc', tableName: 'cars' }, 161 | ]) 162 | }) 163 | 164 | /** 165 | * select "cars".* from "cars" where ("manufacturer_id" > ?) or 166 | * ("manufacturer_id" = ? and "description" < ?) order by 167 | * "cars"."manufacturer_id" ASC, "cars"."description" DESC limit ? 168 | */ 169 | it('Model#fetchCursorPage() with DESC orderBy and after', async () => { 170 | const result = await Car.collection() 171 | .orderBy('manufacturer_id') 172 | .orderBy('-description') 173 | .fetchCursorPage({ 174 | limit: 2, 175 | after: ['8', 'Impala'], 176 | }) 177 | assert.equal(result.models.length, 2) 178 | assert.equal(result.pagination.rowCount, 27) 179 | assert.equal(result.pagination.limit, 2) 180 | const { cursors, orderedBy } = result.pagination 181 | assert.equal(typeof cursors, 'object') 182 | assert.deepEqual(cursors.before, ['8', 'Cruze']) 183 | assert.deepEqual(cursors.after, ['9', 'Escalade']) 184 | assert.deepEqual(orderedBy, [ 185 | { name: 'manufacturer_id', direction: 'asc', tableName: 'cars' }, 186 | { name: 'description', direction: 'desc', tableName: 'cars' }, 187 | ]) 188 | }) 189 | 190 | /** 191 | * select "cars".* from "cars" where ("manufacturer_id" < ?) or 192 | * ("manufacturer_id" = ? and "description" > ?) order by 193 | * "cars"."manufacturer_id" ASC, "cars"."description" DESC limit ? 194 | */ 195 | it('Model#fetchCursorPage() with orderBy and before', async () => { 196 | const result = await Car.collection() 197 | .orderBy('manufacturer_id') 198 | .orderBy('-description') 199 | .fetchCursorPage({ 200 | limit: 2, 201 | before: ['8', 'Impala'], 202 | }) 203 | assert.equal(result.models.length, 2) 204 | assert.equal(result.pagination.rowCount, 27) 205 | assert.equal(result.pagination.limit, 2) 206 | const { cursors, orderedBy } = result.pagination 207 | assert.equal(typeof cursors, 'object') 208 | assert.deepEqual(cursors.before, ['6', 'Yukon']) 209 | assert.deepEqual(cursors.after, ['7', '300']) 210 | assert.deepEqual(orderedBy, [ 211 | { name: 'manufacturer_id', direction: 'asc', tableName: 'cars' }, 212 | { name: 'description', direction: 'desc', tableName: 'cars' }, 213 | ]) 214 | }) 215 | 216 | it('Model#fetchCursorPage() with join', async () => { 217 | const result = await Car.collection() 218 | .query(qb => { 219 | qb.select('cars.*') 220 | qb.select('engines.name as engine_name') 221 | /* eslint-disable func-names */ 222 | qb.leftJoin('engines', function () { 223 | this.on('cars.engine_id', '=', 'engines.id') 224 | }) 225 | }) 226 | .orderBy('manufacturer_id') 227 | .orderBy('-description') 228 | .fetchCursorPage({ 229 | limit: 2, 230 | before: ['8', 'Impala'], 231 | }) 232 | assert.equal(result.models.length, 2) 233 | assert.equal(result.pagination.rowCount, 27) 234 | assert.equal(result.pagination.limit, 2) 235 | const { cursors, orderedBy } = result.pagination 236 | assert.equal(typeof cursors, 'object') 237 | assert.deepEqual(cursors.before, ['6', 'Yukon']) 238 | assert.deepEqual(cursors.after, ['7', '300']) 239 | assert.deepEqual(orderedBy, [ 240 | { name: 'manufacturer_id', direction: 'asc', tableName: 'cars' }, 241 | { name: 'description', direction: 'desc', tableName: 'cars' }, 242 | ]) 243 | }) 244 | 245 | it('Model#fetchCursorPage() with join and sort on joined col', async () => { 246 | Car.prototype.toCursorValue = function ({ name, tableName }) { 247 | if (tableName === this.tableName) return this.get(name) 248 | if (tableName === 'engines' && name === 'name') { 249 | return this.get('engine_name') 250 | } 251 | throw new Error(`cannot extract cursor for ${tableName}.${name}`) 252 | } 253 | const result = await Car.collection() 254 | .query(qb => { 255 | qb.select('cars.*') 256 | qb.select('engines.name as engine_name') 257 | /* eslint-disable func-names */ 258 | qb.leftJoin('engines', function () { 259 | this.on('cars.engine_id', '=', 'engines.id') 260 | }) 261 | }) 262 | .orderBy('engines.name') 263 | .orderBy('id') 264 | .fetchCursorPage({ 265 | limit: 3, 266 | }) 267 | const { models, pagination } = result 268 | const { cursors, orderedBy } = pagination 269 | 270 | assert.deepEqual(orderedBy, [ 271 | { name: 'name', direction: 'asc', tableName: 'engines' }, 272 | { name: 'id', direction: 'asc', tableName: 'cars' }, 273 | ]) 274 | 275 | assert.equal(models[0].get('engine_name'), 'Diesel') 276 | assert.equal(models[1].get('engine_name'), 'Electric') 277 | assert.equal(models[2].get('engine_name'), 'Internal Combustion') 278 | assert.deepEqual(models.map(m => m.get('description')), [ 279 | 'Jetta', 'Model S', 'Volvo V40', 280 | ]) 281 | 282 | assert.deepEqual(pagination.cursors, { 283 | after: ['Internal Combustion', '1'], 284 | before: ['Diesel', '26'], 285 | }) 286 | 287 | const { models: models2 } = await Car.collection() 288 | .query(qb => { 289 | qb.select('cars.*') 290 | qb.select('engines.name as engine_name') 291 | /* eslint-disable func-names */ 292 | qb.leftJoin('engines', function () { 293 | this.on('cars.engine_id', '=', 'engines.id') 294 | }) 295 | }) 296 | .orderBy('engines.name') 297 | .orderBy('id') 298 | .fetchCursorPage({ 299 | after: cursors.after, 300 | limit: 3, 301 | }) 302 | 303 | assert.deepEqual(models2.map(m => m.get('description')), [ 304 | 'Mustang', 'Focus', 'Regal', 305 | ]) 306 | }) 307 | 308 | it('Model#fetchCursorPage() iterate over all rows', async () => { 309 | let i = 0 310 | let iterCount = 0 311 | const iter = async (after) => { 312 | const coll = await Car.collection() 313 | .orderBy('manufacturer_id') 314 | .orderBy('description') 315 | .fetchCursorPage({ after, limit: 5 }) 316 | i += coll.length 317 | iterCount += 1 318 | if (coll.pagination.hasMore) { 319 | return iter(coll.pagination.cursors.after) 320 | } 321 | return coll 322 | } 323 | const backIter = async (before) => { 324 | const coll = await Car.collection() 325 | .orderBy('manufacturer_id') 326 | .orderBy('description') 327 | .fetchCursorPage({ before, limit: 5 }) 328 | i += coll.length 329 | iterCount += 1 330 | if (coll.pagination.hasMore) { 331 | return backIter(coll.pagination.cursors.before) 332 | } 333 | return coll 334 | } 335 | 336 | const lastColl = await iter() 337 | assert.equal(i, 27) 338 | assert.equal(iterCount, 6) 339 | i = 0 340 | iterCount = 0 341 | await backIter(lastColl.pagination.cursors.before) 342 | assert.equal(i, 27 - lastColl.length /* 25 */) 343 | // TODO: last iteration returns empty result.. maybe 344 | // we should overfetch by limit + 1 and only set hasMore if 345 | // the result set has limit + 1 elements? the last element would 346 | // be truncated from the response 347 | assert.equal(iterCount, 6) 348 | }) 349 | }) 350 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { remove, assign } from 'lodash' 2 | 3 | const DEFAULT_LIMIT = 10 4 | 5 | const ensurePositiveIntWithDefault = (val, def) => { 6 | if (!val) return def 7 | const _val = parseInt(val, 10) 8 | if (Number.isNaN(_val)) { 9 | return def 10 | } 11 | return _val 12 | } 13 | 14 | 15 | const count = (origQuery, Model, tableName, idAttribute, limit) => { 16 | const notNeededQueries = [ 17 | 'orderByBasic', 18 | 'orderByRaw', 19 | 'groupByBasic', 20 | 'groupByRaw', 21 | ] 22 | const counter = Model.forge() 23 | 24 | return counter.query(qb => { 25 | assign(qb, origQuery) 26 | 27 | // Remove grouping and ordering. Ordering is unnecessary 28 | // for a count, and grouping returns the entire result set 29 | // What we want instead is to use `DISTINCT` 30 | remove(qb._statements, statement => ( 31 | (notNeededQueries.indexOf(statement.type) > -1) || 32 | statement.grouping === 'columns' 33 | )) 34 | qb.countDistinct.apply(qb, [`${tableName}.${idAttribute}`]) 35 | }).fetchAll().then(result => { 36 | const metadata = { limit } 37 | 38 | if (result && result.length === 1) { 39 | // We shouldn't have to do this, instead it should be 40 | // result.models[0].get('count') 41 | // but SQLite uses a really strange key name. 42 | const modelsCount = result.models[0] 43 | const keys = Object.keys(modelsCount.attributes) 44 | if (keys.length === 1) { 45 | const key = Object.keys(modelsCount.attributes)[0] 46 | metadata.rowCount = parseInt(modelsCount.attributes[key], 10) 47 | } else { 48 | // some keys were probably added due to a custom .parse method on the model 49 | // fallback to using the "count" attribute 50 | metadata.rowCount = parseInt(modelsCount.get('count'), 10) 51 | } 52 | } 53 | 54 | return metadata 55 | }) 56 | } 57 | 58 | const isDesc = str => typeof str === 'string' && str.toLowerCase() === 'desc' 59 | 60 | const reverseDirection = d => (isDesc(d) ? 'ASC' : 'DESC') 61 | 62 | const reverseOrderBy = (qb) => { 63 | qb._statements 64 | .filter(s => s.type === 'orderByBasic') 65 | .forEach(s => { 66 | s.direction = reverseDirection(s.direction) 67 | }) 68 | } 69 | 70 | const reverseSign = (sign) => ({ '>': '<', '<': '>' }[sign]) 71 | 72 | const applyCursor = (qb, cursor, mainTableName, idAttribute) => { 73 | const isNotSorted = qb._statements 74 | .filter(s => s.type === 'orderByBasic') 75 | .length === 0 76 | 77 | // We implicitly sort by ID asc 78 | if (isNotSorted) { 79 | qb.orderBy(`${mainTableName}.${idAttribute}`, 'asc') 80 | } 81 | 82 | const sortedColumns = qb._statements 83 | .filter(s => s.type === 'orderByBasic') 84 | .map(({ value, direction: _direction }) => { 85 | const direction = isDesc(_direction) ? 'desc' : 'asc' 86 | const [tableName, colName] = value.split('.') 87 | if (typeof colName === 'undefined') { 88 | // not prefixed by table name 89 | return { name: tableName, direction, tableName: mainTableName } 90 | } 91 | return { name: colName, direction, tableName } 92 | }) 93 | 94 | const buildWhere = (chain, [currentCol, ...remainingCols], visitedCols = []) => { 95 | const { direction } = currentCol 96 | const index = visitedCols.length 97 | const cursorValue = cursor.columnValues[index] 98 | const cursorType = cursor.type 99 | let sign = isDesc(direction) ? '<' : '>' 100 | if (cursorType === 'before') { 101 | sign = reverseSign(sign) 102 | } 103 | const colRef = (col) => `${col.tableName}.${col.name}` 104 | 105 | // TODO: null cursor needs to be handled specially, 106 | // e.g. where somecol > 'someval' 107 | // will not show rows where somecol is null 108 | 109 | /* eslint-disable func-names */ 110 | chain.orWhere(function () { 111 | this.andWhere(function () { 112 | if (cursorValue !== null) { 113 | this.where(colRef(currentCol), sign, cursorValue) 114 | if (sign === '>') { 115 | // In PostgreSQL, `where somecol > 'abc'` does not return 116 | // rows where somecol is null. We must explicitly include them 117 | // with `where somecol is null` 118 | this.orWhere(colRef(currentCol), 'is', null) 119 | } 120 | } else if (sign === '<') { 121 | // `col < null` does not work as expected, 122 | // we use `IS NOT null` instead 123 | this.where(colRef(currentCol), 'is not', cursorValue) 124 | } else { 125 | this.where(colRef(currentCol), '>', cursorValue) 126 | } 127 | }) 128 | visitedCols.forEach((visitedCol, idx) => { 129 | const colValue = cursor.columnValues[idx] 130 | // If column is null, we have to use "IS" instead of "=" 131 | const operand = colValue === null ? 'is' : '=' 132 | this.andWhere(colRef(visitedCol), operand, colValue) 133 | }) 134 | }) 135 | if (!remainingCols.length) return 136 | return buildWhere(chain, remainingCols, [...visitedCols, currentCol]) 137 | } 138 | 139 | if (cursor) { 140 | if (sortedColumns.length !== cursor.columnValues.length) { 141 | throw new Error('sort/cursor mismatch') 142 | } 143 | 144 | qb.andWhere(function () { 145 | buildWhere(this, sortedColumns) 146 | }) 147 | 148 | // "before" is just like after if we reverse the sort order 149 | if (cursor.type === 'before') { 150 | reverseOrderBy(qb) 151 | } 152 | } 153 | 154 | // This will only work if column name === attribute name 155 | const model2cursor = (model) => { 156 | if (typeof model.toCursorValue === 'function') { 157 | return sortedColumns.map(c => model.toCursorValue(c)) 158 | } 159 | return sortedColumns.map(({ name }) => model.get(name)) 160 | } 161 | 162 | const extractCursors = (coll) => { 163 | if (!coll.length) return {} 164 | const before = model2cursor(coll.models[0]) 165 | const after = model2cursor(coll.models[coll.length - 1]) 166 | if (cursor && cursor.type === 'before') { 167 | // sort is reversed so after is before and before is after 168 | return { after: before, before: after } 169 | } 170 | return { after, before } 171 | } 172 | return (coll) => ({ 173 | cursors: extractCursors(coll), 174 | orderedBy: sortedColumns, 175 | }) 176 | } 177 | 178 | const ensureArray = (val) => { 179 | if (!Array.isArray(val)) { 180 | throw new Error(`${val} is not an array`) 181 | } 182 | } 183 | 184 | /** 185 | * Exports a plugin to pass into the bookshelf instance, i.e.: 186 | * 187 | * import config from './knexfile' 188 | * import knex from 'knex' 189 | * import bookshelf from 'bookshelf' 190 | * 191 | * const ORM = bookshelf(knex(config)) 192 | * 193 | * ORM.plugin('bookshelf-cursor-pagination') 194 | * 195 | * export default ORM 196 | * 197 | * The plugin attaches an instance methods to the bookshelf 198 | * Model object: fetchCursorPage. 199 | * 200 | * Model#fetchCursorPage works like Model#fetchAll, but returns a single page of 201 | * results instead of all results, as well as the pagination information 202 | * 203 | * See methods below for details. 204 | */ 205 | export default (bookshelf) => { 206 | /** 207 | * @method Model#fetchCursorPage 208 | * @belongsTo Model 209 | * 210 | * Similar to {@link Model#fetchAll}, but fetches a single page of results 211 | * as specified by the limit (page size) and cursor (before or after). 212 | * 213 | * Any options that may be passed to {@link Model#fetchAll} may also be passed 214 | * in the options to this method. 215 | * 216 | * To perform pagination, you may include *either* an `after` or `before` 217 | * cursor. 218 | * 219 | * By default, with no parameters or missing parameters, `fetchCursorPage` will use an 220 | * options object of `{limit: 1}` 221 | * 222 | * Below is an example showing the user of a JOIN query with sort/ordering, 223 | * pagination, and related models. 224 | * 225 | * @example 226 | * 227 | * Car 228 | * .query(function (qb) { 229 | * qb.innerJoin('manufacturers', 'cars.manufacturer_id', 'manufacturers.id') 230 | * qb.groupBy('cars.id') 231 | * qb.where('manufacturers.country', '=', 'Sweden') 232 | * }) 233 | * .orderBy('-productionYear') // Same as .orderBy('cars.productionYear', 'DESC') 234 | * .fetchCursorPage({ 235 | * limit: 15, // Defaults to 10 if not specified 236 | * after: 3, 237 | * 238 | * withRelated: ['engine'] // Passed to Model#fetchAll 239 | * }) 240 | * .then(function (results) { 241 | * console.log(results) // Paginated results object with metadata example below 242 | * }) 243 | * 244 | * // Pagination results: 245 | * 246 | * { 247 | * models: [], // Regular bookshelf Collection 248 | * // other standard Collection attributes 249 | * ... 250 | * pagination: { 251 | * rowCount: 53, // Total number of rows found for the query before pagination 252 | * limit: 15, // The requested number of rows per page 253 | * } 254 | * } 255 | * 256 | * @param options {object} 257 | * The pagination options, plus any additional options that will be passed to 258 | * {@link Model#fetchAll} 259 | * @returns {Promise} 260 | */ 261 | const fetchCursorPage = ({ 262 | self, 263 | collection, 264 | Model, 265 | }, options = {}) => { 266 | const { limit, ...fetchOptions } = options 267 | 268 | const origQuery = self.query().clone() 269 | 270 | const cursor = (() => { 271 | if (options.after) { 272 | ensureArray(options.after) 273 | return { type: 'after', columnValues: options.after } 274 | } else if (options.before) { 275 | ensureArray(options.before) 276 | return { type: 'before', columnValues: options.before } 277 | } 278 | return null 279 | })() 280 | 281 | const _limit = ensurePositiveIntWithDefault(limit, DEFAULT_LIMIT) 282 | 283 | const tableName = Model.prototype.tableName 284 | const idAttribute = Model.prototype.idAttribute ? 285 | Model.prototype.idAttribute : 'id' 286 | 287 | const paginate = () => { 288 | // const pageQuery = clone(model.query()) 289 | const pager = collection.clone() 290 | 291 | let extractCursorMetadata 292 | return pager 293 | .query(qb => { 294 | assign(qb, origQuery.clone()) 295 | extractCursorMetadata = applyCursor(qb, cursor, tableName, idAttribute) 296 | qb.limit(_limit) 297 | }) 298 | .fetch(fetchOptions) 299 | .then(coll => ({ coll, ...extractCursorMetadata(coll) })) 300 | } 301 | 302 | return Promise.all([ 303 | paginate(), 304 | count(origQuery.clone(), Model, tableName, idAttribute, _limit), 305 | ]) 306 | .then(([{ coll, cursors, orderedBy }, metadata]) => { 307 | // const pageCount = Math.ceil(metadata.rowCount / _limit) 308 | // const pageData = assign(metadata, { pageCount }) 309 | const hasMore = coll.length === limit 310 | const pageData = assign(metadata, { hasMore }) 311 | 312 | 313 | const next = () => { 314 | if (!hasMore) { 315 | return false 316 | } 317 | const newOptions = options.before ? { 318 | ...options, 319 | before: cursors.before, 320 | } : { 321 | ...options, 322 | after: cursors.after, 323 | } 324 | return fetchCursorPage({ self, collection, Model }, newOptions) 325 | } 326 | 327 | return assign(coll, { 328 | next: hasMore ? next : false, 329 | pagination: { 330 | ...pageData, 331 | cursors, 332 | orderedBy, 333 | }, 334 | }) 335 | }) 336 | } 337 | 338 | const forEach = async (context, fetchOpts, callback = () => {}) => { 339 | let coll 340 | coll = await fetchCursorPage(context, fetchOpts) 341 | /* eslint-disable no-constant-condition */ 342 | /* eslint-disable no-await-in-loop */ 343 | while (true) { 344 | await callback(coll) 345 | if (!coll.next) return 346 | coll = await coll.next() 347 | } 348 | } 349 | 350 | bookshelf.Model.prototype.fetchCursorPage = function modelFetchCursorPage(...args) { 351 | return fetchCursorPage({ 352 | self: this, 353 | Model: this.constructor, 354 | collection: () => this.collection(), 355 | }, ...args) 356 | } 357 | 358 | bookshelf.Model.fetchCursorPage = function staticModelFetchCursorPage(...args) { 359 | return this.forge().fetchCursorPage(...args) 360 | } 361 | 362 | bookshelf.Collection.prototype.fetchCursorPage = function collectionFetchCursorPage(...args) { 363 | return fetchCursorPage({ 364 | self: this, 365 | Model: this.model, 366 | collection: this, 367 | }, ...args) 368 | } 369 | 370 | bookshelf.Model.prototype.forEach = function modelForEach(...args) { 371 | return forEach({ 372 | self: this, 373 | Model: this.constructor, 374 | collection: () => this.collection(), 375 | }, ...args) 376 | } 377 | 378 | bookshelf.Model.forEach = function staticModelForEach(...args) { 379 | return this.forge().forEach(...args) 380 | } 381 | 382 | bookshelf.Collection.prototype.forEach = function collectionForEach(...args) { 383 | return forEach({ 384 | self: this, 385 | Model: this.model, 386 | collection: this, 387 | }, ...args) 388 | } 389 | } 390 | --------------------------------------------------------------------------------