0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Extracted Bookshelf plugins from Ghost to Framework repo

refs:
  - cf15f60085
  - dd20cc649b
  - ccf27f7009
  - abf146d61f
  - 2b54c92a14
  - bb029a53f6
  - 95bd7ee675
  - 9018b4df22
  - df01a6e5f4
  - d313726b34

- these plugins were in a state where they were independent enough to be
  pulled out into their own packages, which is what we did in the
  referenced commits above
- each package is named like `@tryghost/bookshelf-<plugin>`
- to avoid requiring multiple packages into Ghost, we've also created a
  wrapper package called `@tryghost/bookshelf-plugins` which re-exports
  all these plugins, so the changes in Ghost are very simple - dbebdd43b5
- this commit deletes the plugins + tests, and replaces with our new
  package with some minor code changes
This commit is contained in:
Daniel Lockyer 2021-06-15 11:57:47 +01:00
parent 4e49aeb9a0
commit 495ef867c2
15 changed files with 106 additions and 1290 deletions

View file

@ -22,7 +22,7 @@ const security = require('@tryghost/security');
const schema = require('../../data/schema');
const urlUtils = require('../../../shared/url-utils');
const bulkOperations = require('./bulk-operations');
const plugins = require('../plugins');
const plugins = require('@tryghost/bookshelf-plugins');
const tpl = require('@tryghost/tpl');
const messages = {
@ -1307,7 +1307,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
nql(filter).querySQL(query);
if (shouldHavePosts) {
require('../plugins/has-posts').addHasPostsWhere(tableNames[modelName], shouldHavePosts)(query);
plugins.hasPosts.addHasPostsWhere(tableNames[modelName], shouldHavePosts)(query);
}
if (options.id) {

View file

@ -1,95 +0,0 @@
const moment = require('moment-timezone');
const _ = require('lodash');
const errors = require('@tryghost/errors');
/**
* @param {import('bookshelf')} Bookshelf
*/
module.exports = function (Bookshelf) {
const ParentModel = Bookshelf.Model;
const Model = Bookshelf.Model.extend({
/**
* Update collision protection.
*
* IMPORTANT NOTES:
* The `sync` method is called for any query e.g. update, add, delete, fetch
*
* We had the option to override Bookshelf's `save` method, but hooking into the `sync` method gives us
* the ability to access the `changed` object. Bookshelf already knows which attributes has changed.
*
* Bookshelf's timestamp function can't be overridden, as it's synchronous, there is no way to return an Error.
*
* If we want to enable the collision plugin for other tables, the queries might need to run in a transaction.
* This depends on if we fetch the model before editing. Imagine two concurrent requests come in, both would fetch
* the same current database values and both would succeed to update and override each other.
*/
sync: function timestamp(options) {
const parentSync = ParentModel.prototype.sync.apply(this, arguments);
const originalUpdateSync = parentSync.update;
const self = this;
// CASE: only enabled for posts table
if (this.tableName !== 'posts' ||
!self.serverData ||
((options.method !== 'update' && options.method !== 'patch') || !options.method)
) {
return parentSync;
}
/**
* Only hook into the update sync
*
* IMPORTANT NOTES:
* Even if the client sends a different `id` property, it get's ignored by bookshelf.
* Because you can't change the `id` of an existing post.
*
* HTML is always auto generated, ignore.
*/
parentSync.update = async function update() {
const response = await originalUpdateSync.apply(this, arguments);
const changed = _.omit(self._changed, [
'created_at', 'updated_at', 'author_id', 'id',
'published_by', 'updated_by', 'html', 'plaintext'
]);
const clientUpdatedAt = moment(self.clientData.updated_at || self.serverData.updated_at || new Date());
const serverUpdatedAt = moment(self.serverData.updated_at || clientUpdatedAt);
if (Object.keys(changed).length) {
if (clientUpdatedAt.diff(serverUpdatedAt) !== 0) {
// @NOTE: This will rollback the update. We cannot know if relations were updated before doing the update.
throw new errors.UpdateCollisionError({
message: 'Saving failed! Someone else is editing this post.',
code: 'UPDATE_COLLISION',
level: 'critical',
errorDetails: {
clientUpdatedAt: self.clientData.updated_at,
serverUpdatedAt: self.serverData.updated_at
}
});
}
}
return response;
};
return parentSync;
},
/**
* We have to remember current server data and client data.
* The `sync` method has no access to it.
* `updated_at` is already set to "Date.now" when the overridden `sync.update` is called.
* See https://github.com/tgriesser/bookshelf/blob/79c526870e618748caf94e7476a0bc796ee090a6/src/model.js#L955
*/
save: function save(data) {
this.clientData = _.cloneDeep(data) || {};
this.serverData = _.cloneDeep(this.attributes);
return ParentModel.prototype.save.apply(this, arguments);
}
});
Bookshelf.Model = Model;
};

View file

@ -1,19 +0,0 @@
/**
* @param {import('bookshelf')} Bookshelf
*/
const customQueryPlug = function customQueryPlug(Bookshelf) {
const Model = Bookshelf.Model.extend({
// override this on the model itself
customQuery() {},
applyCustomQuery: function applyCustomQuery(options) {
this.query((qb) => {
this.customQuery(qb, options);
});
}
});
Bookshelf.Model = Model;
};
module.exports = customQueryPlug;

View file

@ -1,82 +0,0 @@
const _ = require('lodash');
const _debug = require('ghost-ignition').debug._base;
const debug = _debug('ghost-query');
/**
* Enchances knex query builder with a join to relation configured in
*
* @param {Bookshelf['Model']} model instance of Bookshelf model
* @param {String[]} relationsToLoad relations to be included in joins
*/
function withEager(model, relationsToLoad) {
const tableName = _.result(model.constructor.prototype, 'tableName');
return function (qb) {
if (!model.relationsMeta) {
return qb;
}
for (const [key, config] of Object.entries(model.relationsMeta)) {
if (relationsToLoad.includes(key)) {
const innerQb = qb
.leftJoin(config.targetTableName, `${tableName}.id`, `${config.targetTableName}.${config.foreignKey}`);
debug(`QUERY has posts: ${innerQb.toSQL().sql}`);
}
}
return qb;
};
}
function load(options) {
if (!options) {
return;
}
if (this.eagerLoad) {
if (!options.columns && options.withRelated && _.intersection(this.eagerLoad, options.withRelated).length) {
this.query(withEager(this, this.eagerLoad));
}
}
}
/**
* ## Pagination
* Extends `bookshelf.Model` native `fetch` and `fetchAll` methods with
* a join to "eager loaded" relation. An exaple of such loading is when
* there is a need to order by fields in the related table.
*/
module.exports = function eagerLoadPlugin(Bookshelf) {
const modelPrototype = Bookshelf.Model.prototype;
Bookshelf.Model = Bookshelf.Model.extend({
initialize: function () {
return modelPrototype.initialize.apply(this, arguments);
},
fetch: function () {
load.apply(this, arguments);
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
return modelPrototype.fetch.apply(this, arguments);
},
fetchAll: function () {
load.apply(this, arguments);
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
return modelPrototype.fetchAll.apply(this, arguments);
}
});
};
/**
* @typedef {import('bookshelf')} Bookshelf
*/

View file

@ -1,157 +0,0 @@
const debug = require('ghost-ignition').debug('models:plugins:filter');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const messages = {
errorParsing: 'Error parsing filter'
};
const RELATIONS = {
tags: {
tableName: 'tags',
type: 'manyToMany',
joinTable: 'posts_tags',
joinFrom: 'post_id',
joinTo: 'tag_id'
},
authors: {
tableName: 'users',
tableNameAs: 'authors',
type: 'manyToMany',
joinTable: 'posts_authors',
joinFrom: 'post_id',
joinTo: 'author_id'
},
labels: {
tableName: 'labels',
type: 'manyToMany',
joinTable: 'members_labels',
joinFrom: 'member_id',
joinTo: 'label_id'
},
products: {
tableName: 'products',
type: 'manyToMany',
joinTable: 'members_products',
joinFrom: 'member_id',
joinTo: 'product_id'
},
posts_meta: {
tableName: 'posts_meta',
type: 'oneToOne',
joinFrom: 'post_id'
}
};
const EXPANSIONS = {
posts: [{
key: 'primary_tag',
replacement: 'tags.slug',
expansion: 'posts_tags.sort_order:0+tags.visibility:public'
}, {
key: 'primary_author',
replacement: 'authors.slug',
expansion: 'posts_authors.sort_order:0+authors.visibility:public'
}, {
key: 'authors',
replacement: 'authors.slug'
}, {
key: 'author',
replacement: 'authors.slug'
}, {
key: 'tag',
replacement: 'tags.slug'
}, {
key: 'tags',
replacement: 'tags.slug'
}],
members: [{
key: 'label',
replacement: 'labels.slug'
}, {
key: 'labels',
replacement: 'labels.slug'
}, {
key: 'product',
replacement: 'products.slug'
}, {
key: 'products',
replacement: 'products.slug'
}]
};
/**
* @param {import('bookshelf')} Bookshelf
*/
const filter = function filter(Bookshelf) {
const Model = Bookshelf.Model.extend({
// Cached copy of the filters setup for this model instance
_filters: null,
// Override these on the various models
enforcedFilters() {},
defaultFilters() {},
extraFilters() {},
filterExpansions() {},
/**
* Method which makes the necessary query builder calls (through knex) for the filters set on this model
* instance.
*/
applyDefaultAndCustomFilters: function applyDefaultAndCustomFilters(options) {
const nql = require('@nexes/nql');
const expansions = [];
if (EXPANSIONS[this.tableName]) {
expansions.push(...EXPANSIONS[this.tableName]);
}
if (this.filterExpansions()) {
expansions.push(...this.filterExpansions());
}
let custom = options.filter;
let extra = this.extraFilters(options);
let overrides = this.enforcedFilters(options);
let defaults = this.defaultFilters(options);
let transformer = options.mongoTransformer;
debug('custom', custom);
debug('extra', extra);
debug('enforced', overrides);
debug('default', defaults);
if (extra) {
if (custom) {
custom = `${custom}+${extra}`;
} else {
custom = extra;
}
}
try {
this.query((qb) => {
nql(custom, {
relations: RELATIONS,
expansions: expansions,
overrides: overrides,
defaults: defaults,
transformer: transformer
}).querySQL(qb);
});
} catch (err) {
throw new errors.BadRequestError({
message: tpl(messages.errorParsing),
err
});
}
}
});
Bookshelf.Model = Model;
};
/**
* ## Export Filter plugin
* @api public
*/
module.exports = filter;

View file

@ -1,63 +0,0 @@
const _ = require('lodash');
const _debug = require('ghost-ignition').debug._base;
const debug = _debug('ghost-query');
const addHasPostsWhere = (tableName, config) => {
const comparisonField = `${tableName}.id`;
return function (qb) {
return qb.whereIn(comparisonField, function () {
const innerQb = this
.distinct(`${config.joinTable}.${config.joinTo}`)
.select()
.from(config.joinTable)
.whereRaw(`${config.joinTable}.${config.joinTo} = ${comparisonField}`)
.join('posts', 'posts.id', `${config.joinTable}.post_id`)
.andWhere('posts.status', '=', 'published');
debug(`QUERY has posts: ${innerQb.toSQL().sql}`);
return innerQb;
});
};
};
/**
* @param {import('bookshelf')} Bookshelf
*/
const hasPosts = function hasPosts(Bookshelf) {
const modelPrototype = Bookshelf.Model.prototype;
Bookshelf.Model = Bookshelf.Model.extend({
initialize: function () {
return modelPrototype.initialize.apply(this, arguments);
},
fetch: function () {
if (this.shouldHavePosts) {
this.query(addHasPostsWhere(_.result(this, 'tableName'), this.shouldHavePosts));
}
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
return modelPrototype.fetch.apply(this, arguments);
},
fetchAll: function () {
if (this.shouldHavePosts) {
this.query(addHasPostsWhere(_.result(this, 'tableName'), this.shouldHavePosts));
}
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
return modelPrototype.fetchAll.apply(this, arguments);
}
});
};
module.exports = hasPosts;
module.exports.addHasPostsWhere = addHasPostsWhere;

View file

@ -1,106 +0,0 @@
const _debug = require('ghost-ignition').debug._base;
const debug = _debug('ghost-query');
const _ = require('lodash');
/**
* @param {import('bookshelf')} Bookshelf
*/
module.exports = function (Bookshelf) {
const modelProto = Bookshelf.Model.prototype;
const countQueryBuilder = {
tags: {
posts: function addPostCountToTags(model, options) {
model.query('columns', 'tags.*', function (qb) {
qb.count('posts.id')
.from('posts')
.leftOuterJoin('posts_tags', 'posts.id', 'posts_tags.post_id')
.whereRaw('posts_tags.tag_id = tags.id')
.as('count__posts');
if (options.context && options.context.public) {
// @TODO use the filter behavior for posts
qb.andWhere('posts.type', '=', 'post');
qb.andWhere('posts.status', '=', 'published');
}
});
}
},
users: {
posts: function addPostCountToUsers(model, options) {
model.query('columns', 'users.*', function (qb) {
qb.count('posts.id')
.from('posts')
.join('posts_authors', 'posts.id', 'posts_authors.post_id')
.whereRaw('posts_authors.author_id = users.id')
.as('count__posts');
if (options.context && options.context.public) {
// @TODO use the filter behavior for posts
qb.andWhere('posts.type', '=', 'post');
qb.andWhere('posts.status', '=', 'published');
}
});
}
}
};
const Model = Bookshelf.Model.extend({
addCounts: function (options) {
if (!options) {
return;
}
const tableName = _.result(this, 'tableName');
if (options.withRelated && options.withRelated.indexOf('count.posts') > -1) {
// remove post_count from withRelated
options.withRelated = _.pull([].concat(options.withRelated), 'count.posts');
// Call the query builder
countQueryBuilder[tableName].posts(this, options);
}
},
fetch: function () {
this.addCounts.apply(this, arguments);
// Useful when debugging no. database queries, GQL, etc
// To output this, use DEBUG=ghost:*,ghost-query
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
// Call parent fetch
return modelProto.fetch.apply(this, arguments);
},
fetchAll: function () {
this.addCounts.apply(this, arguments);
// Useful when debugging no. database queries, GQL, etc
// To output this, use DEBUG=ghost:*,ghost-query
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
// Call parent fetchAll
return modelProto.fetchAll.apply(this, arguments);
},
serialize: function serialize(options) {
const attrs = modelProto.serialize.call(this, options);
const countRegex = /^(count)(__)(.*)$/;
_.forOwn(attrs, function (value, key) {
const match = key.match(countRegex);
if (match) {
attrs[match[1]] = attrs[match[1]] || {};
attrs[match[1]][match[3]] = value;
delete attrs[key];
}
});
return attrs;
}
});
Bookshelf.Model = Model;
};

View file

@ -1,12 +0,0 @@
module.exports = {
eagerLoad: require('./eager-load'),
filter: require('./filter'),
order: require('./order'),
customQuery: require('./custom-query'),
search: require('./search'),
includeCount: require('./include-count'),
pagination: require('./pagination'),
collision: require('./collision'),
transactionEvents: require('./transaction-events'),
hasPosts: require('./has-posts')
};

View file

@ -1,80 +0,0 @@
const _ = require('lodash');
/**
* @param {import('bookshelf')} Bookshelf
*/
const orderPlugin = function orderPlugin(Bookshelf) {
Bookshelf.Model = Bookshelf.Model.extend({
orderAttributes() {},
orderRawQuery() {},
parseOrderOption: function (orderQueryString, withRelated) {
const order = {};
const orderRaw = [];
const eagerLoadArray = [];
const orderAttributes = this.orderAttributes();
if (withRelated && withRelated.indexOf('count.posts') > -1) {
orderAttributes.push('count.posts');
}
let rules = [];
// CASE: repeat order query parameter keys are present
if (_.isArray(orderQueryString)) {
orderQueryString.forEach((qs) => {
rules.push(...qs.split(','));
});
} else {
rules = orderQueryString.split(',');
}
_.each(rules, (rule) => {
let match;
let field;
let direction;
match = /^([a-z0-9_.]+)\s+(asc|desc)$/i.exec(rule.trim());
// invalid order syntax
if (!match) {
return;
}
field = match[1].toLowerCase();
direction = match[2].toUpperCase();
const orderRawQuery = this.orderRawQuery(field, direction, withRelated);
if (orderRawQuery) {
orderRaw.push(orderRawQuery.orderByRaw);
if (orderRawQuery.eagerLoad) {
eagerLoadArray.push(orderRawQuery.eagerLoad);
}
return;
}
const matchingOrderAttribute = orderAttributes.find((orderAttribute) => {
// NOTE: this logic assumes we use different field names for "parent" and "child" relations.
// E.g.: ['parent.title', 'child.title'] and ['child.title', 'parent.title'] - would not
// distinguish on which relation to sort neither which order to pick the fields on.
// For more context see: https://github.com/TryGhost/Ghost/pull/12226#discussion_r493085098
return orderAttribute.endsWith(field);
});
if (!matchingOrderAttribute) {
return;
}
order[matchingOrderAttribute] = direction;
});
return {
order,
orderRaw: orderRaw.join(', '),
eagerLoad: _.uniq(eagerLoadArray)
};
}
});
};
module.exports = orderPlugin;

View file

@ -1,278 +0,0 @@
// # Pagination
//
// Extends Bookshelf.Model with a `fetchPage` method. Handles everything to do with paginated requests.
const _ = require('lodash');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const messages = {
pageNotFound: 'Page not found',
couldNotUnderstandRequest: 'Could not understand request.'
};
let defaults;
let paginationUtils;
/**
* ### 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
*/
paginationUtils = {
/**
* ### Parse Options
* Take the given options and ensure they are valid pagination options, else use the defaults
* @param {Object} options
* @returns {Object} 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 {Object} 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 {Object} 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
*/
const 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 {Object} options
* @returns {Promise<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);
}
});
}
if (options.orderRaw) {
self.query((qb) => {
qb.orderByRaw(options.orderRaw);
});
}
if (!_.isEmpty(options.eagerLoad)) {
options.eagerLoad.forEach(property => paginationUtils.handleRelation(self, property));
}
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: tpl(messages.pageNotFound)});
}
throw err;
});
}).catch((err) => {
// CASE: SQL syntax is incorrect
if (err.errno === 1054 || err.errno === 1) {
throw new errors.BadRequestError({
message: tpl(messages.couldNotUnderstandRequest),
err
});
}
throw err;
});
}
});
};
/**
* @typedef {import('bookshelf')} Bookshelf
*/
/**
* ## Export pagination plugin
* @api public
*/
module.exports = pagination;

