Skip to content

Commit

Permalink
Abstract findPage & add pagination Bookshelf plugin
Browse files Browse the repository at this point in the history
closes #2896

- move default options / custom code into model functions
- move most of the filtering logic into base/utils.filtering (to be relocated)
- move the remainder of findPage back into base/index.js and remove from posts/users&tags
- move pagination-specific logic to a separate 'plugin' file
- pagination provides new fetchPage function, similar to fetchAll but handling pagination
- findPage model method uses fetchPage
- plugin is fully unit-tested and documented
  • Loading branch information
ErisDS committed Jun 22, 2015
1 parent 1cd703f commit 7761873
Show file tree
Hide file tree
Showing 8 changed files with 757 additions and 514 deletions.
83 changes: 76 additions & 7 deletions core/server/models/base/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,41 @@ var _ = require('lodash'),
utils = require('../../utils'),
uuid = require('node-uuid'),
validation = require('../../data/validation'),
baseUtils = require('./utils'),
pagination = require('./pagination'),

ghostBookshelf;

// ### ghostBookshelf
// Initializes a new Bookshelf instance called ghostBookshelf, for reference elsewhere in Ghost.
ghostBookshelf = bookshelf(config.database.knex);

// Load the registry plugin, which helps us avoid circular dependencies
// Load the Bookshelf registry plugin, which helps us avoid circular dependencies
ghostBookshelf.plugin('registry');

// ### ghostBookshelf.Model
// Load the Ghost pagination plugin, which gives us the `fetchPage` method on Models
ghostBookshelf.plugin(pagination);

