var _ = require('lodash'), gql = require('ghost-gql'), common = require('../../lib/common'), filter, filterUtils; filterUtils = { /** * ## Combine Filters * Util to combine the enforced, default and custom filters such that they behave accordingly * @param {String|Object} enforced - filters which must ALWAYS be applied * @param {String|Object} defaults - filters which must be applied if a matching filter isn't provided * @param {...String|Object} [custom] - custom filters which are additional * @returns {*} */ combineFilters: function combineFilters(enforced, defaults, custom /* ...custom */) { custom = Array.prototype.slice.call(arguments, 2); // Ensure everything has been run through the gql parser try { enforced = enforced ? (_.isString(enforced) ? gql.parse(enforced) : enforced) : null; defaults = defaults ? (_.isString(defaults) ? gql.parse(defaults) : defaults) : null; custom = _.map(custom, function (arg) { return _.isString(arg) ? gql.parse(arg) : arg; }); } catch (err) { throw new common.errors.ValidationError({ err: err, property: 'filter', context: common.i18n.t('errors.models.plugins.filter.errorParsing'), help: common.i18n.t('errors.models.plugins.filter.forInformationRead', {url: 'https://api.ghost.org/v1.22.0/docs/filter'}) }); } // Merge custom filter options into a single set of statements custom = gql.json.mergeStatements.apply(this, custom); // if there is no enforced or default statements, return just the custom statements; if (!enforced && !defaults) { return custom; } // Reduce custom filters based on enforced filters if (custom && !_.isEmpty(custom.statements) && enforced && !_.isEmpty(enforced.statements)) { custom.statements = gql.json.rejectStatements(custom.statements, function (customStatement) { return gql.json.findStatement(enforced.statements, customStatement, 'prop'); }); } // Reduce default filters based on custom filters if (defaults && !_.isEmpty(defaults.statements) && custom && !_.isEmpty(custom.statements)) { defaults.statements = gql.json.rejectStatements(defaults.statements, function (defaultStatement) { return gql.json.findStatement(custom.statements, defaultStatement, 'prop'); }); } // Merge enforced and defaults enforced = gql.json.mergeStatements(enforced, defaults); if (_.isEmpty(custom.statements)) { return enforced; } if (_.isEmpty(enforced.statements)) { return custom; } return { statements: [ {group: enforced.statements}, {group: custom.statements, func: 'and'} ] }; } }; filter = function filter(Bookshelf) { var Model = Bookshelf.Model.extend({ // Cached copy of the filters setup for this model instance _filters: null, // Override these on the various models enforcedFilters: function enforcedFilters() { }, defaultFilters: function defaultFilters() { }, preProcessFilters: function preProcessFilters() { this._filters.statements = gql.json.replaceStatements(this._filters.statements, {prop: /primary_tag/}, function (statement) { statement.prop = 'tags.slug'; return { group: [ statement, {prop: 'posts_tags.sort_order', op: '=', value: 0}, {prop: 'tags.visibility', op: '=', value: 'public'} ] }; }); this._filters.statements = gql.json.replaceStatements(this._filters.statements, {prop: /primary_author/}, function (statement) { statement.prop = 'authors.slug'; return { group: [ statement, {prop: 'posts_authors.sort_order', op: '=', value: 0}, {prop: 'authors.visibility', op: '=', value: 'public'} ] }; }); }, /** * ## Post process Filters * Post Process filters looking for joins etc * @TODO refactor this * @param {object} options */ postProcessFilters: function postProcessFilters(options) { var joinTables = this._filters.joins; if (joinTables && joinTables.indexOf('tags') > -1) { // We need to use leftOuterJoin to insure we still include posts which don't have tags in the result // The where clause should restrict which items are returned this .query('leftOuterJoin', 'posts_tags', 'posts_tags.post_id', '=', 'posts.id') .query('leftOuterJoin', 'tags', 'posts_tags.tag_id', '=', 'tags.id'); // The order override should ONLY happen if we are doing an "IN" query // TODO move the order handling to the query building that is currently inside pagination // TODO make the order handling in pagination handle orderByRaw // TODO extend this handling to all joins if (gql.json.findStatement(this._filters.statements, {prop: /^tags/, op: 'IN'})) { // TODO make this count the number of MATCHING tags, not just the number of tags this.query('orderByRaw', 'count(tags.id) DESC'); } // We need to add a group by to counter the double left outer join // TODO improve on the group by handling options.groups = options.groups || []; options.groups.push('posts.id'); } if (joinTables && joinTables.indexOf('authors') > -1) { // We need to use leftOuterJoin to insure we still include posts which don't have tags in the result // The where clause should restrict which items are returned this .query('leftOuterJoin', 'posts_authors', 'posts_authors.post_id', '=', 'posts.id') .query('leftOuterJoin', 'users as authors', 'posts_authors.author_id', '=', 'authors.id'); // The order override should ONLY happen if we are doing an "IN" query // TODO move the order handling to the query building that is currently inside pagination // TODO make the order handling in pagination handle orderByRaw // TODO extend this handling to all joins if (gql.json.findStatement(this._filters.statements, {prop: /^authors/, op: 'IN'})) { // TODO make this count the number of MATCHING authors, not just the number of authors this.query('orderByRaw', 'count(authors.id) DESC'); } // We need to add a group by to counter the double left outer join // TODO improve on the group by handling options.groups = options.groups || []; options.groups.push('posts.id'); } /** * @deprecated: `author`, will be removed in Ghost 2.0 */ if (joinTables && joinTables.indexOf('author') > -1) { this .query('join', 'users as author', 'author.id', '=', 'posts.author_id'); } }, /** * ## fetchAndCombineFilters * Helper method, uses the combineFilters util to apply filters to the current model instance * based on options and the set enforced/default filters for this resource * @param {Object} options * @returns {Bookshelf.Model} */ fetchAndCombineFilters: function fetchAndCombineFilters(options) { options = options || {}; this._filters = filterUtils.combineFilters( this.enforcedFilters(options), this.defaultFilters(options), options.filter, options.where ); return this; }, /** * ## Apply Filters * Method which makes the necessary query builder calls (through knex) for the filters set * on this model instance * @param {Object} options * @returns {Bookshelf.Model} */ applyDefaultAndCustomFilters: function applyDefaultAndCustomFilters(options) { var self = this; // @TODO figure out a better place/way to trigger loading filters if (!this._filters) { this.fetchAndCombineFilters(options); } if (this._filters) { if (this.debug) { gql.json.printStatements(this._filters.statements); } this.preProcessFilters(options); this.query(function (qb) { gql.knexify(qb, self._filters); }); // Replaces processGQLResult this.postProcessFilters(options); } return this; } }); Bookshelf.Model = Model; }; /** * ## Export Filter plugin * @api public */ module.exports = filter;