View file

@ -1,18 +0,0 @@
const searchPlugin = function searchPlugin(Bookshelf) {
const Model = Bookshelf.Model.extend({
// override this on the model itself
searchQuery() {},
applySearchQuery: function applySearchQuery(options) {
if (options.search) {
this.query((qb) => {
this.searchQuery(qb, options.search);
});
}
}
});
Bookshelf.Model = Model;
};
module.exports = searchPlugin;

View file

@ -1,26 +0,0 @@
/**
* This is a feature request in knex for 1.0.
* https://github.com/tgriesser/knex/issues/1641
*/
module.exports = function (bookshelf) {
const orig1 = bookshelf.transaction;
bookshelf.transaction = function (cb) {
return orig1.bind(bookshelf)(function (t) {
const orig2 = t.commit;
const orig3 = t.rollback;
t.commit = function () {
t.emit('committed', true);
return orig2.apply(t, arguments);
};
t.rollback = function () {
t.emit('committed', false);
return orig3.apply(t, arguments);
};
return cb(t);
});
};
};

View file

@ -44,6 +44,7 @@
"@sentry/node": "6.7.0",
"@tryghost/adapter-manager": "0.2.13",
"@tryghost/admin-api-schema": "2.3.0",
"@tryghost/bookshelf-plugins": "0.1.1",
"@tryghost/bootstrap-socket": "0.2.9",
"@tryghost/constants": "0.1.8",
"@tryghost/email-analytics-provider-mailgun": "1.0.0",

