diff --git a/README.md b/README.md index ceb5c86f35..8b9d5e6281 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Complete installation requirements can be found in the documentation under value.id || value; -const toIds = (value) => _.castArray(value || []).map(toId); +const toIds = (value) => castArray(value || []).map(toId); -const isValidId = (value) => _.isString(value) || _.isInteger(value); +const isValidId = (value) => isString(value) || isInteger(value); const toAssocs = (data) => { - return _.castArray(data) - .filter((datum) => !_.isNil(datum)) + return castArray(data) + .filter((datum) => !isNil(datum)) .map((datum) => { // if it is a string or an integer return an obj with id = to datum if (isValidId(datum)) { @@ -21,7 +34,7 @@ const toAssocs = (data) => { } // if it is an object check it has at least a valid id - if (!_.has('id', datum) || !isValidId(datum.id)) { + if (!has('id', datum) || !isValidId(datum.id)) { throw new Error(`Invalid id, expected a string or integer, got ${datum}`); } @@ -40,8 +53,8 @@ const processData = (metadata, data = {}, { withDefaults = false } = {}) => { if (types.isScalar(attribute.type)) { const field = createField(attribute); - if (_.isUndefined(data[attributeName])) { - if (!_.isUndefined(attribute.default) && withDefaults) { + if (isUndefined(data[attributeName])) { + if (!isUndefined(attribute.default) && withDefaults) { if (typeof attribute.default === 'function') { obj[attributeName] = attribute.default(); } else { @@ -66,11 +79,11 @@ const processData = (metadata, data = {}, { withDefaults = false } = {}) => { const joinColumnName = attribute.joinColumn.name; // allow setting to null - const attrValue = !_.isUndefined(data[attributeName]) + const attrValue = !isUndefined(data[attributeName]) ? data[attributeName] : data[joinColumnName]; - if (!_.isUndefined(attrValue)) { + if (!isUndefined(attrValue)) { obj[joinColumnName] = attrValue; } @@ -91,8 +104,8 @@ const processData = (metadata, data = {}, { withDefaults = false } = {}) => { continue; } - if (!_.isUndefined(value)) { - if (!_.has('id', value) || !_.has(typeField, value)) { + if (!isUndefined(value)) { + if (!has('id', value) || !has(typeField, value)) { throw new Error(`Expects properties ${typeField} an id to make a morph association`); } @@ -137,7 +150,7 @@ const createEntityManager = (db) => { const states = await db.lifecycles.run('beforeCount', uid, { params }); const res = await this.createQueryBuilder(uid) - .init(_.pick(['_q', 'where', 'filters'], params)) + .init(pick(['_q', 'where', 'filters'], params)) .count() .first() .execute(); @@ -155,7 +168,7 @@ const createEntityManager = (db) => { const metadata = db.metadata.get(uid); const { data } = params; - if (!_.isPlainObject(data)) { + if (!isPlainObject(data)) { throw new Error('Create expects a data object'); } @@ -187,7 +200,7 @@ const createEntityManager = (db) => { const metadata = db.metadata.get(uid); const { data } = params; - if (!_.isArray(data)) { + if (!isArray(data)) { throw new Error('CreateMany expects data to be an array'); } @@ -195,7 +208,7 @@ const createEntityManager = (db) => { processData(metadata, datum, { withDefaults: true }) ); - if (_.isEmpty(dataToInsert)) { + if (isEmpty(dataToInsert)) { throw new Error('Nothing to insert'); } @@ -214,11 +227,11 @@ const createEntityManager = (db) => { const metadata = db.metadata.get(uid); const { where, data } = params; - if (!_.isPlainObject(data)) { + if (!isPlainObject(data)) { throw new Error('Update requires a data object'); } - if (_.isEmpty(where)) { + if (isEmpty(where)) { throw new Error('Update requires a where parameter'); } @@ -232,7 +245,7 @@ const createEntityManager = (db) => { const dataToUpdate = processData(metadata, data); - if (!_.isEmpty(dataToUpdate)) { + if (!isEmpty(dataToUpdate)) { await this.createQueryBuilder(uid).where({ id }).update(dataToUpdate).execute(); } @@ -259,7 +272,7 @@ const createEntityManager = (db) => { const dataToUpdate = processData(metadata, data); - if (_.isEmpty(dataToUpdate)) { + if (isEmpty(dataToUpdate)) { throw new Error('Update requires data'); } @@ -280,7 +293,7 @@ const createEntityManager = (db) => { const { where, select, populate } = params; - if (_.isEmpty(where)) { + if (isEmpty(where)) { throw new Error('Delete requires a where parameter'); } @@ -336,7 +349,7 @@ const createEntityManager = (db) => { for (const attributeName of Object.keys(attributes)) { const attribute = attributes[attributeName]; - const isValidLink = _.has(attributeName, data) && !_.isNil(data[attributeName]); + const isValidLink = has(attributeName, data) && !isNil(data[attributeName]); if (attribute.type !== 'relation' || !isValidLink) { continue; @@ -373,7 +386,7 @@ const createEntityManager = (db) => { }; }); - if (_.isEmpty(rows)) { + if (isEmpty(rows)) { continue; } @@ -398,10 +411,18 @@ const createEntityManager = (db) => { ...(data.__pivot || {}), })); - if (_.isEmpty(rows)) { + if (isEmpty(rows)) { continue; } + // delete previous relations + await deleteRelatedMorphOneRelationsAfterMorphToManyUpdate(rows, { + uid, + attributeName, + joinTable, + db, + }); + await this.createQueryBuilder(joinTable.name).insert(rows).execute(); continue; @@ -450,7 +471,7 @@ const createEntityManager = (db) => { if (isOneToAny(attribute) && isBidirectional(attribute)) { await this.createQueryBuilder(joinTable.name) .delete() - .where({ [inverseJoinColumn.name]: _.castArray(data[attributeName]) }) + .where({ [inverseJoinColumn.name]: castArray(data[attributeName]) }) .where(joinTable.on || {}) .execute(); } @@ -490,7 +511,7 @@ const createEntityManager = (db) => { for (const attributeName of Object.keys(attributes)) { const attribute = attributes[attributeName]; - if (attribute.type !== 'relation' || !_.has(attributeName, data)) { + if (attribute.type !== 'relation' || !has(attributeName, data)) { continue; } @@ -503,12 +524,14 @@ const createEntityManager = (db) => { // set columns const { idColumn, typeColumn } = targetAttribute.morphColumn; + // update instead of deleting because the relation is directly on the entity table + // and not in a join table await this.createQueryBuilder(target) .update({ [idColumn.name]: null, [typeColumn.name]: null }) .where({ [idColumn.name]: id, [typeColumn.name]: uid }) .execute(); - if (!_.isNull(data[attributeName])) { + if (!isNull(data[attributeName])) { await this.createQueryBuilder(target) .update({ [idColumn.name]: id, [typeColumn.name]: uid }) .where({ id: toId(data[attributeName]) }) @@ -540,7 +563,7 @@ const createEntityManager = (db) => { field: attributeName, })); - if (_.isEmpty(rows)) { + if (isEmpty(rows)) { continue; } @@ -577,10 +600,18 @@ const createEntityManager = (db) => { ...(data.__pivot || {}), })); - if (_.isEmpty(rows)) { + if (isEmpty(rows)) { continue; } + // delete previous relations + await deleteRelatedMorphOneRelationsAfterMorphToManyUpdate(rows, { + uid, + attributeName, + joinTable, + db, + }); + await this.createQueryBuilder(joinTable.name).insert(rows).execute(); continue; @@ -602,7 +633,7 @@ const createEntityManager = (db) => { .update({ [attribute.joinColumn.referencedColumn]: null }) .execute(); - if (!_.isNull(data[attributeName])) { + if (!isNull(data[attributeName])) { await this.createQueryBuilder(target) // NOTE: works if it is an array or a single id .where({ id: data[attributeName] }) @@ -633,7 +664,7 @@ const createEntityManager = (db) => { .execute(); } - if (!_.isNull(data[attributeName])) { + if (!isNull(data[attributeName])) { const insert = toAssocs(data[attributeName]).map((data) => { return { [joinColumn.name]: id, @@ -791,7 +822,7 @@ const createEntityManager = (db) => { async load(uid, entity, fields, params) { const { attributes } = db.metadata.get(uid); - const fieldsArr = _.castArray(fields); + const fieldsArr = castArray(fields); fieldsArr.forEach((field) => { const attribute = attributes[field]; @@ -814,7 +845,7 @@ const createEntityManager = (db) => { } if (Array.isArray(fields)) { - return _.pick(fields, entry); + return pick(fields, entry); } return entry[fields]; diff --git a/packages/core/database/lib/entity-manager/morph-relations.js b/packages/core/database/lib/entity-manager/morph-relations.js new file mode 100644 index 0000000000..eb2d7c4136 --- /dev/null +++ b/packages/core/database/lib/entity-manager/morph-relations.js @@ -0,0 +1,59 @@ +'use strict'; + +const { groupBy, pipe, mapValues, map, isEmpty } = require('lodash/fp'); +const { createQueryBuilder } = require('../query'); + +const getMorphToManyRowsLinkedToMorphOne = (rows, { uid, attributeName, typeColumn, db }) => + rows.filter((row) => { + const relatedType = row[typeColumn.name]; + const field = row.field; + + const targetAttribute = db.metadata.get(relatedType).attributes[field]; + + // ensure targeted field is the right one + check if it is a morphOne + return ( + targetAttribute?.target === uid && + targetAttribute?.morphBy === attributeName && + targetAttribute?.relation === 'morphOne' + ); + }); + +const deleteRelatedMorphOneRelationsAfterMorphToManyUpdate = async ( + rows, + { uid, attributeName, joinTable, db } +) => { + const { morphColumn } = joinTable; + const { idColumn, typeColumn } = morphColumn; + + const morphOneRows = getMorphToManyRowsLinkedToMorphOne(rows, { + uid, + attributeName, + typeColumn, + db, + }); + + const groupByType = groupBy(typeColumn.name); + const groupByField = groupBy('field'); + + const typeAndFieldIdsGrouped = pipe(groupByType, mapValues(groupByField))(morphOneRows); + + const orWhere = []; + + for (const [type, v] of Object.entries(typeAndFieldIdsGrouped)) { + for (const [field, arr] of Object.entries(v)) { + orWhere.push({ + [typeColumn.name]: type, + field, + [idColumn.name]: { $in: map(idColumn.name, arr) }, + }); + } + } + + if (!isEmpty(orWhere)) { + await createQueryBuilder(joinTable.name, db).delete().where({ $or: orWhere }).execute(); + } +}; + +module.exports = { + deleteRelatedMorphOneRelationsAfterMorphToManyUpdate, +}; diff --git a/packages/core/upload/package.json b/packages/core/upload/package.json index f45625f887..a71ac8281a 100644 --- a/packages/core/upload/package.json +++ b/packages/core/upload/package.json @@ -42,7 +42,7 @@ "react-redux": "7.2.8", "react-router": "^5.2.0", "react-router-dom": "5.2.0", - "sharp": "0.30.7" + "sharp": "0.31.0" }, "devDependencies": { "@testing-library/dom": "8.17.1", diff --git a/packages/core/upload/tests/content-api/upload.test.e2e.js b/packages/core/upload/tests/content-api/upload.test.e2e.js index 0a1fedd7bc..5bbf81f5b9 100644 --- a/packages/core/upload/tests/content-api/upload.test.e2e.js +++ b/packages/core/upload/tests/content-api/upload.test.e2e.js @@ -9,6 +9,7 @@ const { createStrapiInstance } = require('../../../../../test/helpers/strapi'); const { createContentAPIRequest } = require('../../../../../test/helpers/request'); const builder = createTestBuilder(); +const data = { dogs: [] }; let strapi; let rq; @@ -24,7 +25,7 @@ const dogModel = { }, }; -describe('Upload plugin end to end tests', () => { +describe('Upload plugin', () => { beforeAll(async () => { await builder.addContentType(dogModel).build(); strapi = await createStrapiInstance(); @@ -155,6 +156,8 @@ describe('Upload plugin end to end tests', () => { id: expect.anything(), }, }); + + data.dogs.push(res.body); }); test('With a pdf', async () => { @@ -183,6 +186,51 @@ describe('Upload plugin end to end tests', () => { id: expect.anything(), }, }); + data.dogs.push(res.body); + }); + }); + + // see https://github.com/strapi/strapi/issues/14125 + describe('File relations are correctly removed', () => { + test('Update an entity with a file correctly removes the relation between the entity and its old file', async () => { + const res = await rq({ + method: 'PUT', + url: `/dogs/${data.dogs[0].data.id}?populate=*`, + formData: { + data: '{}', + 'files.profilePicture': fs.createReadStream(path.join(__dirname, '../utils/strapi.jpg')), + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.body.data.attributes.profilePicture.data.id).not.toBe( + data.dogs[0].data.attributes.profilePicture.data.id + ); + + data.dogs[0] = res.body; + }); + + test('Update a file with an entity correctly removes the relation between the entity and its old file', async () => { + const fileId = data.dogs[1].data.attributes.profilePicture.data.id; + await strapi.entityService.update('plugin::upload.file', fileId, { + data: { + related: [ + { + id: data.dogs[0].data.id, + __type: 'api::dog.dog', + __pivot: { field: 'profilePicture' }, + }, + ], + }, + }); + + const res = await rq({ + method: 'GET', + url: `/dogs/${data.dogs[0].data.id}?populate=*`, + }); + expect(res.body.data.attributes.profilePicture.data.id).toBe(fileId); + + data.dogs[0] = res.body; }); }); });