Merge pull request #316 from strapi/fix/api-relations-mongoose

Fix api relations
This commit is contained in:
Jim LAURIE 2017-11-03 12:05:39 +01:00 committed by GitHub
commit bf50390bb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 402 additions and 196 deletions

View File

@ -30,8 +30,8 @@
"strapi-utils": "3.0.0-alpha.6.3"
},
"devDependencies": {
"cross-env": "^5.1.0",
"eslint": "^4.9.0",
"cross-env": "^5.1.1",
"eslint": "^4.10.0",
"eslint-config-airbnb": "^15.1.0",
"eslint-config-airbnb-base": "^11.3.2",
"eslint-config-prettier": "^2.6.0",

View File

@ -482,6 +482,199 @@ module.exports = function(strapi) {
}
return result;
},
manageRelations: async function (model, params) {
const models = strapi.models;
const Model = strapi.models[model];
const virtualFields = [];
const record = await Model
.forge({
[Model.primaryKey]: params[Model.primaryKey]
})
.fetch({
withRelated: Model.associations.map(x => x.alias)
});
const response = record ? record.toJSON() : record;
// Only update fields which are on this document.
const values = params.parseRelationships === false ? params.values : Object.keys(JSON.parse(JSON.stringify(params.values))).reduce((acc, current) => {
const association = Model.associations.filter(x => x.alias === current)[0];
const details = Model._attributes[current];
if (_.get(Model._attributes, `${current}.isVirtual`) !== true && _.isUndefined(association)) {
acc[current] = params.values[current];
} else {
switch (association.nature) {
case 'oneToOne':
if (response[current] !== params.values[current]) {
const value = _.isNull(params.values[current]) ? response[current] : params.values;
const recordId = _.isNull(params.values[current]) ? value[Model.primaryKey] || value.id || value._id : typeof value[current] === 'object' ? value[current].id : value[current];
if (response[current] && _.isObject(response[current]) && response[current][Model.primaryKey] !== value[current]) {
virtualFields.push(
this.manageRelations(details.collection || details.model, {
id: response[current][Model.primaryKey],
values: {
[details.via]: null
},
parseRelationships: false
})
);
}
// Remove previous relationship asynchronously if it exists.
virtualFields.push(
models[details.model || details.collection]
.forge({ id : recordId })
.fetch({
withRelated: models[details.model || details.collection].associations.map(x => x.alias)
})
.then(response => {
const record = response ? response.toJSON() : response;
if (record && _.isObject(record[details.via])) {
return this.manageRelations(model, {
id: record[details.via][models[details.model || details.collection].primaryKey] || record[details.via].id,
values: {
[current]: null
},
parseRelationships: false
});
}
return Promise.resolve();
})
);
// Update the record on the other side.
// When params.values[current] is null this means that we are removing the relation.
virtualFields.push(this.manageRelations(details.model || details.collection, {
id: recordId,
values: {
[details.via]: _.isNull(params.values[current]) ? null : value[Model.primaryKey] || params.id || params._id || value.id || value._id
},
parseRelationships: false
}));
acc[current] = _.isNull(params.values[current]) ? null : typeof value[current] === 'object' ? value[current][Model.primaryKey] : value[current];
}
break;
case 'oneToMany':
case 'manyToOne':
case 'manyToMany':
if (details.dominant === true) {
acc[current] = params.values[current];
} else if (response[current] && _.isArray(response[current]) && current !== 'id') {
// Records to add in the relation.
const toAdd = _.differenceWith(params.values[current], response[current], (a, b) =>
((typeof a === 'number') ? a : a[Model.primaryKey].toString()) === b[Model.primaryKey].toString()
);
// Records to remove in the relation.
const toRemove = _.differenceWith(response[current], params.values[current], (a, b) =>
a[Model.primaryKey].toString() === ((typeof b === 'number') ? b : b[Model.primaryKey].toString())
)
.filter(x => toAdd.find(y => x.id === y.id) === undefined);
// Push the work into the flow process.
toAdd.forEach(value => {
value = (typeof value === 'number') ? { id: value } : value;
value[details.via] = parseFloat(params[Model.primaryKey]);
params.values[Model.primaryKey] = parseFloat(params[Model.primaryKey]);
virtualFields.push(this.addRelation(details.model || details.collection, {
id: value[Model.primaryKey] || value.id || value._id,
values: association.nature === 'manyToMany' ? params.values : value,
foreignKey: current
}));
});
toRemove.forEach(value => {
value[details.via] = null;
virtualFields.push(this.removeRelation(details.model || details.collection, {
id: value[Model.primaryKey] || value.id || value._id,
values: association.nature === 'manyToMany' ? params.values : value,
foreignKey: current
}));
});
} else if (_.get(Model._attributes, `${current}.isVirtual`) !== true) {
acc[current] = params.values[current];
}
break;
default:
}
}
return acc;
}, {});
if (!_.isEmpty(values)) {
virtualFields.push(Model
.forge({
[Model.primaryKey]: params[Model.primaryKey]
})
.save(values, {
patch: true
}));
} else {
virtualFields.push(Promise.resolve(_.assign(response, params.values)));
}
// Update virtuals fields.
await Promise.all(virtualFields);
},
addRelation: async function (model, params) {
const Model = strapi.models[model];
const association = Model.associations.filter(x => x.via === params.foreignKey)[0];
if (!association) {
// Resolve silently.
return Promise.resolve();
}
switch (association.nature) {
case 'oneToOne':
case 'oneToMany':
return this.manageRelations(model, params)
case 'manyToMany':
return Model.forge({
[Model.primaryKey]: parseFloat(params[Model.primaryKey])
})[association.alias]().attach(params.values[Model.primaryKey]);
default:
// Resolve silently.
return Promise.resolve();
}
},
removeRelation: async function (model, params) {
const Model = strapi.models[model];
const association = Model.associations.filter(x => x.via === params.foreignKey)[0];
if (!association) {
// Resolve silently.
return Promise.resolve();
}
switch (association.nature) {
case 'oneToOne':
case 'oneToMany':
return this.manageRelations(model, params)
case 'manyToMany':
return Model.forge({
[Model.primaryKey]: parseFloat(params[Model.primaryKey])
})[association.alias]().detach(params.values[Model.primaryKey]);
default:
// Resolve silently.
return Promise.resolve();
}
}
};

