mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-10 23:18:42 -05:00
refs #11729 - When ordering is done by fields from a relation (like post's `meta_title` that comes form `posts_meta` table), Bookshelf does not include those relations in the original query which caused errors. To support this usecase added a mechanism to detect fields from a relation and load those relations into query. - Extended ordering to include table name in ordered field name. The information about the table name is needed to avoid using `tableName` within pagination plugin and gives path to having other than original table ordering fields (e.g. order by posts_meta table fields) - Added test case to check ordering on posts_meta fields - Added support for "eager loading" relations. Allows to extend query builder object with joins to related tables, which could be used in ordering (possibly in filtering later). Bookshelf does not support ordering/filtering by proprieties coming from relations, that's why this kind of plugin and query expansion is needed - Added note about lack of support for child relations with same property names.
264 lines
9.3 KiB
JavaScript
264 lines
9.3 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;
|
|
},
|
|
|
|
/**
|
|
*
|
|
* @param {Bookshelf.Model} model instance of Bookshelf model
|
|
* @param {string} propertyName property to be inspected and included in the relation
|
|
*/
|
|
handleRelation: function handleRelation(model, propertyName) {
|
|
const tableName = _.result(model.constructor.prototype, 'tableName');
|
|
|
|
const targetTable = propertyName.includes('.') && propertyName.split('.')[0];
|
|
|
|
if (targetTable && targetTable !== tableName) {
|
|
if (!model.eagerLoad) {
|
|
model.eagerLoad = [];
|
|
}
|
|
|
|
if (!model.eagerLoad.includes(targetTable)) {
|
|
model.eagerLoad.push(targetTable);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// ## 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', property, direction);
|
|
|
|
paginationUtils.handleRelation(self, property);
|
|
}
|
|
});
|
|
} 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;
|