Pierre Noël 10092f5a57 refacto
Signed-off-by: Pierre Noël <petersg83@gmail.com>
2020-07-01 11:59:33 +02:00

604 lines
16 KiB
JavaScript

'use strict';
const _ = require('lodash');
var semver = require('semver');
const utils = require('./utils')();
const combineSearchAndWhere = (search = [], wheres = []) => {
const criterias = {};
if (search.length > 0 && wheres.length > 0) {
criterias.$and = [{ $and: wheres }, { $or: search }];
} else if (search.length > 0) {
criterias.$or = search;
} else if (wheres.length > 0) {
criterias.$and = wheres;
}
return criterias;
};
const buildSearchOr = (model, query) => {
if (typeof query !== 'string') {
return [];
}
const searchOr = Object.keys(model.attributes).reduce((acc, curr) => {
switch (model.attributes[curr].type) {
case 'biginteger':
case 'integer':
case 'float':
case 'decimal':
if (!_.isNaN(_.toNumber(query))) {
const mongoVersion = model.db.base.mongoDBVersion;
if (semver.valid(mongoVersion) && semver.gt(mongoVersion, '4.2.0')) {
return acc.concat({
$expr: {
$regexMatch: {
input: { $toString: `$${curr}` },
regex: _.escapeRegExp(query),
},
},
});
} else {
return acc.concat({ [curr]: _.toNumber(query) });
}
}
return acc;
case 'string':
case 'text':
case 'richtext':
case 'email':
case 'enumeration':
case 'uid':
return acc.concat({ [curr]: { $regex: _.escapeRegExp(query), $options: 'i' } });
default:
return acc;
}
}, []);
if (utils.isMongoId(query)) {
searchOr.push({ _id: query });
}
return searchOr;
};
/**
* Build a mongo query
* @param {Object} options - Query options
* @param {Object} options.model - The model you are querying
* @param {Object} options.filers - An object with the possible filters (start, limit, sort, where)
* @param {Object} options.populate - An array of paths to populate
* @param {boolean} options.aggregate - Force aggregate function to use group by feature
*/
const buildQuery = ({
model,
filters = {},
searchParam,
populate = [],
aggregate = false,
} = {}) => {
const deepFilters = (filters.where || []).filter(({ field }) => field.split('.').length > 1);
const search = buildSearchOr(model, searchParam);
if (deepFilters.length === 0 && aggregate === false) {
return buildSimpleQuery({ model, filters, search, populate });
}
return buildDeepQuery({ model, filters, populate, search });
};
/**
* Builds a simple find query when there are no deep filters
* @param {Object} options - Query options
* @param {Object} options.model - The model you are querying
* @param {Object} options.filters - An object with the possible filters (start, limit, sort, where)
* @param {Object} options.search - An object with the possible search params
* @param {Object} options.populate - An array of paths to populate
*/
const buildSimpleQuery = ({ model, filters, search, populate }) => {
const { where = [] } = filters;
const wheres = where.map(buildWhereClause);
const findCriteria = combineSearchAndWhere(search, wheres);
let query = model.find(findCriteria).populate(populate);
query = applyQueryParams({ query, filters });
return Object.assign(query, {
// Override count to use countDocuments on simple find query
count(...args) {
return query.countDocuments(...args);
},
});
};
/**
* Builds a deep aggregate query when there are deep filters
* @param {Object} options - Query options
* @param {Object} options.model - The model you are querying
* @param {Object} options.filers - An object with the possible filters (start, limit, sort, where)
* @param {Object} options.populate - An array of paths to populate
*/
const buildDeepQuery = ({ model, filters, search, populate }) => {
// Build a tree of paths to populate based on the filtering and the populate option
const { populatePaths, wherePaths } = computePopulatedPaths({
model,
populate,
where: filters.where,
});
// Init the query
let query = model
.aggregate(
buildQueryAggregate(model, {
paths: _.merge({}, populatePaths, wherePaths),
})
)
.append(buildQueryMatches(model, filters, search));
return {
/**
* Overrides the promise to rehydrate mongoose docs after the aggregation query
*/
then(...args) {
return query
.append({
$project: { _id: true },
})
.then(results => results.map(el => el._id))
.then(ids => {
if (ids.length === 0) return [];
const query = model
.find({
_id: {
$in: ids,
},
})
.populate(populate);
return applyQueryParams({ query, filters });
})
.then(...args);
},
catch(...args) {
return this.then(r => r).catch(...args);
},
/**
* Maps to query.count
*/
count() {
return query.count('count').then(results => _.get(results, ['0', 'count'], 0));
},
/**
* Maps to query group
*/
group(...args) {
return query.group(...args);
},
/**
* Returns an array of plain JS object instead of mongoose documents
*/
lean() {
// Returns plain js objects without the transformations we normally do on find
return this.then(results => {
return results.map(r => r.toObject({ transform: false }));
});
},
};
};
/**
* Apply sort limit and start params
* @param {Object} options - Options
* @param {Object} options.query - Mongoose query
* @param {Object} options.filters - Filters object
*/
const applyQueryParams = ({ query, filters }) => {
// Apply sort param
if (_.has(filters, 'sort')) {
const sortFilter = filters.sort.reduce((acc, sort) => {
const { field, order } = sort;
acc[field] = order === 'asc' ? 1 : -1;
return acc;
}, {});
query = query.sort(sortFilter);
}
// Apply start param
if (_.has(filters, 'start')) {
query = query.skip(filters.start);
}
// Apply limit param
if (_.has(filters, 'limit') && filters.limit >= 0) {
query = query.limit(filters.limit);
}
return query;
};
/**
* Returns a tree of the paths to populate both for population and deep filtering purposes
* @param {Object} options - Options
* @param {Object} options.model - Model from which to populate
* @param {Object} options.populate - Paths to populate
* @param {Object} options.where - Where clauses we need to populate to filters
*/
const computePopulatedPaths = ({ model, populate = [], where = [] }) => {
const castedPopulatePaths = populate
.map(el => (Array.isArray(el) ? el.join('.') : el))
.map(path => findModelPath({ rootModel: model, path }))
.map(path => {
const assocModel = findModelByPath({ rootModel: model, path });
// autoload morph relations
let extraPaths = [];
if (assocModel) {
extraPaths = assocModel.associations
.filter(assoc => assoc.nature.toLowerCase().indexOf('morph') !== -1)
.map(assoc => `${path}.${assoc.alias}`);
}
return [path, ...extraPaths];
})
.reduce((acc, paths) => acc.concat(paths), []);
const castedWherePaths = where
.map(({ field }) => findModelPath({ rootModel: model, path: field }))
.filter(path => !!path);
return {
populatePaths: pathsToTree(castedPopulatePaths),
wherePaths: pathsToTree(castedWherePaths),
};
};
/**
* Builds an object based on paths:
* [
* 'articles',
* 'articles.tags.cateogry',
* 'articles.tags.label',
* ] => {
* articles: {
* tags: {
* category: {},
* label: {}
* }
* }
* }
* @param {Array<string>} paths - A list of paths to transform
*/
const pathsToTree = paths => paths.reduce((acc, path) => _.merge(acc, _.set({}, path, {})), {});
/**
* Builds the aggregations pipeling of the query
* @param {Object} model - Queried model
* @param {Object} options - Options
* @param {Object} options.paths - A tree of paths to aggregate e.g { article : { tags : { label: {}}}}
*/
const buildQueryAggregate = (model, { paths } = {}) => {
return Object.keys(paths).reduce((acc, key) => {
return acc.concat(buildLookup({ model, key, paths: paths[key] }));
}, []);
};
/**
* Builds a lookup aggregation for a specific key
* @param {Object} options - Options
* @param {Object} options.model - Queried model
* @param {string} options.key - The attribute name to lookup on the model
* @param {Object} options.paths - A tree of paths to aggregate inside the current lookup e.g { { tags : { label: {}}}
*/
const buildLookup = ({ model, key, paths }) => {
const assoc = model.associations.find(a => a.alias === key);
const assocModel = findModelByAssoc({ assoc });
if (!assocModel) return [];
return [
{
$lookup: {
from: assocModel.collectionName,
as: assoc.alias,
let: {
localId: '$_id',
localAlias: `$${assoc.alias}`,
},
pipeline: []
.concat(buildLookupMatch({ assoc }))
.concat(buildQueryAggregate(assocModel, { paths })),
},
},
];
};
/**
* Build a lookup match expression (equivalent to a SQL join condition)
* @param {Object} options - Options
* @param {Object} options.assoc - The association on which is based the ematching xpression
*/
const buildLookupMatch = ({ assoc }) => {
switch (assoc.nature) {
case 'oneToOne': {
return [
{
$match: {
$expr: {
$eq: [`$${assoc.via}`, '$$localId'],
},
},
},
];
}
case 'oneToMany': {
return {
$match: {
$expr: {
$eq: [`$${assoc.via}`, '$$localId'],
},
},
};
}
case 'oneWay':
case 'manyToOne': {
return {
$match: {
$expr: {
$eq: ['$$localAlias', '$_id'],
},
},
};
}
case 'manyWay': {
return {
$match: {
$expr: {
$in: ['$_id', '$$localAlias'],
},
},
};
}
case 'manyToMany': {
if (assoc.dominant === true) {
return {
$match: {
$expr: {
$in: ['$_id', '$$localAlias'],
},
},
};
}
return {
$match: {
$expr: {
$in: ['$$localId', `$${assoc.via}`],
},
},
};
}
case 'manyToManyMorph':
case 'oneToManyMorph': {
return [
{
$unwind: { path: `$${assoc.via}`, preserveNullAndEmptyArrays: true },
},
{
$match: {
$expr: {
$and: [
{ $eq: [`$${assoc.via}.ref`, '$$localId'] },
{ $eq: [`$${assoc.via}.${assoc.filter}`, assoc.alias] },
],
},
},
},
];
}
default:
return [];
}
};
/**
* Match query for lookups
* @param {Object} model - Mongoose model
* @param {Object} filters - Filters object
*/
const buildQueryMatches = (model, filters, search = []) => {
if (_.has(filters, 'where') && Array.isArray(filters.where)) {
const wheres = filters.where.map(whereClause => {
return buildWhereClause(formatWhereClause(model, whereClause));
});
const criterias = combineSearchAndWhere(search, wheres);
return [{ $match: criterias }];
}
return [];
};
/**
* Cast values
* @param {*} value - Value to cast
*/
const formatValue = value => utils.valueToId(value);
/**
* Builds a where clause
* @param {Object} options - Options
* @param {string} options.field - Where clause field
* @param {string} options.operator - Where clause operator
* @param {*} options.value - Where clause alue
*/
const buildWhereClause = ({ field, operator, value }) => {
if (Array.isArray(value) && !['in', 'nin'].includes(operator)) {
return {
$or: value.map(val => buildWhereClause({ field, operator, value: val })),
};
}
const val = formatValue(value);
switch (operator) {
case 'eq':
return { [field]: val };
case 'ne':
return { [field]: { $ne: val } };
case 'lt':
return { [field]: { $lt: val } };
case 'lte':
return { [field]: { $lte: val } };
case 'gt':
return { [field]: { $gt: val } };
case 'gte':
return { [field]: { $gte: val } };
case 'in':
return {
[field]: {
$in: Array.isArray(val) ? val : [val],
},
};
case 'nin':
return {
[field]: {
$nin: Array.isArray(val) ? val : [val],
},
};
case 'contains': {
return {
[field]: {
$regex: `${val}`,
$options: 'i',
},
};
}
case 'ncontains':
return {
[field]: {
$not: new RegExp(val, 'i'),
},
};
case 'containss':
return {
[field]: {
$regex: `${val}`,
},
};
case 'ncontainss':
return {
[field]: {
$not: new RegExp(val),
},
};
case 'null': {
return value ? { [field]: { $eq: null } } : { [field]: { $ne: null } };
}
default:
throw new Error(`Unhandled whereClause : ${field} ${operator} ${value}`);
}
};
/**
* Add primaryKey on relation where clause for lookups match
* @param {Object} model - Mongoose model
* @param {Object} whereClause - Where clause
* @param {string} whereClause.field - Where clause field
* @param {string} whereClause.operator - Where clause operator
* @param {*} whereClause.value - Where clause alue
*/
const formatWhereClause = (model, { field, operator, value }) => {
const { assoc, model: assocModel } = getAssociationFromFieldKey(model, field);
const shouldFieldBeSuffixed =
assoc &&
!_.endsWith(field, assocModel.primaryKey) &&
(['in', 'nin'].includes(operator) || // When using in or nin operators we want to apply the filter on the relation's primary key and not the relation itself
(['eq', 'ne'].includes(operator) && utils.isMongoId(value))); // Only suffix the field if the operators are eq or ne and the value is a valid mongo id
return {
field: shouldFieldBeSuffixed ? `${field}.${assocModel.primaryKey}` : field,
operator,
value,
};
};
/**
* Returns an association from a path starting from model
* @param {Object} model - Mongoose model
* @param {string} fieldKey - Relation path
*/
const getAssociationFromFieldKey = (model, fieldKey) => {
let tmpModel = model;
let assoc;
const parts = fieldKey.split('.');
for (let key of parts) {
assoc = tmpModel.associations.find(ast => ast.alias === key);
if (assoc) {
tmpModel = findModelByAssoc({ assoc });
}
}
return {
assoc,
model: tmpModel,
};
};
/**
* Returns a model from a relation path and a root model
* @param {Object} options - Options
* @param {Object} options.rootModel - Mongoose model
* @param {string} options.path - Relation path
*/
const findModelByPath = ({ rootModel, path }) => {
const parts = path.split('.');
let tmpModel = rootModel;
for (let part of parts) {
const assoc = tmpModel.associations.find(ast => ast.alias === part);
if (assoc) {
tmpModel = findModelByAssoc({ assoc });
}
}
return tmpModel;
};
/**
* Returns a model path from an attribute path and a root model
* @param {Object} options - Options
* @param {Object} options.rootModel - Mongoose model
* @param {string} options.path - Attribute path
*/
const findModelPath = ({ rootModel, path }) => {
const parts = path.split('.');
let tmpModel = rootModel;
let tmpPath = [];
for (let part of parts) {
const assoc = tmpModel.associations.find(ast => ast.alias === part);
if (assoc) {
tmpModel = findModelByAssoc({ assoc });
tmpPath.push(part);
}
}
return tmpPath.length > 0 ? tmpPath.join('.') : null;
};
const findModelByAssoc = ({ assoc }) => {
const { models } = strapi.plugins[assoc.plugin] || strapi;
return models[assoc.model || assoc.collection];
};
module.exports = buildQuery;