View File

@ -28,7 +28,9 @@ module.exports = {
qb.where(key, where.symbol, where.value);
});
qb.orderBy(convertedParams.sort);
if (convertedParams.sort) {
qb.orderBy(convertedParams.sort);
}
qb.offset(convertedParams.start);
@ -56,8 +58,10 @@ module.exports = {
* @return {Promise}
*/
add: (values) => {
return <%= globalID %>.forge(values).save();
add: async (values) => {
const data = await <%= globalID %>.forge(_.omit(values, _.keys(_.groupBy(strapi.models.<%= id %>.associations, 'alias')))).save();
await strapi.hook.bookshelf.manageRelations('<%= id %>', _.merge(_.clone(data.toJSON()), { values }));
return data;
},
/**
@ -66,8 +70,9 @@ module.exports = {
* @return {Promise}
*/
edit: (params, values) => {
return <%= globalID %>.forge(params).save(values, {path: true});
edit: async (params, values) => {
await strapi.hook.bookshelf.manageRelations('<%= id %>', _.merge(_.clone(params), { values }));
return <%= globalID %>.forge(params).save(_.omit(values, _.keys(_.groupBy(strapi.models.<%= id %>.associations, 'alias'))), {path: true});
},
/**
@ -77,172 +82,9 @@ module.exports = {
*/
remove: (params) => {
_.forEach(<%= globalID %>.associations, async association => {
await <%= globalID %>.forge(params)[association.alias]().detach();
});
return <%= globalID %>.forge(params).destroy();
},
/**
* Add relation to a specific <%= id %> (only from a to-many relationships).
*
* @return {Object}
*/
addRelation: (params, values) => {
const relation = _.find(strapi.models.<%= id %>.associations, {alias: params.relation});
if (!_.isEmpty(relation) && _.isArray(values)) {
switch (relation.nature) {
case 'manyToOne': {
const PK = utils.getPK(_.get(relation, relation.type), undefined, strapi.models);
const arrayOfPromises = _.map(values, function (value) {
const parameters = {};
_.set(parameters, PK, value);
_.set(parameters, 'relation', relation.via);
return strapi.services[_.get(relation, relation.type)].editRelation(parameters, [_.get(params, 'id') || null]);
});
return Promise.all(arrayOfPromises);
}
case 'manyToMany': {
return <%= globalID %>.forge(_.omit(params, 'relation'))[params.relation]().attach(values);
}
default:
return new Error('Impossible to add relation on this type of relation');
}
}
return new Error('Relationship Not Found');
},
/**
* Edit relation to a specific <%= id %>.
*
* @return {Object}
*/
editRelation: async (params, values) => {
const relation = _.find(strapi.models.<%= id %>.associations, {alias: params.relation});
if (!_.isEmpty(relation) && _.isArray(values)) {
switch (relation.nature) {
case 'oneWay':
case 'oneToOne':
case 'oneToMany': {
const data = _.set({}, params.relation, _.first(values) || null);
return <%= globalID %>.forge(_.omit(params, 'relation')).save(data, {path: true});
}
case 'manyToOne': {
const PK = utils.getPK(_.get(relation, relation.type), undefined, strapi.models);
const results = await <%= globalID %>.forge(_.omit(params, 'relation')).fetch({
withRelated: _.get(params, 'relation')
});
// Remove relationship between records.
const data = results.toJSON() || {};
const currentValues = _.keys(_.groupBy(_.get(data, _.get(params, 'relation')), PK));
const valuesToRemove = _.difference(currentValues, values);
const arrayOfRemovePromises = _.map(valuesToRemove, value => {
const params = {};
_.set(params, PK, value);
_.set(params, 'relation', relation.via);
return strapi.services[_.get(relation, relation.type)].editRelation(params, [null]);
});
await Promise.all(arrayOfRemovePromises);
// Add relationship between records.
const arrayOfAddPromises = _.map(values, value => {
const params = {};
_.set(params, PK, value);
_.set(params, 'relation', relation.via);
return strapi.services[_.get(relation, relation.type)].editRelation(params, [_.get(params, 'id') || null]);
});
await Promise.all(arrayOfAddPromises);
return;
}
case 'manyToMany': {
const results = <%= globalID %>.forge(_.omit(params, 'relation')).fetch({
withRelated: _.get(params, 'relation')
});
const data = results.toJSON() || {};
const PK = utils.getPK('<%= globalID %>', <%= globalID %>, strapi.models);
// Values to add
const currentValues = _.keys(_.groupBy(_.get(data, _.get(params, 'relation')), PK));
const valuesToAdd = _.difference(_.map(values, o => {
return o.toString();
}), currentValues);
try {
await <%= globalID %>.forge(_.omit(params, 'relation'))[params.relation]().attach(valuesToAdd);
} catch (err) {
return err;
}
// Values to remove
const valuesToDrop = _.difference(currentValues, _.map(values, o => {
return o.toString();
}));
try {
await <%= globalID %>.forge(_.omit(params, 'relation'))[params.relation]().detach(valuesToDrop);
} catch (err) {
return err;
}
return;
}
default:
return new Error('Impossible to update relation on this type of relation');
}
}
return new Error ('Relationship Not Found');
},
/**
* Promise to remove a specific entry from a specific <%= id %> (only from a to-many relationships).
*
* @return {Promise}
*/
removeRelation: (params, values) => {
const relation = _.find(strapi.models.<%= id %>.associations, {alias: params.relation});
if (!_.isEmpty(relation) && _.isArray(values)) {
switch (relation.nature) {
case 'manyToOne':
const PK = utils.getPK(_.get(relation, relation.type), undefined, strapi.models);
const arrayOfPromises = _.map(values, value => {
const parameters = {};
_.set(parameters, PK, value);
_.set(parameters, 'relation', relation.via);
return strapi.services[_.get(relation, relation.type)].editRelation(parameters, [null]);
});
return Promise.all(arrayOfPromises);
case 'manyToMany':
return <%= globalID %>.forge(_.omit(params, 'relation'))[params.relation]().detach(values);
default:
return new Error('Impossible to delete relation on this type of relation');
}
}
return new Error('Relationship Not Found');
}
};

View File

@ -26,7 +26,7 @@ module.exports = {
.sort(convertedParams.sort)
.skip(convertedParams.start)
.limit(convertedParams.limit)
.populate(_.keys(_.pickBy(strapi.models.<%= humanizeId %>.attributes, { autoPopulate: true })).join(' '));
.populate(_.keys(_.groupBy(_.reject(strapi.models.<%= id %>.associations, {autoPopulate: false}), 'alias')).join(' '));
},
/**
@ -38,7 +38,7 @@ module.exports = {
fetch: (params) => {
return <%= globalID %>
.findOne(params)
.populate(_.keys(_.pickBy(strapi.models.<%= humanizeId %>.attributes, { autoPopulate: true })).join(' '));
.populate(_.keys(_.groupBy(_.reject(strapi.models.<%= id %>.associations, {autoPopulate: false}), 'alias')).join(' '));
},
/**
@ -47,8 +47,10 @@ module.exports = {
* @return {Promise}
*/
add: (values) => {
return <%= globalID %>.create(values);
add: async (values) => {
const data = await <%= globalID %>.create(_.omit(values, _.keys(_.groupBy(strapi.models.<%= id %>.associations, 'alias'))));
await strapi.hook.mongoose.manageRelations('<%= id %>', _.merge(_.clone(data), { values }));
return data;
},
/**
@ -57,10 +59,11 @@ module.exports = {
* @return {Promise}
*/
edit: (params, values) => {
edit: async (params, values) => {
// Note: The current method will return the full response of Mongo.
// To get the updated object, you have to execute the `findOne()` method
// or use the `findOneOrUpdate()` method with `{ new:true }` option.
await strapi.hook.mongoose.manageRelations('<%= id %>', _.merge(_.clone(params), { values }));
return <%= globalID %>.update(params, values, { multi: true });
},
@ -70,9 +73,22 @@ module.exports = {
* @return {Promise}
*/
remove: params => {
remove: async params => {
// Note: To get the full response of Mongo, use the `remove()` method
// or add spent the parameter `{ passRawResult: true }` as second argument.
return <%= globalID %>.findOneAndRemove(params, {});
const data = await <%= globalID %>.findOneAndRemove(params, {})
.populate(_.keys(_.groupBy(_.reject(strapi.models.<%= id %>.associations, {autoPopulate: false}), 'alias')).join(' '));
_.forEach(<%= globalID %>.associations, async association => {
const search = (_.endsWith(association.nature, 'One')) ? { [association.via]: data._id } : { [association.via]: { $in: [data._id] } };
const update = (_.endsWith(association.nature, 'One')) ? { [association.via]: null } : { $pull: { [association.via]: data._id } };
await strapi.models[association.model || association.collection].update(
search,
update,
{ multi: true });
});
return data;
}
};

View File

@ -1,6 +1,6 @@
{
"connection": "<%= connection %>",
"tableName": "<%= idPluralized %>",
"collectionName": "<%= idPluralized %>",
"options": {
"increments": true,
"timestamps": true,

View File

@ -342,6 +342,151 @@ module.exports = function (strapi) {
}
return result;
},
manageRelations: async function (model, params) {
const models = strapi.models;
const Model = strapi.models[model];
const virtualFields = [];
const response = await Model
.findOne({
[Model.primaryKey]: params._id || params.id
})
.populate(_.keys(_.groupBy(_.reject(Model.associations, {autoPopulate: false}), 'alias')).join(' '));
// Only update fields which are on this document.
const values = params.parseRelationships === false ? params.values : Object.keys(JSON.parse(JSON.stringify(params.values))).reduce((acc, current) => {
const association = Model.associations.filter(x => x.alias === current)[0];
const details = Model._attributes[current];
if (_.get(Model._attributes, `${current}.isVirtual`) !== true && _.isUndefined(association)) {
acc[current] = params.values[current];
} else {
switch (association.nature) {
case 'oneToOne':
if (response[current] !== params.values[current]) {
const value = _.isNull(params.values[current]) ? response[current] : params.values;
const recordId = _.isNull(params.values[current]) ? value[Model.primaryKey] || value.id || value._id : value[current];
if (response[current] && _.isObject(response[current]) && response[current][Model.primaryKey] !== value[current]) {
virtualFields.push(
this.manageRelations(details.model || details.collection, {
_id: response[current][Model.primaryKey],
values: {
[details.via]: null
},
parseRelationships: false
})
);
}
// Remove previous relationship asynchronously if it exists.
virtualFields.push(
models[details.model || details.collection]
.findOne({ id : recordId })
.populate(_.keys(_.groupBy(_.reject(models[details.model || details.collection].associations, {autoPopulate: false}), 'alias')).join(' '))
.then(record => {
if (record && _.isObject(record[details.via])) {
return this.manageRelations(details.model || details.collection, {
id: record[details.via][Model.primaryKey] || record[details.via].id,
values: {
[current]: null
},
parseRelationships: false
});
}
return Promise.resolve();
})
);
// Update the record on the other side.
// When params.values[current] is null this means that we are removing the relation.
virtualFields.push(this.manageRelations(details.model || details.collection, {
id: recordId,
values: {
[details.via]: _.isNull(params.values[current]) ? null : value[Model.primaryKey] || params.id || params._id || value.id || value._id
},
parseRelationships: false
}));
acc[current] = _.isNull(params.values[current]) ? null : value[current];
}
break;
case 'oneToMany':
case 'manyToOne':
case 'manyToMany':
if (details.dominant === true) {
acc[current] = params.values[current];
} else if (response[current] && _.isArray(response[current]) && current !== 'id') {
// Records to add in the relation.
const toAdd = _.differenceWith(params.values[current], response[current], (a, b) =>
((typeof a === 'string') ? a : a[Model.primaryKey].toString()) === b[Model.primaryKey].toString()
);
// Records to remove in the relation.
const toRemove = _.differenceWith(response[current], params.values[current], (a, b) =>
a[Model.primaryKey].toString() === ((typeof b === 'string') ? b : b[Model.primaryKey].toString())
)
.filter(x => toAdd.find(y => x.id === y.id) === undefined);
// Push the work into the flow process.
toAdd.forEach(value => {
value = (typeof value === 'string') ? { _id: value } : value;
if (association.nature === 'manyToMany' && !_.isArray(params.values[Model.primaryKey])) {
value[details.via] = (value[details.via] || []).concat([response[Model.primaryKey]]);
} else {
value[details.via] = params[Model.primaryKey];
}
virtualFields.push(this.manageRelations(details.model || details.collection, {
id: value[Model.primaryKey] || value.id || value._id,
values: value,
foreignKey: current
}));
});
toRemove.forEach(value => {
value = (typeof value === 'string') ? { _id: value } : value;
if (association.nature === 'manyToMany' && !_.isArray(params.values[Model.primaryKey])) {
value[details.via] = value[details.via].filter(x => x.toString() !== response[Model.primaryKey].toString());
} else {
value[details.via] = null;
}
virtualFields.push(this.manageRelations(details.model || details.collection, {
id: value[Model.primaryKey] || value.id || value._id,
values: value,
foreignKey: current
}));
});
} else if (_.get(Model._attributes, `${current}.isVirtual`) !== true) {
acc[current] = params.values[current];
}
break;
default:
}
}
return acc;
}, {});
virtualFields.push(Model
.update({
[Model.primaryKey]: params[Model.primaryKey] || params.id
}, values, {
strict: false
}));
// Update virtuals fields.
const process = await Promise.all(virtualFields);
return process[process.length - 1];
}
};

View File

@ -5,7 +5,7 @@ module.exports = {
return await this
.forge()
.fetchAll({
withRelated: this.associations.map(x => x.alias).join(' ')
withRelated: this.associations.map(x => x.alias)
});
},
@ -21,7 +21,7 @@ module.exports = {
[this.primaryKey]: params[this.primaryKey]
})
.fetch({
withRelated: this.associations.map(x => x.alias).join(' ')
withRelated: this.associations.map(x => x.alias)
});
return record ? record.toJSON() : record;

View File

@ -49,8 +49,8 @@
"strapi-helper-plugin": "3.0.0-alpha.6.3"
},
"devDependencies": {
"cross-env": "^5.1.0",
"eslint": "^4.9.0",
"cross-env": "^5.1.1",
"eslint": "^4.10.0",
"eslint-config-airbnb": "^15.1.0",
"eslint-config-airbnb-base": "^11.3.2",
"eslint-config-prettier": "^2.6.0",

View File

@ -36,8 +36,8 @@
"strapi-generate-api": "3.0.0-alpha.6.3"
},
"devDependencies": {
"cross-env": "^5.1.0",
"eslint": "^4.9.0",
"cross-env": "^5.1.1",
"eslint": "^4.10.0",
"eslint-config-airbnb": "^15.1.0",
"eslint-config-airbnb-base": "^11.3.2",
"eslint-config-prettier": "^2.6.0",

View File

@ -59,7 +59,9 @@ module.exports = {
generateAPI: (name, description, connection, collectionName, attributes) => {
description = _.replace(description, /\"/g, '\\"');
const template = _.get(strapi.config.currentEnvironment, `database.connections.${connection}.connector`, 'strapi-mongoose').split('-')[1];
return new Promise((resolve, reject) => {
const scope = {
generatorType: 'api',
@ -70,7 +72,8 @@ module.exports = {
description,
attributes,
connection,
collectionName: !_.isEmpty(collectionName) ? collectionName : undefined
collectionName: !_.isEmpty(collectionName) ? collectionName : undefined,
tpl: template
}
};

View File

@ -33,8 +33,8 @@
"reactstrap": "^4.8.0"
},
"devDependencies": {
"cross-env": "^5.1.0",
"eslint": "^4.9.0",
"cross-env": "^5.1.1",
"eslint": "^4.10.0",
"eslint-config-airbnb": "^15.1.0",
"eslint-config-airbnb-base": "^11.3.2",
"eslint-config-prettier": "^2.6.0",

View File

@ -242,7 +242,7 @@ module.exports = {
collection: association.collection,
via: association.via || undefined,
nature: infos.nature,
autoPopulate: (_.get(association, 'autoPopulate') || _.get(strapi.config, 'jsonapi.enabled')) === true,
autoPopulate: _.get(association, 'autoPopulate', true),
dominant: details.dominant !== true
});
} else if (association.hasOwnProperty('model')) {
@ -252,7 +252,7 @@ module.exports = {
model: association.model,
via: association.via || undefined,
nature: infos.nature,
autoPopulate: (_.get(association, 'autoPopulate') || _.get(strapi.config, 'jsonapi.enabled')) === true,
autoPopulate: _.get(association, 'autoPopulate', true),
dominant: details.dominant !== true
});
}

View File

@ -2,7 +2,7 @@
const glob = require('glob');
const path = require('path');
const { after, includes, indexOf, dropRight, uniq, isUndefined, get, defaultsDeep, set } = require('lodash');
const { after, includes, indexOf, dropRight, uniq, isUndefined, get, defaultsDeep, set, merge} = require('lodash');
module.exports = function() {
// Method to initialize hooks and emit an event.
@ -16,7 +16,9 @@ module.exports = function() {
}
}, this.config.hook.timeout || 1000);
module(this).initialize.call(module, err => {
const loadedModule = module(this);
loadedModule.initialize.call(module, err => {
timeout = false;
if (err) {
@ -26,6 +28,9 @@ module.exports = function() {
}
this.hook[hook].loaded = true;
this.hook[hook] = merge(this.hook[hook], loadedModule);
this.emit('hook:' + hook + ':loaded');
// Remove listeners.
this.removeAllListeners('hook:' + hook + ':loaded');

View File

@ -3,7 +3,7 @@
const glob = require('glob');
const path = require('path');
const { parallel } = require('async');
const { after, includes, indexOf, drop, dropRight, uniq, defaultsDeep, get, set, isEmpty, isUndefined, union } = require('lodash');
const { after, includes, indexOf, drop, dropRight, uniq, defaultsDeep, get, set, isEmpty, isUndefined, union, merge } = require('lodash');
module.exports = function() {
const accepted = Object.keys(this.plugins).map(url => `^\/${url}/`).concat(['^\/admin/']);
@ -26,6 +26,8 @@ module.exports = function() {
}
}, this.config.middleware.timeout || 1000);
this.middleware[middleware] = merge(this.middleware[middleware], module);
module.initialize.call(module, err => {
timeout = false;