Fix relations and graphql nested lookups for both mongo and sql

This commit is contained in:
Alexandre Bodin 2019-03-28 12:13:32 +01:00
parent aa0ee1b56a
commit b8dc116ff6
7 changed files with 183 additions and 287 deletions

View File

@ -1,4 +1,5 @@
const _ = require('lodash'); const _ = require('lodash');
const pluralize = require('pluralize');
const buildQuery = ({ model, filters }) => qb => { const buildQuery = ({ model, filters }) => qb => {
if (_.has(filters, 'where') && Array.isArray(filters.where)) { if (_.has(filters, 'where') && Array.isArray(filters.where)) {
@ -159,7 +160,7 @@ const buildSingleJoin = (qb, strapiModel, astModel, association) => {
// Join on both ends // Join on both ends
qb.innerJoin( qb.innerJoin(
association.tableCollectionName, association.tableCollectionName,
`${association.tableCollectionName}.${strapiModel.info.name}_${strapiModel.primaryKey}`, `${association.tableCollectionName}.${pluralize.singular(strapiModel.collectionName)}_${strapiModel.primaryKey}`,
`${strapiModel.collectionName}.${strapiModel.primaryKey}` `${strapiModel.collectionName}.${strapiModel.primaryKey}`
); );

View File

@ -30,11 +30,13 @@ const transformToArrayID = (array, association) => {
return []; return [];
}; };
module.exports = { const getModel = (model, plugin) => {
getModel: function (model, plugin) { return _.get(strapi.plugins, [plugin, 'models', model]) || _.get(strapi, ['models', model]) || undefined;
return _.get(strapi.plugins, [plugin, 'models', model]) || _.get(strapi, ['models', model]) || undefined; };
},
const removeUndefinedKeys = obj => _.pickBy(obj, _.negate(_.isUndefined));
module.exports = {
findOne: async function (params, populate) { findOne: async function (params, populate) {
const record = await this const record = await this
.forge({ .forge({
@ -69,214 +71,177 @@ module.exports = {
}, },
update: async function (params) { update: async function (params) {
const virtualFields = []; const relationUpdates = [];
const primaryKeyValue = getValuePrimaryKey(params, this.primaryKey);
const response = await module.exports.findOne.call(this, params); const response = await module.exports.findOne.call(this, params);
// Only update fields which are on this document. // 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 values = params.parseRelationships === false ? params.values : Object.keys(removeUndefinedKeys(params.values)).reduce((acc, current) => {
const property = params.values[current];
const association = this.associations.filter(x => x.alias === current)[0]; const association = this.associations.filter(x => x.alias === current)[0];
const details = this._attributes[current]; const details = this._attributes[current];
if (_.get(this._attributes, `${current}.isVirtual`) !== true && _.isUndefined(association)) { if (!association && _.get(details, 'isVirtual') !== true) {
acc[current] = params.values[current]; return _.set(acc, current, property);
} else { }
switch (association.nature) {
case 'oneWay':
acc[current] = _.get(params.values[current], this.primaryKey, params.values[current]) || null;
break; const assocModel = getModel(details.model || details.collection, details.plugin);
case 'oneToOne': switch (association.nature) {
if (response[current] !== params.values[current]) { case 'oneWay': {
const value = _.isNull(params.values[current]) ? response[current] : params.values; return _.set(acc, current, _.get(property, assocModel.primaryKey, property));
const recordId = _.isNull(params.values[current]) ? getValuePrimaryKey(value, this.primaryKey) : value[current]; }
case 'oneToOne': {
if (response[current] === property) return acc;
const model = module.exports.getModel(details.collection || details.model, details.plugin); if (_.isNull(property)) {
const updatePromise = assocModel.where({
[assocModel.primaryKey]: getValuePrimaryKey(response[current], assocModel.primaryKey)
}).save({ [details.via]: null }, {method: 'update', patch: true, require: false});
// Remove relation in the user side. relationUpdates.push(updatePromise);
virtualFields.push( return _.set(acc, current, null);
module.exports.findOne }
.call(model, { [model.primaryKey]: recordId }, [details.via])
.then(record => {
if (record && _.isObject(record[details.via]) && record.id !== record[details.via][current]) {
return module.exports.update.call(this, {
id: getValuePrimaryKey(record[details.via], model.primaryKey),
values: {
[current]: null
},
parseRelationships: false
});
}
return Promise.resolve();
})
.then(() => {
return module.exports.update.call(model, {
id: getValuePrimaryKey(response[current] || {}, this.primaryKey) || value[current],
values: {
[details.via]: null
},
parseRelationships: false
});
})
.then(() => {
if (!_.isNull(params.values[current])) {
return module.exports.update.call(model, {
id: recordId,
values: {
[details.via]: getValuePrimaryKey(params, this.primaryKey) || null
},
parseRelationships: false
});
}
return Promise.resolve(); // set old relations to null
}) const updateLink = this.where({ [current]: property })
); .save({ [current]: null }, {method: 'update', patch: true, require: false})
.then(() => {
return assocModel
.where({ [this.primaryKey]: property })
.save({ [details.via] : primaryKeyValue}, {method: 'update', patch: true, require: false});
});
acc[current] = _.isNull(params.values[current]) ? null : value[current]; // set new relation
} relationUpdates.push(updateLink);
return _.set(acc, current, property);
}
case 'oneToMany': {
// receive array of ids or array of objects with ids
break; // set relation to null for all the ids not in the list
case 'oneToMany': const currentIds = response[current];
case 'manyToOne': const diff = _.differenceWith(property, currentIds, (a, b) => {
case 'manyToMany': return `${a[assocModel.primaryKey] || a}` === `${b[assocModel.primaryKey] || b}`;
if (response[current] && _.isArray(response[current]) && current !== 'id') { });
// Compare array of ID to find deleted files.
const currentValue = transformToArrayID(response[current], association).map(id => id.toString());
const storedValue = transformToArrayID(params.values[current], association).map(id => id.toString());
const toAdd = _.difference(storedValue, currentValue); const updatePromise = assocModel
const toRemove = _.difference(currentValue, storedValue); .where(assocModel.primaryKey, 'in', currentIds.map(val => val[assocModel.primaryKey]||val))
.save({ [details.via] : null }, { method: 'update', patch: true, require: false })
.then(() => {
return assocModel
.where(assocModel.primaryKey, 'in', diff.map(val => val[assocModel.primaryKey]||val))
.save({ [details.via] : primaryKeyValue }, { method: 'update', patch: true, require: false });
});
const model = module.exports.getModel(details.collection || details.model, details.plugin); relationUpdates.push(updatePromise);
return acc;
}
case 'manyToOne': {
return _.set(acc, current, _.get(property, assocModel.primaryKey, property));
}
case 'manyToMany': {
const currentValue = transformToArrayID(response[current], association).map(id => id.toString());
const storedValue = transformToArrayID(params.values[current], association).map(id => id.toString());
// Push the work into the flow process. const toAdd = _.difference(storedValue, currentValue);
toAdd.forEach(value => { const toRemove = _.difference(currentValue, storedValue);
value = _.isString(value) || _.isNumber(value) ? { [this.primaryKey]: value } : value;
value[details.via] = params.values[this.primaryKey] || params[this.primaryKey]; const collection = this.forge({ [this.primaryKey]: primaryKeyValue })[association.alias]();
const updatePromise = collection
.detach(toRemove)
.then(() => collection.attach(toAdd));
virtualFields.push( relationUpdates.push(updatePromise);
module.exports.addRelation.call(model, { return acc;
id: getValuePrimaryKey(value, this.primaryKey), }
values: value, case 'manyMorphToMany':
foreignKey: current case 'manyMorphToOne':
}) // Update the relational array.
); params.values[current].forEach(obj => {
}); const model = obj.source && obj.source !== 'content-manager' ?
strapi.plugins[obj.source].models[obj.ref]:
strapi.models[obj.ref];
toRemove.forEach(value => { // Remove existing relationship because only one file
value = _.isString(value) || _.isNumber(value) ? { [this.primaryKey]: value } : value; // can be related to this field.
if (association.nature === 'manyMorphToOne') {
value[details.via] = association.nature !== 'manyToMany' ? relationUpdates.push(
null : module.exports.removeRelationMorph.call(this, {
params.values[this.primaryKey] || params[this.primaryKey];
virtualFields.push(
module.exports.removeRelation.call(model, {
id: getValuePrimaryKey(value, this.primaryKey),
values: value,
foreignKey: current
})
);
});
} else if (_.get(this._attributes, `${current}.isVirtual`) !== true) {
if (params.values[current] && typeof params.values[current] === 'object') {
acc[current] = _.get(params.values[current], this.primaryKey);
} else {
acc[current] = params.values[current];
}
}
break;
case 'manyMorphToMany':
case 'manyMorphToOne':
// Update the relational array.
params.values[current].forEach(obj => {
const model = obj.source && obj.source !== 'content-manager' ?
strapi.plugins[obj.source].models[obj.ref]:
strapi.models[obj.ref];
// Remove existing relationship because only one file
// can be related to this field.
if (association.nature === 'manyMorphToOne') {
virtualFields.push(
module.exports.removeRelationMorph.call(this, {
alias: association.alias,
ref: model.collectionName,
refId: obj.refId,
field: obj.field
})
.then(() =>
module.exports.addRelationMorph.call(this, {
id: response[this.primaryKey],
alias: association.alias,
ref: model.collectionName,
refId: obj.refId,
field: obj.field
})
)
);
} else {
virtualFields.push(module.exports.addRelationMorph.call(this, {
id: response[this.primaryKey],
alias: association.alias, alias: association.alias,
ref: model.collectionName, ref: model.collectionName,
refId: obj.refId, refId: obj.refId,
field: obj.field field: obj.field
}));
}
});
break;
case 'oneToManyMorph':
case 'manyToManyMorph': {
// Compare array of ID to find deleted files.
const currentValue = transformToArrayID(response[current], association).map(id => id.toString());
const storedValue = transformToArrayID(params.values[current], association).map(id => id.toString());
const toAdd = _.difference(storedValue, currentValue);
const toRemove = _.difference(currentValue, storedValue);
const model = module.exports.getModel(details.collection || details.model, details.plugin);
toAdd.forEach(id => {
virtualFields.push(
module.exports.addRelationMorph.call(model, {
id,
alias: association.via,
ref: this.collectionName,
refId: response.id,
field: association.alias
}) })
.then(() =>
module.exports.addRelationMorph.call(this, {
id: response[this.primaryKey],
alias: association.alias,
ref: model.collectionName,
refId: obj.refId,
field: obj.field
})
)
); );
}); } else {
relationUpdates.push(module.exports.addRelationMorph.call(this, {
id: response[this.primaryKey],
alias: association.alias,
ref: model.collectionName,
refId: obj.refId,
field: obj.field
}));
}
});
break;
case 'oneToManyMorph':
case 'manyToManyMorph': {
// Compare array of ID to find deleted files.
const currentValue = transformToArrayID(response[current], association).map(id => id.toString());
const storedValue = transformToArrayID(params.values[current], association).map(id => id.toString());
// Update the relational array. const toAdd = _.difference(storedValue, currentValue);
toRemove.forEach(id => { const toRemove = _.difference(currentValue, storedValue);
virtualFields.push(
module.exports.removeRelationMorph.call(model, { const model = getModel(details.collection || details.model, details.plugin);
id,
alias: association.via, toAdd.forEach(id => {
ref: this.collectionName, relationUpdates.push(
refId: response.id, module.exports.addRelationMorph.call(model, {
field: association.alias id,
}) alias: association.via,
); ref: this.collectionName,
}); refId: response.id,
break; field: association.alias
} })
case 'oneMorphToOne': );
case 'oneMorphToMany': });
break;
default: // Update the relational array.
toRemove.forEach(id => {
relationUpdates.push(
module.exports.removeRelationMorph.call(model, {
id,
alias: association.via,
ref: this.collectionName,
refId: response.id,
field: association.alias
})
);
});
break;
} }
case 'oneMorphToOne':
case 'oneMorphToMany':
break;
default:
} }
return acc; return acc;
}, {}); }, {});
if (!_.isEmpty(values)) { if (!_.isEmpty(values)) {
virtualFields.push( relationUpdates.push(
this this
.forge({ .forge({
[this.primaryKey]: getValuePrimaryKey(params, this.primaryKey) [this.primaryKey]: getValuePrimaryKey(params, this.primaryKey)
@ -286,11 +251,11 @@ module.exports = {
}) })
); );
} else { } else {
virtualFields.push(Promise.resolve(_.assign(response, params.values))); relationUpdates.push(Promise.resolve(_.assign(response, params.values)));
} }
// Update virtuals fields. // Update virtuals fields.
await Promise.all(virtualFields); await Promise.all(relationUpdates);
return await this return await this
.forge({ .forge({

View File

@ -5,7 +5,6 @@
// Public node modules. // Public node modules.
const _ = require('lodash'); const _ = require('lodash');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const util = require('util');
// Utils // Utils
const { models: { getValuePrimaryKey } } = require('strapi-utils'); const { models: { getValuePrimaryKey } } = require('strapi-utils');
@ -14,23 +13,21 @@ const getModel = function (model, plugin) {
return _.get(strapi.plugins, [plugin, 'models', model]) || _.get(strapi, ['models', model]) || undefined; return _.get(strapi.plugins, [plugin, 'models', model]) || _.get(strapi, ['models', model]) || undefined;
}; };
const removeUndefinedKeys = obj => _.pickBy(obj, _.negate(_.isUndefined));
module.exports = { module.exports = {
update: async function (params) { update: async function (params) {
const relationUpdates = []; const relationUpdates = [];
const populate = this.associations.map(x => x.alias).join(' '); const populate = this.associations.map(x => x.alias).join(' ');
const primaryKeyValue = getValuePrimaryKey(params, this.primaryKey); const primaryKeyValue = getValuePrimaryKey(params, this.primaryKey);
const response = await this const response = await this
.findOne({ .findOne({ [this.primaryKey]: primaryKeyValue })
[this.primaryKey]: primaryKeyValue
})
.populate(populate) .populate(populate)
.lean(); .lean();
// Only update fields which are on this document. // Only update fields which are on this document.
const values = params.parseRelationships === false ? params.values : Object.keys(params.values).reduce((acc, current) => { const values = params.parseRelationships === false ? params.values : Object.keys(removeUndefinedKeys(params.values)).reduce((acc, current) => {
const property = params.values[current]; const property = params.values[current];
const association = this.associations.find(x => x.alias === current); const association = this.associations.find(x => x.alias === current);
const details = this._attributes[current]; const details = this._attributes[current];
@ -53,21 +50,17 @@ module.exports = {
if (_.isNull(property)) { if (_.isNull(property)) {
const updatePromise = assocModel.updateOne({ const updatePromise = assocModel.updateOne({
[assocModel.primaryKey]: getValuePrimaryKey(response[current], assocModel.primaryKey) [assocModel.primaryKey]: getValuePrimaryKey(response[current], assocModel.primaryKey)
}, { [details.via]: null }) }, { [details.via]: null });
relationUpdates.push(updatePromise) relationUpdates.push(updatePromise);
return _.set(acc, current, null); return _.set(acc, current, null);
} }
// set old relations to null // set old relations to null
const updateLink = this.updateOne({ const updateLink = this.updateOne({ [current]: new mongoose.Types.ObjectId(property) }, { [current]: null })
[current]: new mongoose.Types.ObjectId(property) .then(() => {
}, { [current]: null }) return assocModel.updateOne({ [this.primaryKey]: new mongoose.Types.ObjectId(property) }, { [details.via] : primaryKeyValue});
.then(() => { });
return assocModel.updateOne({
[this.primaryKey]: new mongoose.Types.ObjectId(property)
}, { [details.via] : primaryKeyValue})
})
// set new relation // set new relation
relationUpdates.push(updateLink); relationUpdates.push(updateLink);
@ -79,30 +72,29 @@ module.exports = {
// set relation to null for all the ids not in the list // set relation to null for all the ids not in the list
const currentIds = response[current]; const currentIds = response[current];
const diff = _.differenceWith(property, currentIds, (a, b) => { const diff = _.differenceWith(property, currentIds, (a, b) => {
`${a[assocModel.primaryKey] || a}` === `${b[assocModel.primaryKey] || b}` return `${a[assocModel.primaryKey] || a}` === `${b[assocModel.primaryKey] || b}`;
}) });
const updatePromise = assocModel.updateMany({ const updatePromise = assocModel.updateMany({
[assocModel.primaryKey]: { [assocModel.primaryKey]: {
$in: currentIds.map(val => new mongoose.Types.ObjectId(val[assocModel.primaryKey]||val)) $in: currentIds.map(val => new mongoose.Types.ObjectId(val[assocModel.primaryKey]||val))
} }
}, { [details.via] : null }) }, { [details.via] : null })
.then(() => { .then(() => {
return assocModel.updateMany({ return assocModel.updateMany({
[assocModel.primaryKey]: { [assocModel.primaryKey]: {
$in: diff.map(val => new mongoose.Types.ObjectId(val[assocModel.primaryKey]||val)) $in: diff.map(val => new mongoose.Types.ObjectId(val[assocModel.primaryKey]||val))
} }
}, { [details.via] : primaryKeyValue }) }, { [details.via] : primaryKeyValue });
}) });
relationUpdates.push(updatePromise) relationUpdates.push(updatePromise);
return acc; return acc;
} }
case 'manyToOne': { case 'manyToOne': {
return _.set(acc, current, _.get(property, assocModel.primaryKey, property)); return _.set(acc, current, _.get(property, assocModel.primaryKey, property));
} }
case 'manyToMany': { case 'manyToMany': {
if (details.dominant) { if (details.dominant) {
return _.set(acc, current, property.map(val => val[assocModel.primaryKey] || val)); return _.set(acc, current, property.map(val => val[assocModel.primaryKey] || val));
} }
@ -114,82 +106,18 @@ module.exports = {
}, { }, {
$pull: { [association.via]: new mongoose.Types.ObjectId(primaryKeyValue) } $pull: { [association.via]: new mongoose.Types.ObjectId(primaryKeyValue) }
}) })
.then(() => { .then(() => {
return assocModel.updateMany({ return assocModel.updateMany({
[assocModel.primaryKey]: { [assocModel.primaryKey]: {
$in: property.map(val => new mongoose.Types.ObjectId(val[assocModel.primaryKey] || val)) $in: property.map(val => new mongoose.Types.ObjectId(val[assocModel.primaryKey] || val))
} }
}, { }, {
$addToSet: { [association.via]: [primaryKeyValue] } $addToSet: { [association.via]: [primaryKeyValue] }
}) });
}) });
relationUpdates.push(updatePomise); relationUpdates.push(updatePomise);
return acc; return acc;
// TODO: handle concat or remove from current
if (association.nature === 'manyToMany' && details.dominant === true) {
return _.set(acc, current, property);
}
if (response[current] && _.isArray(response[current]) && current !== 'id') {
// Records to add in the relation.
const toAdd = _.differenceWith(property, response[current], (a, b) =>
(a[this.primaryKey] || a).toString() === (b[this.primaryKey] || b).toString()
);
// Records to remove in the relation.
const toRemove = _.differenceWith(response[current], property, (a, b) =>
(a[this.primaryKey] || a).toString() === (b[this.primaryKey] || b).toString()
)
.filter(x => toAdd.find(y => x.id === y.id) === undefined);
const model = getModel(details.model || details.collection, details.plugin);
// Push the work into the flow process.
toAdd.forEach(value => {
value = _.isString(value) ? { [this.primaryKey]: value } : value;
if (association.nature === 'manyToMany' && !_.isArray(params.values[this.primaryKey] || params[this.primaryKey])) {
value[details.via] = (value[details.via] || [])
.concat([(params.values[this.primaryKey] || params[this.primaryKey])])
.filter(x => {
return x !== null && x !== undefined;
});
} else {
value[details.via] = getValuePrimaryKey(params, this.primaryKey);
}
relationUpdates.push(
module.exports.addRelation.call(model, {
id: getValuePrimaryKey(value, this.primaryKey),
values: _.pick(value, [this.primaryKey, details.via]),
foreignKey: current
})
);
});
toRemove.forEach(value => {
value = _.isString(value) ? { [this.primaryKey]: value } : value;
if (association.nature === 'manyToMany' && !_.isArray(params.values[this.primaryKey] || params[this.primaryKey])) {
value[details.via] = value[details.via].filter(x => _.toString(x) !== _.toString(params.values[this.primaryKey] || params[this.primaryKey]));
} else {
value[details.via] = null;
}
relationUpdates.push(
module.exports.removeRelation.call(model, {
id: getValuePrimaryKey(value, this.primaryKey),
values: _.pick(value, [this.primaryKey, details.via]),
foreignKey: current
})
);
});
return acc;
}
} }
case 'manyMorphToMany': case 'manyMorphToMany':
case 'manyMorphToOne': case 'manyMorphToOne':

View File

@ -162,7 +162,8 @@ module.exports = {
} }
params[primaryKey] = response[primaryKey]; params[primaryKey] = response[primaryKey];
params.values = Object.keys(JSON.parse(JSON.stringify(response))).reduce((acc, current) => {
params.values = Object.keys(response).reduce((acc, current) => {
const association = (strapi.models[params.model] || strapi.plugins[source].models[params.model]).associations.filter(x => x.alias === current)[0]; const association = (strapi.models[params.model] || strapi.plugins[source].models[params.model]).associations.filter(x => x.alias === current)[0];
// Remove relationships. // Remove relationships.

View File

@ -58,6 +58,7 @@ module.exports = {
const { queries, map } = this.extractQueries(model, _.cloneDeep(keys)); const { queries, map } = this.extractQueries(model, _.cloneDeep(keys));
// Run queries in parallel. // Run queries in parallel.
const results = await Promise.all(queries.map(query => this.makeQuery(model, query))); const results = await Promise.all(queries.map(query => this.makeQuery(model, query)));
// Use to match initial queries order. // Use to match initial queries order.
const data = this.mapData(model, keys, map, results); const data = this.mapData(model, keys, map, results);

View File

@ -369,8 +369,8 @@ const buildShadowCRUD = (models, plugin) => {
...Query.convertToQuery(queryParams.where), ...Query.convertToQuery(queryParams.where),
}; };
if (association.nature === 'manyToMany' && association.dominant) { if (model.orm === 'mongoose' && association.nature === 'manyToMany' && association.dominant) {
_.set(queryOpts, ['query', ref.primaryKey], obj[association.alias] || []); _.set(queryOpts, ['query', ref.primaryKey], obj[association.alias].map(val => val[ref.primaryKey] || val) || []);
} }
_.set(queryOpts, ['query', association.via], obj[ref.primaryKey]); _.set(queryOpts, ['query', association.via], obj[ref.primaryKey]);

View File

@ -8,7 +8,7 @@ let rq;
let graphqlQuery; let graphqlQuery;
// utils // utils
const selectFields = doc => _.pick(doc[('id', 'name')]); const selectFields = doc => _.pick(doc, ['id', 'name']);
const documentModel = { const documentModel = {
attributes: [ attributes: [