const _ = require('lodash'); const utils = require('./utils')(); const util = require('util'); 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; }; 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]; }; const computePopulatedPaths = ({ model, populate = [], where = [] }) => { const castedPopulate = populate.map(el => (Array.isArray(el) ? el.join('.') : el)); const castedWhere = where .map(({ field }) => findModelPath({ rootModel: model, path: field })) .filter(path => !!path); return pathsToTree([...castedPopulate, ...castedWhere]); }; const buildQuery = ({ model, filters, populate = [] } = {}) => { let query = model.aggregate(); // compute all the final populated paths and their models const populatedModels = computePopulatedPaths({ model, populate, where: filters.where }); const joins = buildJoins(model, { paths: populatedModels }); query.append(joins); if (_.has(filters, 'where') && Array.isArray(filters.where)) { const matches = filters.where.map(whereClause => { return { $match: buildWhereClause(formatWhereClause(model, whereClause)), }; }); query = query.append(matches); } 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); } if (_.has(filters, 'start')) { query = query.skip(filters.start); } if (_.has(filters, 'limit') && filters.limit >= 0) { query = query.limit(filters.limit); } const hydrateFn = hydrateModel({ model, populatedModels, }); return { count(...args) { return query.count(...args); }, group(...args) { return query.group(...args); }, lean() { return this.then(results => { return results.map(r => r.toObject()); }); }, then(onSuccess, onError) { return query .then(result => (Array.isArray(result) ? result.map(hydrateFn) : hydrateFn(result))) .then(onSuccess, onError); }, catch(...args) { return query.catch(...args); }, }; }; const hydrateModel = ({ model: rootModel, populatedModels }) => obj => { const toSet = Object.keys(populatedModels).reduce((acc, key) => { const val = _.get(obj, key); if (!val) return acc; const assocModel = findModelByPath({ rootModel, path: key }); if (!assocModel) return acc; const subHydrate = hydrateModel({ model: assocModel, populatedModels: populatedModels[key], }); acc.push({ path: key, data: Array.isArray(val) ? val.map(v => subHydrate(v)) : subHydrate(val), }); return acc; }, []); let doc = rootModel.hydrate(obj); toSet.forEach(({ path, data }) => { _.set(doc, path, data); }); return doc; }; const buildWhereClause = ({ field, operator, value }) => { switch (operator) { case 'eq': return { [field]: utils.valueToId(value) }; case 'ne': return { [field]: { $ne: utils.valueToId(value) } }; case 'lt': return { [field]: { $lt: value } }; case 'lte': return { [field]: { $lte: value } }; case 'gt': return { [field]: { $gt: value } }; case 'gte': return { [field]: { $gte: value } }; case 'in': return { [field]: { $in: Array.isArray(value) ? value.map(utils.valueToId) : [utils.valueToId(value)], }, }; case 'nin': return { [field]: { $nin: Array.isArray(value) ? value.map(utils.valueToId) : [utils.valueToId(value)], }, }; case 'contains': { return { [field]: { $regex: value, $options: 'i', }, }; } case 'ncontains': return { [field]: { $not: new RegExp(value, 'i'), }, }; case 'containss': return { [field]: { $regex: value, }, }; case 'ncontainss': return { [field]: { $not: new RegExp(value), }, }; default: throw new Error(`Unhandled whereClause : ${fullField} ${operator} ${value}`); } }; const getAssociationFromFieldKey = (strapiModel, fieldKey) => { let model = strapiModel; let assoc; const parts = fieldKey.split('.'); for (let key of parts) { assoc = model.associations.find(ast => ast.alias === key); if (assoc) { model = findModelByAssoc({ assoc }); } } return { assoc, model, }; }; 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, }; }; const buildMatch = ({ 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 '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 [] } }; const buildLookup = ({ part, model, paths }) => { const assoc = model.associations.find(a => a.alias === part); const assocModel = findModelByAssoc({ assoc }); if (!assocModel) return [] return [ { $lookup: { from: assocModel.collectionName, as: assoc.alias, let: { localId: '$_id', localAlias: `$${assoc.alias}`, }, pipeline: [].concat(buildMatch({ assoc })).concat(buildJoins(assocModel, { paths })), }, }, ].concat( assoc.type === 'model' ? { $unwind: { path: `$${assoc.alias}`, preserveNullAndEmptyArrays: true, }, } : [] ); }; const buildJoins = (model, { paths } = {}) => { return Object.keys(paths).reduce((acc, path) => { return acc.concat(buildLookup({ part: path, paths: paths[path], model })); }, []); }; const pathsToTree = paths => paths.reduce((acc, path) => _.merge(acc, _.set({}, path, {})), {}); module.exports = buildQuery;