View file

@ -1,350 +0,0 @@
const should = require('should');
const sinon = require('sinon');
const Promise = require('bluebird');
const rewire = require('rewire');
const pagination = rewire('../../../../core/server/models/plugins/pagination');
describe('pagination', function () {
let paginationUtils;
afterEach(function () {
sinon.restore();
});
describe('paginationUtils', function () {
before(function () {
paginationUtils = pagination.__get__('paginationUtils');
});
describe('formatResponse', function () {
let formatResponse;
before(function () {
formatResponse = paginationUtils.formatResponse;
});
it('returns correct pagination object for single page', function () {
formatResponse(5, {limit: 10, page: 1}).should.eql({
limit: 10,
next: null,
page: 1,
pages: 1,
prev: null,
total: 5
});
});
it('returns correct pagination object for first page of many', function () {
formatResponse(44, {limit: 5, page: 1}).should.eql({
limit: 5,
next: 2,
page: 1,
pages: 9,
prev: null,
total: 44
});
});
it('returns correct pagination object for middle page of many', function () {
formatResponse(44, {limit: 5, page: 9}).should.eql({
limit: 5,
next: null,
page: 9,
pages: 9,
prev: 8,
total: 44
});
});
it('returns correct pagination object for last page of many', function () {
formatResponse(44, {limit: 5, page: 3}).should.eql({
limit: 5,
next: 4,
page: 3,
pages: 9,
prev: 2,
total: 44
});
});
it('returns correct pagination object when page not set', function () {
formatResponse(5, {limit: 10}).should.eql({
limit: 10,
next: null,
page: 1,
pages: 1,
prev: null,
total: 5
});
});
it('returns correct pagination object for limit all', function () {
formatResponse(5, {limit: 'all'}).should.eql({
limit: 'all',
next: null,
page: 1,
pages: 1,
prev: null,
total: 5
});
});
});
describe('parseOptions', function () {
let parseOptions;
before(function () {
parseOptions = paginationUtils.parseOptions;
});
it('should use defaults if no options are passed', function () {
parseOptions().should.eql({
limit: 15,
page: 1
});
});
it('should accept numbers for limit and page', function () {
parseOptions({
limit: 10,
page: 2
}).should.eql({
limit: 10,
page: 2
});
});
it('should use defaults if bad options are passed', function () {
parseOptions({
limit: 'thelma',
page: 'louise'
}).should.eql({
limit: 15,
page: 1
});
});
it('should permit all for limit', function () {
parseOptions({
limit: 'all'
}).should.eql({
limit: 'all',
page: 1
});
});
});
describe('addLimitAndOffset', function () {
let addLimitAndOffset;
const collection = {};
before(function () {
addLimitAndOffset = paginationUtils.addLimitAndOffset;
});
beforeEach(function () {
collection.query = sinon.stub().returns(collection);
});
it('should add query options if limit is set', function () {
addLimitAndOffset(collection, {limit: 5, page: 1});
collection.query.calledTwice.should.be.true();
collection.query.firstCall.calledWith('limit', 5).should.be.true();
collection.query.secondCall.calledWith('offset', 0).should.be.true();
});
it('should not add query options if limit is not set', function () {
addLimitAndOffset(collection, {page: 1});
collection.query.called.should.be.false();
});
});
});
describe('fetchPage', function () {
let model;
let bookshelf;
let knex;
let mockQuery;
before(function () {
paginationUtils = pagination.__get__('paginationUtils');
});
beforeEach(function () {
// Stub paginationUtils
paginationUtils.parseOptions = sinon.stub();
paginationUtils.addLimitAndOffset = sinon.stub();
paginationUtils.formatResponse = sinon.stub().returns({});
// Mock out bookshelf model
mockQuery = {
clone: sinon.stub(),
select: sinon.stub(),
toQuery: sinon.stub()
};
mockQuery.clone.returns(mockQuery);
mockQuery.select.returns(Promise.resolve([{aggregate: 1}]));
model = function () {
};
model.prototype.fetchAll = sinon.stub().returns(Promise.resolve({}));
model.prototype.query = sinon.stub();
model.prototype.query.returns(mockQuery);
knex = {raw: sinon.stub().returns(Promise.resolve())};
bookshelf = {Model: model, knex: knex};
pagination(bookshelf);
});
it('extends Model with fetchPage', function () {
bookshelf.Model.prototype.should.have.ownProperty('fetchPage');
bookshelf.Model.prototype.fetchPage.should.be.a.Function();
});
it('calls all paginationUtils and methods', function (done) {
paginationUtils.parseOptions.returns({});
bookshelf.Model.prototype.fetchPage().then(function () {
sinon.assert.callOrder(
paginationUtils.parseOptions,
model.prototype.query,
mockQuery.clone,
mockQuery.select,
paginationUtils.addLimitAndOffset,
model.prototype.fetchAll,
paginationUtils.formatResponse
);
paginationUtils.parseOptions.calledOnce.should.be.true();
paginationUtils.parseOptions.calledWith(undefined).should.be.true();
paginationUtils.addLimitAndOffset.calledOnce.should.be.true();
paginationUtils.formatResponse.calledOnce.should.be.true();
model.prototype.query.calledOnce.should.be.true();
model.prototype.query.firstCall.calledWith().should.be.true();
mockQuery.clone.calledOnce.should.be.true();
mockQuery.clone.firstCall.calledWith().should.be.true();
mockQuery.select.calledOnce.should.be.true();
mockQuery.select.calledWith().should.be.true();
model.prototype.fetchAll.calledOnce.should.be.true();
model.prototype.fetchAll.calledWith({}).should.be.true();
done();
}).catch(done);
});
it('calls all paginationUtils and methods when order set', function (done) {
const orderOptions = {order: {id: 'DESC'}};
paginationUtils.parseOptions.returns(orderOptions);
bookshelf.Model.prototype.fetchPage(orderOptions).then(function () {
sinon.assert.callOrder(
paginationUtils.parseOptions,
model.prototype.query,
mockQuery.clone,
mockQuery.select,
paginationUtils.addLimitAndOffset,
model.prototype.query,
model.prototype.fetchAll,
paginationUtils.formatResponse
);
paginationUtils.parseOptions.calledOnce.should.be.true();
paginationUtils.parseOptions.calledWith(orderOptions).should.be.true();
paginationUtils.addLimitAndOffset.calledOnce.should.be.true();
paginationUtils.formatResponse.calledOnce.should.be.true();
model.prototype.query.calledTwice.should.be.true();
model.prototype.query.firstCall.calledWith().should.be.true();
model.prototype.query.secondCall.calledWith('orderBy', 'id', 'DESC').should.be.true();
mockQuery.clone.calledOnce.should.be.true();
mockQuery.clone.firstCall.calledWith().should.be.true();
mockQuery.select.calledOnce.should.be.true();
mockQuery.select.calledWith().should.be.true();
model.prototype.fetchAll.calledOnce.should.be.true();
model.prototype.fetchAll.calledWith(orderOptions).should.be.true();
done();
}).catch(done);
});
it('calls all paginationUtils and methods when group by set', function (done) {
const groupOptions = {groups: ['posts.id']};
paginationUtils.parseOptions.returns(groupOptions);
bookshelf.Model.prototype.fetchPage(groupOptions).then(function () {
sinon.assert.callOrder(
paginationUtils.parseOptions,
model.prototype.query,
mockQuery.clone,
mockQuery.select,
paginationUtils.addLimitAndOffset,
model.prototype.query,
model.prototype.fetchAll,
paginationUtils.formatResponse
);
paginationUtils.parseOptions.calledOnce.should.be.true();
paginationUtils.parseOptions.calledWith(groupOptions).should.be.true();
paginationUtils.addLimitAndOffset.calledOnce.should.be.true();
paginationUtils.formatResponse.calledOnce.should.be.true();
model.prototype.query.calledTwice.should.be.true();
model.prototype.query.firstCall.calledWith().should.be.true();
model.prototype.query.secondCall.calledWith('groupBy', 'posts.id').should.be.true();
mockQuery.clone.calledOnce.should.be.true();
mockQuery.clone.firstCall.calledWith().should.be.true();
mockQuery.select.calledOnce.should.be.true();
mockQuery.select.calledWith().should.be.true();
model.prototype.fetchAll.calledOnce.should.be.true();
model.prototype.fetchAll.calledWith(groupOptions).should.be.true();
done();
}).catch(done);
});
it('returns expected response', function (done) {
paginationUtils.parseOptions.returns({});
bookshelf.Model.prototype.fetchPage().then(function (result) {
result.should.have.ownProperty('collection');
result.should.have.ownProperty('pagination');
result.collection.should.be.an.Object();
result.pagination.should.be.an.Object();
done();
});
});
it('returns expected response even when aggregate is empty', function (done) {
// override aggregate response
mockQuery.select.returns(Promise.resolve([]));
paginationUtils.parseOptions.returns({});
bookshelf.Model.prototype.fetchPage().then(function (result) {
result.should.have.ownProperty('collection');
result.should.have.ownProperty('pagination');
result.collection.should.be.an.Object();
result.pagination.should.be.an.Object();
done();
});
});
});
});

