mirror of
https://github.com/strapi/strapi.git
synced 2025-12-27 07:03:38 +00:00
Introduce the new Builder/Converter for Mongoose ORM
This commit is contained in:
parent
c93311557e
commit
328f09da29
115
packages/strapi-hook-mongoose/lib/converter.js
Normal file
115
packages/strapi-hook-mongoose/lib/converter.js
Normal file
@ -0,0 +1,115 @@
|
||||
const _ = require('lodash');
|
||||
const getUtils = require('./utils');
|
||||
const { getAssociationFromField } = require('./query-utils');
|
||||
|
||||
const utils = getUtils();
|
||||
|
||||
class Converter {
|
||||
constructor(model, filter) {
|
||||
this.model = model;
|
||||
this.query = {
|
||||
...this.buildFilter(filter),
|
||||
where: this.buildWhere(filter.where),
|
||||
};
|
||||
}
|
||||
|
||||
buildFilter(filter) {
|
||||
// Remove where from the filter
|
||||
const cleanFilter = _.omit(filter, 'where');
|
||||
return _.omitBy({
|
||||
...cleanFilter,
|
||||
sort: _.isPlainObject(cleanFilter.sort)
|
||||
? `${cleanFilter.sort.order === 'desc' ? '-' : ''}${cleanFilter.sort.key}`
|
||||
: undefined
|
||||
}, _.isUndefined);
|
||||
}
|
||||
|
||||
buildWhere(where) {
|
||||
let query = {};
|
||||
_.forEach(where, (_value, key) => {
|
||||
// To make sure that the value is not mutated
|
||||
let value = _.cloneDeep(_value);
|
||||
if (key === 'and' || key === 'or') {
|
||||
if (_.isArray(value)) {
|
||||
value = _.map(value, (innerWhere) => this.buildWhere(innerWhere));
|
||||
}
|
||||
query[`$${key}`] = value;
|
||||
delete query[key];
|
||||
return;
|
||||
}
|
||||
|
||||
let operation;
|
||||
// Case of an operation value
|
||||
if (_.isPlainObject(value)) {
|
||||
operation = _.keys(value)[0];
|
||||
value = value[operation];
|
||||
}
|
||||
|
||||
const fieldId = this.getFieldId(key, value, operation);
|
||||
|
||||
if (operation) {
|
||||
if (operation === 'between') {
|
||||
query[fieldId] = {
|
||||
$gte: value[0],
|
||||
$lte: value[1]
|
||||
};
|
||||
} else if (operation === 'in' || operation === 'nin') {
|
||||
query[fieldId] = {
|
||||
[`$${operation}`]: _.castArray(value).map(utils.valueToId),
|
||||
};
|
||||
} else if (operation === 'contains') {
|
||||
query[fieldId] = { $regex: new RegExp(value), $options: 'i' };
|
||||
} else if (operation === 'containss') {
|
||||
query[fieldId] = { $regex: new RegExp(value) };
|
||||
} else {
|
||||
query[fieldId] = {
|
||||
[`$${operation}`]: utils.valueToId(value),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
query[fieldId] = utils.valueToId(value);
|
||||
}
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to suffix an association field with its primaryKey
|
||||
* so that it works with the new population system.
|
||||
*
|
||||
* @example
|
||||
* Get me all users that have administrator role (id: '5af470063af04c75f7e91db3')
|
||||
* where = {
|
||||
* role: '5af470063af04c75f7e91db3'
|
||||
* }
|
||||
*
|
||||
* // => {
|
||||
* role.id: '5af470063af04c75f7e91db3'
|
||||
* }
|
||||
*
|
||||
*/
|
||||
getFieldId(fieldId, value, operation = 'eq') {
|
||||
const { association, model } = getAssociationFromField(this.model, fieldId);
|
||||
const shouldFieldBeSuffixed =
|
||||
association &&
|
||||
!_.endsWith(fieldId, model.primaryKey) && (
|
||||
['in', 'nin'].includes(operation) || // 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(operation) && utils.isMongoId(value)) // Only suffix the field if the operators are eq or ne and the value is a valid mongo id
|
||||
);
|
||||
|
||||
if (shouldFieldBeSuffixed) {
|
||||
return `${fieldId}.${model.primaryKey}`;
|
||||
}
|
||||
|
||||
return fieldId;
|
||||
}
|
||||
|
||||
convert() {
|
||||
return this.query;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Converter
|
||||
};
|
||||
@ -20,6 +20,8 @@ const { models: utilsModels } = require('strapi-utils');
|
||||
const utils = require('./utils/');
|
||||
|
||||
const relations = require('./relations');
|
||||
const { Query } = require('./query');
|
||||
const { Converter } = require('./converter');
|
||||
|
||||
/**
|
||||
* Mongoose hook
|
||||
@ -578,7 +580,10 @@ module.exports = function (strapi) {
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
Query,
|
||||
Converter,
|
||||
}, relations);
|
||||
|
||||
return hook;
|
||||
|
||||
262
packages/strapi-hook-mongoose/lib/query-utils.js
Normal file
262
packages/strapi-hook-mongoose/lib/query-utils.js
Normal file
@ -0,0 +1,262 @@
|
||||
const _ = require("lodash");
|
||||
|
||||
const buildTempFieldPath = field => {
|
||||
return `__${field}`;
|
||||
};
|
||||
|
||||
const restoreRealFieldPath = (field, prefix) => {
|
||||
return `${prefix}${field}`;
|
||||
};
|
||||
|
||||
class QueryBuilder {
|
||||
constructor() {
|
||||
this.buildQueryJoins = this.buildQueryJoins.bind(this);
|
||||
this.buildQueryFilter = this.buildQueryFilter.bind(this);
|
||||
}
|
||||
|
||||
populateAssociation(ast, prefixPath = '') {
|
||||
const stages = [];
|
||||
const { models } = ast.plugin ? strapi.plugins[ast.plugin] : strapi;
|
||||
const model = models[ast.collection || ast.model];
|
||||
|
||||
// Make sure that the model is defined (it'll not be defined in case of related association in upload plugin)
|
||||
if (!model) {
|
||||
return stages;
|
||||
}
|
||||
|
||||
const from = model.collectionName;
|
||||
const isDominantAssociation =
|
||||
(ast.dominant && ast.nature === "manyToMany") || !!ast.model;
|
||||
|
||||
const _localField =
|
||||
!isDominantAssociation || ast.via === "related" ? "_id" : ast.alias;
|
||||
|
||||
const localField = `${prefixPath}${_localField}`;
|
||||
|
||||
const foreignField = ast.filter
|
||||
? `${ast.via}.ref`
|
||||
: isDominantAssociation
|
||||
? "_id"
|
||||
: ast.via;
|
||||
|
||||
// Add the juncture like the `.populate()` function
|
||||
const asTempPath = buildTempFieldPath(ast.alias, prefixPath);
|
||||
const asRealPath = restoreRealFieldPath(ast.alias, prefixPath);
|
||||
|
||||
if (ast.plugin === 'upload') {
|
||||
// Filter on the correct upload field
|
||||
stages.push({
|
||||
$lookup: {
|
||||
from,
|
||||
let: { local_id: `$${localField}` },
|
||||
pipeline: [
|
||||
{ $unwind: { path: `$${ast.via}`, preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$match: {
|
||||
$expr: {
|
||||
$and: [
|
||||
{ $eq: [`$${foreignField}`, '$$local_id'] },
|
||||
{ $eq: [`$${ast.via}.${ast.filter}`, ast.alias] }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
as: asTempPath
|
||||
}
|
||||
});
|
||||
} else {
|
||||
stages.push({
|
||||
$lookup: {
|
||||
from,
|
||||
localField,
|
||||
foreignField,
|
||||
as: asTempPath
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Unwind the relation's result if only one is expected
|
||||
if (ast.type === "model") {
|
||||
stages.push({
|
||||
$unwind: {
|
||||
path: `$${asTempPath}`,
|
||||
preserveNullAndEmptyArrays: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Preserve relation field if it is empty
|
||||
stages.push({
|
||||
$addFields: {
|
||||
[asRealPath]: {
|
||||
$ifNull: [`$${asTempPath}`, null]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove temp field
|
||||
stages.push({
|
||||
$project: {
|
||||
[asTempPath]: 0
|
||||
}
|
||||
});
|
||||
|
||||
return stages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of relations to populate
|
||||
*/
|
||||
buildQueryJoins(strapiModel, { whitelistedPopulate = null, prefixPath = '' } = {}) {
|
||||
return _.chain(strapiModel.associations)
|
||||
.filter(ast => {
|
||||
// Included only whitelisted relation if needed
|
||||
if (whitelistedPopulate) {
|
||||
return _.includes(whitelistedPopulate, ast.alias);
|
||||
}
|
||||
return ast.autoPopulate;
|
||||
})
|
||||
.map(ast => this.populateAssociation(ast, prefixPath))
|
||||
.flatten()
|
||||
.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of filters to apply to the model
|
||||
*/
|
||||
buildQueryFilter(strapiModel, where) {
|
||||
if (_.isEmpty(where)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const joins = this.buildJoinsFromWhere(strapiModel, where);
|
||||
|
||||
const filters = _.map(where, (value, fieldPath) => ({
|
||||
$match: { [fieldPath]: value }
|
||||
}));
|
||||
|
||||
return [...joins, ...filters];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an object containing the association and its corresponding model from a given field id
|
||||
* @param {MongooseModel} strapiModel
|
||||
* @param {string} fieldId
|
||||
*
|
||||
* @example
|
||||
* strapiModel = Post
|
||||
* fieldId = 'author.company'
|
||||
* // => {
|
||||
* association: CompanyAssociation
|
||||
* model: CompanyModel
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* strapiModel = Post
|
||||
* fieldId = 'author.company.name'
|
||||
* // => {} Because "name" is not an association of company, it's a primitive field.
|
||||
*/
|
||||
getAssociationFromField(strapiModel, fieldId) {
|
||||
const associationParts = fieldId.split(".");
|
||||
let association;
|
||||
let currentModel = strapiModel;
|
||||
_.forEach(associationParts, astPart => {
|
||||
association = currentModel.associations.find(
|
||||
a => a.alias === astPart
|
||||
);
|
||||
|
||||
if (association) {
|
||||
const { models } = association.plugin ? strapi.plugins[association.plugin] : strapi;
|
||||
currentModel = models[association.collection || association.model];
|
||||
}
|
||||
});
|
||||
|
||||
if (association) {
|
||||
return {
|
||||
association,
|
||||
model: currentModel,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the minimal relation to populate
|
||||
* @example
|
||||
* where = {
|
||||
* "role.name": "administrator",
|
||||
* "subjects.code": "S1",
|
||||
* "organization.name": "strapi",
|
||||
* "organization.creator.name": "admin",
|
||||
* "organization.courses.code": "C1",
|
||||
* "organization.courses.batches.code": "B1",
|
||||
* "organization.subjects.teachers.code": "T1",
|
||||
* };
|
||||
* // =>
|
||||
* [
|
||||
* 'role',
|
||||
* 'subjects',
|
||||
* 'organization.creator',
|
||||
* 'organization.courses.batches',
|
||||
* 'organization.subjects.teachers',
|
||||
* ]
|
||||
*/
|
||||
extractRelationsFromWhere(where) {
|
||||
return _.chain(where)
|
||||
.keys()
|
||||
.map(field => {
|
||||
const parts = field.split('.');
|
||||
return _.size(parts) === 1 ? field : _.initial(parts).join('.');
|
||||
})
|
||||
.flatten()
|
||||
.sort()
|
||||
.reverse()
|
||||
.reduce((acc, currentValue) => {
|
||||
const alreadyPopulated = _.some(acc, item => _.startsWith(item, currentValue));
|
||||
if (!alreadyPopulated) {
|
||||
acc.push(currentValue);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.value();
|
||||
}
|
||||
|
||||
buildJoinsFromWhere(strapiModel, where) {
|
||||
const relationToPopulate = this.extractRelationsFromWhere(where);
|
||||
let result = [];
|
||||
_.forEach(relationToPopulate, (fieldPath) => {
|
||||
const associationParts = fieldPath.split(".");
|
||||
|
||||
let currentModel = strapiModel;
|
||||
let nextPrefixedPath = '';
|
||||
_.forEach(associationParts, astPart => {
|
||||
const association = currentModel.associations.find(
|
||||
a => a.alias === astPart
|
||||
);
|
||||
|
||||
if (association) {
|
||||
const { models } = association.plugin ? strapi.plugins[association.plugin] : strapi;
|
||||
const model = models[association.collection || association.model];
|
||||
|
||||
// Generate lookup for this relation
|
||||
result.push(
|
||||
...this.buildQueryJoins(currentModel, {
|
||||
whitelistedPopulate: [astPart],
|
||||
prefixPath: nextPrefixedPath
|
||||
})
|
||||
);
|
||||
|
||||
currentModel = model;
|
||||
nextPrefixedPath += `${astPart}.`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new QueryBuilder();
|
||||
46
packages/strapi-hook-mongoose/lib/query.js
Normal file
46
packages/strapi-hook-mongoose/lib/query.js
Normal file
@ -0,0 +1,46 @@
|
||||
const { has, isEmpty, get } = require('lodash');
|
||||
const { buildQueryJoins, buildQueryFilter } = require('./query-utils');
|
||||
|
||||
class Query {
|
||||
constructor(model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
buildQuery(filter) {
|
||||
const filterStage = buildQueryFilter(this.model, filter.where);
|
||||
const query = this.model.aggregate(filterStage);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
find(filter) {
|
||||
this.query = this.buildQuery(filter);
|
||||
if (!isEmpty(filter.sort)) this.query.sort(filter.sort);
|
||||
if (has(filter, 'start')) this.query.skip(filter.start);
|
||||
if (has(filter, 'limit')) this.query.limit(filter.limit);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
count(filter) {
|
||||
this.query = this.buildQuery(filter);
|
||||
this.query = this.query
|
||||
.count("count")
|
||||
.then(result => get(result, `0.count`, 0));
|
||||
return this;
|
||||
}
|
||||
|
||||
populate(populate) {
|
||||
const queryJoins = buildQueryJoins(this.model, { whitelistedPopulate: populate });
|
||||
this.query = this.query.append(queryJoins);
|
||||
return this;
|
||||
}
|
||||
|
||||
execute() {
|
||||
return this.query;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Query,
|
||||
};
|
||||
@ -1,10 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('lodash');
|
||||
const Mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* Module dependencies
|
||||
*/
|
||||
|
||||
module.exports = (mongoose = new Mongoose()) => {
|
||||
module.exports = (mongoose = Mongoose) => {
|
||||
mongoose.Schema.Types.Decimal = require('mongoose-float').loadType(mongoose, 2);
|
||||
mongoose.Schema.Types.Float = require('mongoose-float').loadType(mongoose, 20);
|
||||
|
||||
@ -17,7 +20,7 @@ module.exports = (mongoose = new Mongoose()) => {
|
||||
return this.toString();
|
||||
};
|
||||
|
||||
const fn = {
|
||||
const utils = {
|
||||
convertType: mongooseType => {
|
||||
switch (mongooseType.toLowerCase()) {
|
||||
case 'array':
|
||||
@ -50,8 +53,27 @@ module.exports = (mongoose = new Mongoose()) => {
|
||||
return 'String';
|
||||
default:
|
||||
}
|
||||
},
|
||||
valueToId: (value) => {
|
||||
return utils.isMongoId(value)
|
||||
? mongoose.Types.ObjectId(value)
|
||||
: value;
|
||||
},
|
||||
isMongoId: (value) => {
|
||||
if(value instanceof mongoose.Types.ObjectId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_.isString(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Here we don't use mongoose.Types.ObjectId.isValid method because it's a weird check,
|
||||
// it returns for instance true for any integer value ¯\_(ツ)_/¯
|
||||
const hexadecimal = /^[0-9A-F]+$/i;
|
||||
return hexadecimal.test(value) && value.length === 24;
|
||||
}
|
||||
};
|
||||
|
||||
return fn;
|
||||
return utils;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user