// ## ghostBookshelf.Model
// The Base Model which other Ghost objects will inherit from,
// including some convenience functions as static properties on the model.
ghostBookshelf.Model = ghostBookshelf.Model.extend({

// Bookshelf `hasTimestamps` - handles created_at and updated_at properties
hasTimestamps: true,

// Get permitted attributes from server/data/schema.js, which is where the DB schema is defined
// Ghost option handling - get permitted attributes from server/data/schema.js, where the DB schema is defined
permittedAttributes: function permittedAttributes() {
return _.keys(schema.tables[this.tableName]);
},

// Bookshelf `defaults` - default values setup on every model creation
defaults: function defaults() {
return {
uuid: uuid.v4()
};
},

// Bookshelf `initialize` - declare a constructor-like method for model creation
initialize: function initialize() {
var self = this,
options = arguments[1] || {};
Expand Down Expand Up @@ -174,7 +181,6 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
updated: function updated(attr) {
return this.updatedAttributes()[attr];
}

}, {
// ## Data Utility Functions

Expand Down Expand Up @@ -221,9 +227,9 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
* @param {Object} options (optional)
* @return {Promise(ghostBookshelf.Collection)} Collection of all Models
*/
findAll: function findAll(options) {
findAll: function findAll(options) {
options = this.filterOptions(options, 'findAll');
return ghostBookshelf.Collection.forge([], {model: this}).fetch(options).then(function then(result) {
return this.forge().fetchAll(options).then(function then(result) {
if (options.include) {
_.each(result.models, function each(item) {
item.include = options.include;
Expand All @@ -233,6 +239,69 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
});
},

/**
* ### Find Page
* Find results by page - returns an object containing the
* information about the request (page, limit), along with the
* info needed for pagination (pages, total).
*
* **response:**
*
* {
* posts: [
* {...}, ...
* ],
* page: __,
* limit: __,
* pages: __,
* total: __
* }
*
* @param {Object} options
*/
findPage: function findPage(options) {
options = options || {};

var self = this,
itemCollection = this.forge(),
tableName = _.result(this.prototype, 'tableName'),
filterObjects = self.setupFilters(options);

// Filter options so that only permitted ones remain
options = this.filterOptions(options, 'findPage');

// Extend the model defaults
options = _.defaults(options, this.findPageDefaultOptions());

// Run specific conversion of model query options to where options
options = this.processOptions(itemCollection, options);

// Prefetch filter objects
return Promise.all(baseUtils.filtering.preFetch(filterObjects)).then(function doQuery() {
// If there are `where` conditionals specified, add those to the query.
if (options.where) {
itemCollection.query('where', options.where);
}

// Setup filter joins / queries
baseUtils.filtering.query(filterObjects, itemCollection);

// Handle related objects
// TODO: this should just be done for all methods @ the API level
options.withRelated = _.union(options.withRelated, options.include);

options.order = self.orderDefaultOptions();

return itemCollection.fetchPage(options).then(function formatResponse(response) {
var data = {};
data[tableName] = response.collection.toJSON(options);
data.meta = {pagination: response.pagination};

return baseUtils.filtering.formatResponse(filterObjects, options, data);
});
}).catch(errors.logAndThrowError);
},

/**
* ### Find One
* Naive find one where data determines what to match on
Expand Down
185 changes: 185 additions & 0 deletions core/server/models/base/pagination.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// # Pagination
//
// Extends Bookshelf.Model with a `fetchPage` method. Handles everything to do with paginated requests.
var _ = require('lodash'),
Promise = require('bluebird'),

defaults,
paginationUtils,
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, Bookshelf.Collection} itemCollection
* @param {options} options
*/
query: function query(itemCollection, options) {
if (_.isNumber(options.limit)) {
itemCollection
.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) {
var calcPages = Math.ceil(totalItems / options.limit) || 0,
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
* @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
var tableName = _.result(this.constructor.prototype, 'tableName'),
idAttribute = _.result(this.constructor.prototype, 'idAttribute'),
// Create a new collection for running `this` query, ensuring we're definitely using collection,
// rather than model
collection = this.constructor.collection(),
// Clone the base query & set up a promise to get the count of total items in the full set
countPromise = this.query().clone().count(tableName + '.' + idAttribute + ' as aggregate'),
collectionPromise;

// Clone the base query into our collection
collection._knex = this.query().clone();

// Setup the pagination parameters so that we return the correct items from the set
paginationUtils.query(collection, options);

// Apply ordering options if they are present
// This is an optimisation, adding order before cloning for the count query would mean the count query
// was also ordered, when that is unnecessary.
if (options.order) {
_.forOwn(options.order, function (direction, property) {
collection.query('orderBy', tableName + '.' + property, direction);
});
}

// 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
collectionPromise = collection.fetch(_.omit(options, ['page', 'limit']));

// Resolve the two promises
return Promise.join(collectionPromise, countPromise).then(function formatResponse(results) {
// Format the collection & count result into `{collection: [], pagination: {}}`
return {
collection: results[0],
pagination: paginationUtils.formatResponse(results[1][0].aggregate, options)
};
});
}
});
};

/**
* ## Export pagination plugin
* @api public
*/
module.exports = pagination;
66 changes: 40 additions & 26 deletions core/server/models/base/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,49 @@
* # Utils
* Parts of the model code which can be split out and unit tested
*/
var _ = require('lodash'),
filtering;

/**
* Takes the number of items returned and original options and calculates all of the pagination meta data
* TODO: Could be moved to either middleware or a bookshelf plugin?
* @param {Number} totalItems
* @param {Object} options
* @returns {Object} pagination
*/
module.exports.paginateResponse = function paginateResponse(totalItems, options) {
var calcPages = Math.ceil(totalItems / options.limit) || 0,
pagination = {};
filtering = {
preFetch: function preFetch(filterObjects) {
var promises = [];
_.forOwn(filterObjects, function (obj) {
promises.push(obj.fetch());
});

pagination.page = options.page || 1;
pagination.limit = options.limit;
pagination.pages = calcPages === 0 ? 1 : calcPages;
pagination.total = totalItems;
pagination.next = null;
pagination.prev = null;
return promises;
},
query: function query(filterObjects, itemCollection) {
if (filterObjects.tags) {
itemCollection
.query('join', 'posts_tags', 'posts_tags.post_id', '=', 'posts.id')
.query('where', 'posts_tags.tag_id', '=', filterObjects.tags.id);
}

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;
if (filterObjects.author) {
itemCollection
.query('where', 'author_id', '=', filterObjects.author.id);
}
}

return pagination;
if (filterObjects.roles) {
itemCollection
.query('join', 'roles_users', 'roles_users.user_id', '=', 'users.id')
.query('where', 'roles_users.role_id', '=', filterObjects.roles.id);
}
},
formatResponse: function formatResponse(filterObjects, options, data) {
if (!_.isEmpty(filterObjects)) {
data.meta.filters = {};
}

_.forOwn(filterObjects, function (obj, key) {
if (!filterObjects[key].isNew()) {
data.meta.filters[key] = [filterObjects[key].toJSON(options)];
}
});

return data;
}
};

module.exports.filtering = filtering;
Loading

0 comments on commit 7761873

Please sign in to comment.