Ghost/core/server/models/plugins/pagination.js
Kevin Ansfield 5e64f113d5 Skipped separate count query in .findPage() for limit="all" requests
no issue

- for large result sets or complex queries the count query itself can be quite time consuming
- when `limit: 'all'` is passed as an option there's no need to perform a separate count query because we can determine the pagination data from the final result set
- skipped count query when `limit: 'all'` option is present
- re-ordered comments to be closer to the code they reference (ie, why we have our own count query instead of Bookshelf's `.count()`
2020-08-27 01:09:07 +01:00

241 lines
8.5 KiB
JavaScript

// # Pagination
//
// Extends Bookshelf.Model with a `fetchPage` method. Handles everything to do with paginated requests.
const _ = require('lodash');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
let defaults;
let paginationUtils;
let pagination;
/**
* ### Default pagination values
* These are overridden via `options` passed to each function
* @typedef {Object} defaults
* @default
* @property {Number} `page` \- page in set to display (default: 1)
* @property {Number|String} `limit` \- no. results per page (default: 15)
*/
defaults = {
page: 1,
limit: 15
};
/**
* ## Pagination Utils
* @api private
* @type {{parseOptions: Function, query: Function, formatResponse: Function}}
*/
paginationUtils = {
/**
* ### Parse Options
* Take the given options and ensure they are valid pagination options, else use the defaults
* @param {options} options
* @returns {options} options sanitised for pagination
*/
parseOptions: function parseOptions(options) {
options = _.defaults(options || {}, defaults);
if (options.limit !== 'all') {
options.limit = parseInt(options.limit, 10) || defaults.limit;
}
options.page = parseInt(options.page, 10) || defaults.page;
return options;
},
/**
* ### Query
* Apply the necessary parameters to paginate the query
* @param {bookshelf.Model} model
* @param {options} options
*/
addLimitAndOffset: function addLimitAndOffset(model, options) {
if (_.isNumber(options.limit)) {
model
.query('limit', options.limit)
.query('offset', options.limit * (options.page - 1));
}
},
/**
* ### Format Response
* Takes the no. items returned and original options and calculates all of the pagination meta data
* @param {Number} totalItems
* @param {options} options
* @returns {pagination} pagination metadata
*/
formatResponse: function formatResponse(totalItems, options) {
const calcPages = Math.ceil(totalItems / options.limit) || 0;
const pagination = {
page: options.page || defaults.page,
limit: options.limit,
pages: calcPages === 0 ? 1 : calcPages,
total: totalItems,
next: null,
prev: null
};
if (pagination.pages > 1) {
if (pagination.page === 1) {
pagination.next = pagination.page + 1;
} else if (pagination.page === pagination.pages) {
pagination.prev = pagination.page - 1;
} else {
pagination.next = pagination.page + 1;
pagination.prev = pagination.page - 1;
}
}
return pagination;
}
};
// ## Object Definitions
/**
* ### Pagination Object
* @typedef {Object} pagination
* @property {Number} page \- page in set to display
* @property {Number|String} limit \- no. results per page, or 'all'
* @property {Number} pages \- total no. pages in the full set
* @property {Number} total \- total no. items in the full set
* @property {Number|null} next \- next page
* @property {Number|null} prev \- previous page
*/
/**
* ### Fetch Page Options
* @typedef {Object} options
* @property {Number} page \- page in set to display
* @property {Number|String} limit \- no. results per page, or 'all'
* @property {Object} order \- set of order by params and directions
*/
/**
* ### Fetch Page Response
* @typedef {Object} paginatedResult
* @property {Array} collection \- set of results
* @property {pagination} pagination \- pagination metadata
*/
/**
* ## Pagination
* Extends `bookshelf.Model` with `fetchPage`
* @param {Bookshelf} bookshelf \- the instance to plug into
*/
pagination = function pagination(bookshelf) {
// Extend updates the first object passed to it, no need for an assignment
_.extend(bookshelf.Model.prototype, {
/**
* ### Fetch page
* A `fetch` extension to get a paginated set of items from a collection
*
* We trigger two queries:
* 1. count query to know how many pages left (important: we don't attach any group/order statements!)
* 2. the actualy fetch query with limit and page property
*
* @param {options} options
* @returns {paginatedResult} set of results + pagination metadata
*/
fetchPage: function fetchPage(options) {
// Setup pagination options
options = paginationUtils.parseOptions(options);
// Get the table name and idAttribute for this model
const tableName = _.result(this.constructor.prototype, 'tableName');
const idAttribute = _.result(this.constructor.prototype, 'idAttribute');
const self = this;
// #### Pre count clauses
// Add any where or join clauses which need to be included with the aggregate query
// Clone the base query & set up a promise to get the count of total items in the full set
// Necessary due to lack of support for `count distinct` in bookshelf's count()
// Skipped if limit='all' as we can use the length of the fetched data set
let countPromise = Promise.resolve();
if (options.limit !== 'all') {
const countQuery = this.query().clone();
if (options.transacting) {
countQuery.transacting(options.transacting);
}
countPromise = countQuery.select(
bookshelf.knex.raw('count(distinct ' + tableName + '.' + idAttribute + ') as aggregate')
);
}
return countPromise.then(function (countResult) {
// #### Post count clauses
// Add any where or join clauses which need to NOT be included with the aggregate query
// Setup the pagination parameters so that we return the correct items from the set
paginationUtils.addLimitAndOffset(self, options);
// Apply ordering options if they are present
if (options.order && !_.isEmpty(options.order)) {
_.forOwn(options.order, function (direction, property) {
if (property === 'count.posts') {
self.query('orderBy', 'count__posts', direction);
} else {
self.query('orderBy', tableName + '.' + property, direction);
}
});
} else if (options.orderRaw) {
self.query(function (qb) {
qb.orderByRaw(options.orderRaw);
});
}
if (options.groups && !_.isEmpty(options.groups)) {
_.each(options.groups, function (group) {
self.query('groupBy', group);
});
}
// Setup the promise to do a fetch on our collection, running the specified query
// @TODO: ensure option handling is done using an explicit pick elsewhere
return self.fetchAll(_.omit(options, ['page', 'limit']))
.then(function (fetchResult) {
if (options.limit === 'all') {
countResult = [{aggregate: fetchResult.length}];
}
return {
collection: fetchResult,
pagination: paginationUtils.formatResponse(countResult[0] ? countResult[0].aggregate : 0, options)
};
})
.catch(function (err) {
// e.g. offset/limit reached max allowed integer value
if (err.errno === 20 || err.errno === 1064) {
throw new errors.NotFoundError({message: i18n.t('errors.errors.pageNotFound')});
}
throw err;
});
}).catch((err) => {
// CASE: SQL syntax is incorrect
if (err.errno === 1054 || err.errno === 1) {
throw new errors.BadRequestError({
message: i18n.t('errors.models.general.sql'),
err: err
});
}
throw err;
});
}
});
};
/**
* ## Export pagination plugin
* @api public
*/
module.exports = pagination;