Introduce the new Builder/Converter for Mongoose ORM

This commit is contained in:
Kamal Bennani 2019-02-02 13:11:46 +01:00 committed by Alexandre Bodin
parent c93311557e
commit 328f09da29
5 changed files with 454 additions and 4 deletions

View 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
};

View File

@ -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;

View 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();

View 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,
};

View File

@ -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;
};