'use strict'; const _ = require('lodash'); const { isNil, isEmpty, set, omit, assoc } = require('lodash/fp'); const semver = require('semver'); const { hasDeepFilters, contentTypes: { constants: { DP_PUB_STATES }, hasDraftAndPublish, }, } = require('strapi-utils'); const utils = require('./utils')(); const populateQueries = require('./utils/populate-queries'); const sortOrderMapper = { asc: 1, desc: -1, }; 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) => { if (model.attributes[curr].searchable === false) { return acc; } 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; }; const BOOLEAN_OPERATORS = ['or']; /** * Build a mongo query * @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.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, session = null, } = {}) => { const search = buildSearchOr(model, searchParam); if (!hasDeepFilters(filters) && aggregate === false) { return buildSimpleQuery({ model, filters, search, populate }, { session }); } return buildDeepQuery({ model, filters, populate, search }, { session }); }; /** * 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 }, { session }) => { const { where = [] } = filters; const wheres = where.map(buildWhereClause); const findCriteria = combineSearchAndWhere(search, wheres); let query = model .find(findCriteria, null, { publicationState: filters.publicationState }) .session(session) .populate(populate); query = applyQueryParams({ model, 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.filters - 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 }, { session }) => { // Build a tree of paths to populate based on the filtering and the populate option const { populatePaths, wherePaths } = computePopulatedPaths({ model, populate, where: filters.where, }); const aggregateOptions = { paths: _.merge({}, populatePaths, wherePaths), }; // Init the query let query = model .aggregate(buildQueryAggregate(model, filters, aggregateOptions)) .session(session) .append(buildQueryMatches(model, filters, search)) .append(buildQuerySort(model, filters)) .append(buildQueryPagination(model, filters)); 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 idsMap = ids.reduce((acc, id, idx) => assoc(id, idx, acc), {}); const mongooseQuery = model .find({ _id: { $in: ids } }, null) .session(session) .populate(populate); const query = applyQueryParams({ model, query: mongooseQuery, filters: omit(['sort', 'start', 'limit'], filters), }); return query.then(orderByIndexMap(idsMap)); }) .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 = ({ model, query, filters }) => { if (_.has(filters, 'sort')) { const sortFilter = filters.sort.reduce((acc, sort) => { const { field, order } = sort; acc[field] = sortOrderMapper[order]; 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); } // Apply publication state param if (_.has(filters, 'publicationState')) { const populateQuery = populateQueries.publicationState[filters.publicationState]; if (hasDraftAndPublish(model) && populateQuery) { query = query.where(populateQuery); } } 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 = recursiveCastedWherePaths(where, { model }); return { populatePaths: pathsToTree(castedPopulatePaths), wherePaths: pathsToTree(castedWherePaths), }; }; const recursiveCastedWherePaths = (whereClauses, { model }) => { const paths = whereClauses.map(({ field, operator, value }) => { if (BOOLEAN_OPERATORS.includes(operator)) { return value.map(where => recursiveCastedWherePaths(where, { model })); } return findModelPath({ rootModel: model, path: field }); }); return _.flattenDeep(paths).filter(path => !!path); }; /** * Builds an object based on paths: * [ * 'articles', * 'articles.tags.category', * 'articles.tags.label', * ] => { * articles: { * tags: { * category: {}, * label: {} * } * } * } * @param {Array} paths - A list of paths to transform */ const pathsToTree = paths => paths.reduce((acc, path) => _.merge(acc, _.set({}, path, {})), {}); /** * Builds the aggregations pipeline of the query * @param {Object} model - Queried model * @param {Object} filters - The query filters * @param {Object} options - Options * @param {Object} options.paths - A tree of paths to aggregate e.g { article : { tags : { label: {}}}} */ const buildQueryAggregate = (model, filters, { paths } = {}) => { return Object.keys(paths).reduce((acc, key) => { return acc.concat(buildLookup({ model, key, paths: paths[key], filters })); }, []); }; /** * 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, filters }) => { const assoc = model.associations.find(a => a.alias === key); const assocModel = strapi.db.getModelByAssoc(assoc); if (!assocModel) return []; return [ { $lookup: { from: assocModel.collectionName, as: assoc.alias, let: { localId: '$_id', localAlias: `$${assoc.alias}`, }, pipeline: [] .concat(buildLookupMatch({ assoc, assocModel, filters })) .concat(buildQueryAggregate(assocModel, filters, { 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 matching expression */ const buildLookupMatch = ({ assoc, assocModel, filters = {} }) => { const defaultMatches = []; if (hasDraftAndPublish(assocModel) && DP_PUB_STATES.includes(filters.publicationState)) { const dpQuery = populateQueries.publicationState[filters.publicationState]; if (_.isObject(dpQuery)) { defaultMatches.push(dpQuery); } } switch (assoc.nature) { case 'oneToOne': { return [ { $match: { $and: defaultMatches.concat({ $expr: { $eq: [`$${assoc.via}`, '$$localId'], }, }), }, }, ]; } case 'oneToMany': { return { $match: { $and: defaultMatches.concat({ $expr: { $eq: [`$${assoc.via}`, '$$localId'], }, }), }, }; } case 'oneWay': case 'manyToOne': { return { $match: { $and: defaultMatches.concat({ $expr: { $eq: ['$$localAlias', '$_id'], }, }), }, }; } case 'manyWay': { return { $match: { $and: defaultMatches.concat({ $expr: { $in: ['$_id', { $ifNull: ['$$localAlias', []] }], }, }), }, }; } case 'manyToMany': { if (assoc.dominant === true) { return { $match: { $and: defaultMatches.concat({ $expr: { $in: ['$_id', { $ifNull: ['$$localAlias', []] }], }, }), }, }; } return { $match: { $and: defaultMatches.concat({ $expr: { $in: ['$$localId', { $ifNull: [`$${assoc.via}`, []] }], }, }), }, }; } case 'manyToManyMorph': case 'oneToManyMorph': { return [ { $unwind: { path: `$${assoc.via}`, preserveNullAndEmptyArrays: true }, }, { $match: { $and: defaultMatches.concat({ $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 * @param {Array} search */ 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 []; }; /** * Sort query for the aggregate * @param {Object} model - Mongoose model * @param {Object} filters - Filters object */ const buildQuerySort = (model, filters) => { const { sort } = filters; if (Array.isArray(sort) && !isEmpty(sort)) { return [ { $sort: sort.reduce( (acc, { field, order }) => set([field], sortOrderMapper[order], acc), {} ), }, ]; } return []; }; /** * Add pagination operators for the aggregate * @param {Object} model - Mongoose model * @param {Object} filters - Filters object */ const buildQueryPagination = (model, filters) => { const { limit, start } = filters; const pagination = []; if (start && start >= 0) { pagination.push({ $skip: start }); } if (limit && limit >= 0) { pagination.push({ $limit: limit }); } return pagination; }; /** * 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) && !['or', 'in', 'nin'].includes(operator)) { return { $or: value.map(val => buildWhereClause({ field, operator, value: val })), }; } const val = formatValue(value); switch (operator) { case 'or': { return { $or: value.map(orClause => { if (Array.isArray(orClause)) { return { $and: orClause.map(buildWhereClause), }; } else { return buildWhereClause(orClause); } }), }; } 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 }) => { if (BOOLEAN_OPERATORS.includes(operator)) { return { field, operator, value: value.map(v => v.map(whereClause => formatWhereClause(model, whereClause))), }; } 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 = strapi.db.getModelByAssoc(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 = strapi.db.getModelByAssoc(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|Object} options.path - Attribute path */ const findModelPath = ({ rootModel, path }) => { const parts = (_.isObject(path) ? path.path : path).split('.'); let tmpModel = rootModel; let tmpPath = []; for (let part of parts) { const assoc = tmpModel.associations.find(ast => ast.alias === part); if (assoc) { tmpModel = strapi.db.getModelByAssoc(assoc); tmpPath.push(part); } } return tmpPath.length > 0 ? tmpPath.join('.') : null; }; /** * Order a list of entities based on an indexMap * @param {Object} indexMap - index map of the form { [id]: index } */ const orderByIndexMap = indexMap => entities => { return entities .reduce((acc, entry) => { acc[indexMap[entry._id]] = entry; return acc; }, []) .filter(entity => !isNil(entity)); }; module.exports = buildQuery;