/**
* @module filter
*/
const { ObjectId } = require('mongodb');
const is = require('fi-is');
const regexer = require('./regexer');
const STAGES = require('./stages');
const TRUE = 'true';
const SPACE = ' ';
/**
* Builds a filter by fields aggregation stage by checking whether the params
* value with the same name is truthy or falsy and filters by not null or null
* respectively in a $match stage.
*
* @param {String[]} fields The fields to filter.
* @param {Object} params The parameters object.
*
* @returns {Object} The aggregation stage object.
*
* @throws {Error} If any of the parameters is of wrong type.
*
* @example
* const fields = ['disabledAt', 'createdAt', 'updatedAt'];
*
* const params = {
* disabledAt: false,
* };
*
* const stage = filter.byFields(fields, params);
*
* // Stage will be:
* {
* $match: {
* disabledAt: {
* $ne: null
* }
* }
* }
*/
function byFields(fields, params) {
if (is.not.array(fields)) {
throw new Error('The fields argument must be a String Array.');
}
if (is.not.object(params)) {
throw new Error('The params argument must be an Object.');
}
const $match = {};
fields.forEach((field) => {
if (Object.prototype.hasOwnProperty.call(params, field)) {
if (params[field] === TRUE) {
$match[field] = {
$ne: null,
};
} else {
$match[field] = null;
}
}
});
return {
$match,
};
}
/**
* Builds an exclude by _id aggregation stage by adding excluded Object Ids to a
* $nin inside a $match stage.
*
* @param {String[]|ObjectID[]} exclude Excluded ids array.
*
* @returns {Object} The aggregation stage object.
*
* @throws {Error} If any of the parameters is of wrong type.
*
* @example
* const exclude = [
* '59f1d626a787d654433ecff4', '59f1d627a787d654433ecff5',
* '59f1d627a787d654433ecff6', '59f1d627a787d654433ecff7',
* ];
*
* const stage = filter.excludeById(exclude);
*
* // Stage will be:
* $match: {
* _id: {
* $nin: [
* ObjectId('59f1d626a787d654433ecff4'),
* ObjectId('59f1d627a787d654433ecff5'),
* ObjectId('59f1d627a787d654433ecff6'),
* ObjectId('59f1d627a787d654433ecff7'),
* ]
* }
* }
*/
function excludeById(exclude) {
const $nin = [];
if (is.not.empty(exclude)) {
let ids;
if (is.string(exclude)) {
ids = [exclude];
} else if (is.array(exclude)) {
ids = exclude;
} else {
throw new Error('The exclude argument must be a String, a String Array or and ObjectID Array.');
}
ids.forEach((value) => {
if (ObjectId.isValid(value)) {
$nin.push(new ObjectId(value));
}
});
}
return {
$match: {
_id: {
$nin,
},
},
};
}
/**
* Builds a filter by numeric range $match stage using $and conditions for each
* field.
*
* @param {Object[]} ranges Numeric ranges object.
* @param {String} ranges.name The range key name in the params object.
* @param {String} ranges.field The range field name in the model.
* @param {String} ranges.cond The range query condition ($lte, $gte, $eq).
* @param {Object} params Request query params object.
*
* @returns {Object} The aggregation stage object.
*
* @throws {Error} If any of the parameters is of wrong type.
*
* @example
* const ranges = [{
* name: 'yearFrom',
* field: 'year',
* cond: '$gte',
* }, {
* name: 'yearTo',
* field: 'year',
* cond: '$lte',
* }];
*
* const params = {
* yearFrom: 2012,
* yearTo: 2017,
* };
*
* const stage = filter.byNumRange(ranges, params);
*
* // Stage will be:
* $match: {
* $and: [{
* year: {
* $gte: 2012
* }
* }, {
* year: {
* $lte: 2017
* }
* }]
* }
*/
function byNumRange(ranges, params) {
if (is.not.array(ranges)) {
throw new Error('The ranges argument must be an Object Array.');
}
if (is.not.object(params)) {
throw new Error('The params argument must be an Object.');
}
const $and = [];
ranges.forEach((range) => {
if (Object.prototype.hasOwnProperty.call(params, range.name)) {
const num = parseFloat(params[range.name], 10);
if (is.number(num) && num > 0) {
const cond = {};
cond[range.field] = {};
cond[range.field][range.cond] = num;
$and.push(cond);
}
}
});
if (is.not.empty($and)) {
return { $match: { $and } };
}
return { $match: {} };
}
/**
* Builds a $group stage, grouping by id, filter slug, filter score and adding
* the list of props by their $first accumulator.
*
* @param {String[]} props The list name of props to reference by $first.
*
* @returns {Object} The aggregation stage object.
*
* @throws {Error} If any of the parameters is of wrong type.
*
* @example
* const props = ['$year', '$brand', '$color'];
*
* const stage = filter.keywordsGroup(props);
*
* // Stage will be:
* $group: {
* {
* _id: '$_id',
* _filter_score: {
* $first: '$_filter_score',
* },
* _filter_slug: {
* $first: '$_filter_slug',
* },
*
* year: {
* $first: '$year'
* },
* brand: {
* $first: '$brand'
* },
* color: {
* $first: '$color'
* }
* }
* }
*/
function keywordsGroup(props) {
if (is.not.array(props)) {
throw new Error('The props argument must be a String Array.');
}
const $group = {
_id: '$_id',
_filter_score: {
$first: '$_filter_score',
},
_filter_slug: {
$first: '$_filter_slug',
},
};
props.forEach((prop) => {
$group[prop] = {
$first: `$${prop}`,
};
});
return {
$group,
};
}
/**
* Builds the filter's slug $addFields aggregation stage.
*
* @param {String[]} fields Field references to add.
*
* @returns {Object} The aggregation stage object.
*
* @throws {Error} If any of the parameters is of wrong type.
*
* @example
* const props = [
* '$brand', '$model', '$color', {
* $substrBytes: ['$year', 0, -1] // Convert Number to String
* }
* ];
*
* const stage = filter.keywordsSlug(props);
*
* // Stage will be:
* $addFields: {
* _filter_slug: {
* $toLower: {
* $concat: [{
* $ifNull: ['$brand', '']
* }, {
* $ifNull: ['$model', '']
* }, {
* $ifNull: ['$color', '']
* }, {
* $ifNull: [{
* $substrBytes: ['$year', 0, -1]
* }, '']
* }],
* },
* },
* },
*/
function keywordsSlug(fields) {
if (is.not.array(fields)) {
throw new Error('The fields argument must be a String Array.');
}
const concat = [];
fields.forEach((field, i) => {
concat.push({
$ifNull: [field, ''],
});
if ((i + 1) < fields.length) {
concat.push(SPACE);
}
});
return {
$addFields: {
_filter_slug: {
$toLower: {
$concat: concat,
},
},
},
};
}
/**
* Builds the filter by keywords aggregation stages.
*
* @param {String} keywords Keywords string to split by white spaces.
* @param {Object} slug Slug $addFields prebuilt stage.
* @param {Object} group $group results prebuilt stage.
*
* @returns {Object[]} The aggregation stages object array.
*
* @throws {Error} If any of the parameters is of wrong type.
*
* @example
* const group = filter.keywordsGroup(groupProps);
* const slug = filter.keywordsSlug(slugProps);
* const keywords = 'hello world';
*
* const stage = filter.byKeywords(keywords, slug, group);
*
* // Stage output is too large to place here but it creates a $facet stage
* // filter and assigns a score to the results by exact (3), mixed (2), or
* // fuzzy (1) matches and then concatenates, groups to remove duplicates and
* // sorts the results by their score.
*/
function byKeywords(keywords, slug, group) {
if (is.not.string(keywords)) {
throw new Error('The fields argument must be a String.');
}
if (is.not.object(slug)) {
throw new Error('The slug argument must be an Object.');
}
if (is.not.object(group)) {
throw new Error('The group argument must be an Object.');
}
return [slug,
{
$facet: {
/* Match exact results */
exact: [{
$match: {
_filter_slug: regexer.exact(keywords),
},
}],
/* Match mixed results */
mixed: [{
$match: {
_filter_slug: regexer.mixed(keywords),
},
}],
/* Match fuzzy results */
fuzzy: [{
$match: {
_filter_slug: regexer.fuzzy(keywords),
},
}],
},
},
STAGES.UNWIND_EXACT, STAGES.UNWIND_MIXED, STAGES.UNWIND_FUZZY,
STAGES.SEARCH_SCORES, STAGES.SEARCH_GROUP_SCORES,
STAGES.SEARCH_CONCAT_RESULTS, STAGES.UNWIND_RESULTS,
STAGES.SEARCH_REPLACE_ROOT, group, STAGES.SEARCH_NOT_NULL,
STAGES.SEARCH_SCORE_SORT,
];
}
module.exports = {
byFields,
byNumRange,
excludeById,
keywordsGroup,
keywordsSlug,
byKeywords,
};