mirror of
https://github.com/strapi/strapi.git
synced 2025-07-23 17:10:08 +00:00
535 lines
14 KiB
JavaScript
535 lines
14 KiB
JavaScript
const _ = require('lodash');
|
|
const utils = require('./utils')();
|
|
|
|
/**
|
|
* 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 = {},
|
|
populate = [],
|
|
aggregate = false,
|
|
} = {}) => {
|
|
const deepFilters = (filters.where || []).filter(
|
|
({ field }) => field.split('.').length > 1
|
|
);
|
|
|
|
if (deepFilters.length === 0 && aggregate === false) {
|
|
return buildSimpleQuery({ model, filters, populate });
|
|
}
|
|
|
|
return buildDeepQuery({ model, filters, populate });
|
|
};
|
|
|
|
/**
|
|
* 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.filers - An object with the possible filters (start, limit, sort, where)
|
|
* @param {Object} options.populate - An array of paths to populate
|
|
*/
|
|
const buildSimpleQuery = ({ model, filters, populate }) => {
|
|
const { where = [] } = filters;
|
|
|
|
const wheres = where.map(buildWhereClause);
|
|
const findCriteria = wheres.length > 0 ? { $and: 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, 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));
|
|
|
|
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 '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) => {
|
|
if (_.has(filters, 'where') && Array.isArray(filters.where)) {
|
|
return filters.where.map(whereClause => {
|
|
return {
|
|
$match: buildWhereClause(formatWhereClause(model, whereClause)),
|
|
};
|
|
});
|
|
}
|
|
|
|
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;
|