105
yarn.lock
View file

@ -324,7 +324,7 @@
resolved "https://registry.yarnpkg.com/@nexes/nql-lang/-/nql-lang-0.0.1.tgz#a13c023873f9bc11b9e4e284449c6cfbeccc8011"
integrity sha1-oTwCOHP5vBG55OKERJxs++zMgBE=
"@nexes/nql@0.5.2":
"@nexes/nql@0.5.2", "@nexes/nql@^0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@nexes/nql/-/nql-0.5.2.tgz#64d847d563720d9c3a0f9683dde930fee518e064"
integrity sha512-qGLwtpYkKoHI++b+zgsEHuISDUv7LC1PI/0bWd6X9bOz2zGj1nJUDFJHjwdOuLmV5q6BR60VDBKw51Mvzqkl2g==
@ -596,6 +596,92 @@
ghost-ignition "^4.6.2"
lodash "^4.17.11"
"@tryghost/bookshelf-collision@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-collision/-/bookshelf-collision-0.1.0.tgz#6cfe9735b50122c0a01741effda223b013260ee0"
integrity sha512-2Cs/dDg1IrDDNHs/ogz07VVAILJndODOIXRVkwLdZiEfEaiFISzi3sb1UWbDkZGmiYzjOIwZ7/ifB28kqDvYHg==
dependencies:
"@tryghost/errors" "^0.2.12"
lodash "^4.17.21"
moment-timezone "^0.5.33"
"@tryghost/bookshelf-custom-query@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-custom-query/-/bookshelf-custom-query-0.1.0.tgz#17a511f83fe51c80007563df28b35776d4092846"
integrity sha512-sJCgqVBpQjhZohgUeV+e6EC3yDd7WJgkGP+LSsSvxRq/wxf7RxbkkTwAgd3XPiyi4LcHz/9a2Sr8HBgVDsXLIQ==
"@tryghost/bookshelf-eager-load@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-eager-load/-/bookshelf-eager-load-0.1.1.tgz#3c0b8b28669e0dedbe46c33cb3efd56e49b380af"
integrity sha512-o/JzO7Xt7cTdHw9mrwkSaJ2uQKhmzRLvZSt7PyFx/gecD//OyajN7VTU6OfuBQ85gHaYrRAc3YRI047U0ik/eQ==
dependencies:
lodash "^4.17.21"
"@tryghost/bookshelf-filter@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-filter/-/bookshelf-filter-0.1.1.tgz#4976d498e28b3a48ec3cc1f6a3a69225e3fe0227"
integrity sha512-np/J6+Lzqa9NIFhr69pE3E79XBGUinOSzvUtk6pxMukxTJtqAPkwjFOoZrAe21HHkmdzsQ51bvDAYHAPgVKHxA==
dependencies:
"@nexes/nql" "^0.5.2"
"@tryghost/errors" "^0.2.12"
"@tryghost/tpl" "^0.1.1"
"@tryghost/bookshelf-has-posts@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-has-posts/-/bookshelf-has-posts-0.1.1.tgz#9ee97057bed61f964b5b7abc79c2073f37dedac5"
integrity sha512-zfWKA3CRE/zhV2L7vYR7zQKIn75pYPY4EExDaxpixy77mMoTactXpofsYy1leJ1MwLR0RW850MWRavIN9PTXEA==
dependencies:
"@tryghost/debug" "^0.1.0"
lodash "^4.17.21"
"@tryghost/bookshelf-include-count@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-include-count/-/bookshelf-include-count-0.1.1.tgz#ef1bffc91dfe17934c6aadf298f9d8fc2200d367"
integrity sha512-9sIei0t1ymgBha+b7vZpsStkclYXV+EHVO9LTl/WY2V2TYa4RpnbIaa7NELF/1OzQ54Iy67yqqf9J2FhtgiFWA==
dependencies:
lodash "^4.17.21"
"@tryghost/bookshelf-order@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-order/-/bookshelf-order-0.1.0.tgz#7666e3135ab0322531f610ea5554da227d767087"
integrity sha512-mBJYHPw7f+cpZlkfNB5//sE8/yVscHZYhiUKiebSGZVV1CqyzixQ9NPIOsc2oRk4xiG6uHTxhH6WH4MXKTIpOA==
dependencies:
lodash "^4.17.21"
"@tryghost/bookshelf-pagination@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-pagination/-/bookshelf-pagination-0.1.1.tgz#3177f89f88747ed888db8276d946651f69736729"
integrity sha512-aZrbFOnuukB+bBmcw97MxNNrVok8BP1DHgPTY8hUaERQTU009ACHb/Mtb89fMExB/qFbz4UYuToUcGqEvRR7kw==
dependencies:
"@tryghost/errors" "^0.2.12"
"@tryghost/tpl" "^0.1.1"
"@tryghost/bookshelf-plugins@0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-plugins/-/bookshelf-plugins-0.1.1.tgz#c75db06c15e5680036915bfea4fb89dd485c0678"
integrity sha512-b10rPGFWEkjRrO1mB+1XELBHwEOa3er9NWkTcak7thDTVnBnSjJImAdPNVt10gEck6sB8EgS8C7UyZJcGxS/gA==
dependencies:
"@tryghost/bookshelf-collision" "^0.1.0"
"@tryghost/bookshelf-custom-query" "^0.1.0"
"@tryghost/bookshelf-eager-load" "^0.1.1"
"@tryghost/bookshelf-filter" "^0.1.1"
"@tryghost/bookshelf-has-posts" "^0.1.1"
"@tryghost/bookshelf-include-count" "^0.1.1"
"@tryghost/bookshelf-order" "^0.1.0"
"@tryghost/bookshelf-pagination" "^0.1.1"
"@tryghost/bookshelf-search" "^0.1.0"
"@tryghost/bookshelf-transaction-events" "^0.1.0"
"@tryghost/bookshelf-search@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-search/-/bookshelf-search-0.1.0.tgz#34e22fe6fb49cde7d1755acb4baaf156e0a2e8e7"
integrity sha512-O6KZMz9+bnTLX6ZGAiQCI7EQ+JMlIPQX2CezxECUzh+GueTJ+31Ai6o8hhA0QUQpvp2GXDBoGhe54dW1cG5yTw==
"@tryghost/bookshelf-transaction-events@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-transaction-events/-/bookshelf-transaction-events-0.1.0.tgz#cb49e567777aeeb4e911fb9a6421e8450fd6ef15"
integrity sha512-WlBTOe+/BtJWTIaH1sOay8d1uGOlHGzprg68E07WzgX6S+dEgUaazZvyFpfHcJxkjb51ysI4qG+6OJf1QUXaWw==
"@tryghost/bootstrap-socket@0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@tryghost/bootstrap-socket/-/bootstrap-socket-0.2.9.tgz#441e9287c4293fc344fc363a4ea8579144393ea6"
@ -613,6 +699,13 @@
resolved "https://registry.yarnpkg.com/@tryghost/constants/-/constants-0.1.8.tgz#b08074cdc6f8a379209750e60c2ab62c8ba895cf"
integrity sha512-3/w0k2JlpYjG/3tU3zjvVlrNH+kK9bjZvp7xaCkvmbsLGxQFMtlKU4fFY+rcpDVWOkywIHmKbLGmAqADgZeakA==
"@tryghost/debug@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@tryghost/debug/-/debug-0.1.0.tgz#f55dc680b07fe49556c436d43e93e726e6ecbfc3"
integrity sha512-ZoA6I9cjGMnJ4Nfcm4isOcxQEcm9Rhd1CmiBOXvTBsk442h/H4BIoOOY2Wy55oUiOf4rWxpqi9Duv/rQxJjkpA==
dependencies:
"@tryghost/root-utils" "^0.2.2"
"@tryghost/elasticsearch-bunyan@0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch-bunyan/-/elasticsearch-bunyan-0.1.1.tgz#5a36d81dd020825dd563b1357ae6c249580c46f5"
@ -848,6 +941,14 @@
caller "^1.0.1"
find-root "^1.1.0"
"@tryghost/root-utils@^0.2.2":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.2.2.tgz#bdd22faf5bd6628494748363e69de4afc8563a0e"
integrity sha512-U2CLiEalfRX1R+7o+YrhzoIy3F7F0GvZKSMN9h5u7tuFWLq6U92QzpSHhpAFtpzgmGhIzOVsVtnVjntaUE0LlA==
dependencies:
caller "^1.0.1"
find-root "^1.1.0"
"@tryghost/security@0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@tryghost/security/-/security-0.2.9.tgz#1bc323b22774c122c54a1b43a2594cdc88feba71"
@ -6994,7 +7095,7 @@ module-not-found-error@^1.0.1:
resolved "https://registry.yarnpkg.com/module-not-found-error/-/module-not-found-error-1.0.1.tgz#cf8b4ff4f29640674d6cdd02b0e3bc523c2bbdc0"
integrity sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=
moment-timezone@0.5.23, moment-timezone@^0.5.31:
moment-timezone@0.5.23, moment-timezone@^0.5.31, moment-timezone@^0.5.33:
version "0.5.23"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.23.tgz#7cbb00db2c14c71b19303cb47b0fb0a6d8651463"
integrity sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==