diff --git a/packages/core/content-manager/server/controllers/relations.js b/packages/core/content-manager/server/controllers/relations.js index c51c407343..eb0ece9ced 100644 --- a/packages/core/content-manager/server/controllers/relations.js +++ b/packages/core/content-manager/server/controllers/relations.js @@ -24,7 +24,7 @@ module.exports = { await validateFindAvailable(ctx.request.query); - const { component, entityId, idsToOmit, _q, ...query } = ctx.request.query; + const { component, entityId, idsToOmit, idsToInclude, _q, ...query } = ctx.request.query; const sourceModelUid = component || model; @@ -103,8 +103,17 @@ module.exports = { const alias = subQuery.getAlias(); + const where = { + id: entityId, + [`${alias}.id`]: { $notNull: true }, + }; + + if (!isEmpty(idsToInclude)) { + where[`${alias}.id`].$notIn = idsToInclude; + } + const knexSubQuery = subQuery - .where({ id: entityId, [`${alias}.id`]: { $notNull: true } }) + .where(where) .join({ alias, targetField }) .select(`${alias}.id`) .getKnexQuery(); diff --git a/packages/core/content-manager/server/tests/index.test.e2e.js b/packages/core/content-manager/server/tests/index.test.e2e.js index e832ff7eb4..c74efc87ea 100644 --- a/packages/core/content-manager/server/tests/index.test.e2e.js +++ b/packages/core/content-manager/server/tests/index.test.e2e.js @@ -91,15 +91,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe('tag1'); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -119,15 +115,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe('tag2'); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -147,15 +139,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe('tag3'); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -180,15 +168,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -216,15 +200,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -251,15 +231,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -282,15 +258,11 @@ describe('Relations', () => { expect(body.title).toBe(data.articles[0].title); expect(body.content).toBe(data.articles[0].content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -312,15 +284,11 @@ describe('Relations', () => { expect(body.title).toBe(data.articles[0].title); expect(body.content).toBe(data.articles[0].content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -346,15 +314,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -458,15 +422,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); @@ -502,15 +462,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe('cat1'); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -533,15 +489,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe('cat2'); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -568,15 +520,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -603,15 +551,11 @@ describe('Relations', () => { expect(body.title).toBe(data.articles[0].title); expect(body.content).toBe(data.articles[0].content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); @@ -640,15 +584,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); @@ -671,15 +611,11 @@ describe('Relations', () => { expect(body.title).toBe(data.articles[1].title); expect(body.content).toBe(data.articles[1].content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); @@ -704,15 +640,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe(data.categories[0].name); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); @@ -737,15 +669,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe(entry.name); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); @@ -845,15 +773,11 @@ describe('Relations', () => { expect(body.id).toBeDefined(); expect(body.name).toBe('ref1'); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); }); @@ -876,15 +800,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.publishedAt).toBeUndefined(); @@ -905,15 +825,11 @@ describe('Relations', () => { expect(body.title).toBe(data.articles[0].title); expect(body.content).toBe(data.articles[0].content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); @@ -940,15 +856,11 @@ describe('Relations', () => { expect(body.title).toBe(entry.title); expect(body.content).toBe(entry.content); expect(body.createdBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); expect(body.updatedBy).toMatchObject({ - firstname: 'admin', id: 1, - lastname: 'admin', username: null, }); const reference = await getRelations('article', 'reference', body.id); diff --git a/packages/core/database/lib/entity-manager/index.js b/packages/core/database/lib/entity-manager/index.js index bd11129b26..9248fb730d 100644 --- a/packages/core/database/lib/entity-manager/index.js +++ b/packages/core/database/lib/entity-manager/index.js @@ -12,20 +12,38 @@ const { isEmpty, isArray, isNull, + uniqWith, + isEqual, + differenceWith, + isNumber, + map, + difference, } = require('lodash/fp'); const types = require('../types'); const { createField } = require('../fields'); const { createQueryBuilder } = require('../query'); const { createRepository } = require('./entity-repository'); -const { isBidirectional, isOneToAny } = require('../metadata/relations'); const { deleteRelatedMorphOneRelationsAfterMorphToManyUpdate } = require('./morph-relations'); +const { + isBidirectional, + isAnyToOne, + isOneToAny, + hasOrderColumn, + hasInverseOrderColumn, +} = require('../metadata/relations'); +const { + deletePreviousOneToAnyRelations, + deletePreviousAnyToOneRelations, + deleteRelations, + cleanOrderColumns, +} = require('./regular-relations'); const toId = (value) => value.id || value; const toIds = (value) => castArray(value || []).map(toId); const isValidId = (value) => isString(value) || isInteger(value); -const toAssocs = (data) => { - return castArray(data) +const toIdArray = (data) => { + const array = castArray(data) .filter((datum) => !isNil(datum)) .map((datum) => { // if it is a string or an integer return an obj with id = to datum @@ -40,6 +58,26 @@ const toAssocs = (data) => { return datum; }); + return uniqWith(isEqual, array); +}; + +const toAssocs = (data) => { + if (isArray(data) || isString(data) || isNumber(data) || isNull(data) || data?.id) { + return { + set: isNull(data) ? data : toIdArray(data), + }; + } + + if (data?.set) { + return { + set: isNull(data.set) ? data.set : toIdArray(data.set), + }; + } + + return { + connect: toIdArray(data?.connect), + disconnect: toIdArray(data?.disconnect), + }; }; const processData = (metadata, data = {}, { withDefaults = false } = {}) => { @@ -355,6 +393,8 @@ const createEntityManager = (db) => { continue; } + const cleanRelationData = toAssocs(data[attributeName]); + if (attribute.relation === 'morphOne' || attribute.relation === 'morphMany') { const { target, morphBy } = attribute; @@ -364,9 +404,11 @@ const createEntityManager = (db) => { // set columns const { idColumn, typeColumn } = targetAttribute.morphColumn; + const relId = toId(cleanRelationData.set[0]); + await this.createQueryBuilder(target) .update({ [idColumn.name]: id, [typeColumn.name]: uid }) - .where({ id: toId(data[attributeName]) }) + .where({ id: relId }) .execute(); } else if (targetAttribute.relation === 'morphToMany') { const { joinTable } = targetAttribute; @@ -374,7 +416,11 @@ const createEntityManager = (db) => { const { idColumn, typeColumn } = morphColumn; - const rows = toAssocs(data[attributeName]).map((data, idx) => { + if (isEmpty(cleanRelationData.set)) { + continue; + } + + const rows = cleanRelationData.set.map((data, idx) => { return { [joinColumn.name]: data.id, [idColumn.name]: id, @@ -386,10 +432,6 @@ const createEntityManager = (db) => { }; }); - if (isEmpty(rows)) { - continue; - } - await this.createQueryBuilder(joinTable.name).insert(rows).execute(); } @@ -403,18 +445,19 @@ const createEntityManager = (db) => { const { idColumn, typeColumn, typeField = '__type' } = morphColumn; - const rows = toAssocs(data[attributeName]).map((data) => ({ + if (isEmpty(cleanRelationData.set)) { + continue; + } + + const rows = cleanRelationData.set.map((data, idx) => ({ [joinColumn.name]: id, [idColumn.name]: data.id, [typeColumn.name]: data[typeField], ...(joinTable.on || {}), ...(data.__pivot || {}), + order: idx + 1, })); - if (isEmpty(rows)) { - continue; - } - // delete previous relations await deleteRelatedMorphOneRelationsAfterMorphToManyUpdate(rows, { uid, @@ -429,13 +472,14 @@ const createEntityManager = (db) => { } if (attribute.joinColumn && attribute.owner) { + const relIdsToAdd = toIds(cleanRelationData.set); if ( attribute.relation === 'oneToOne' && isBidirectional(attribute) && - data[attributeName] + relIdsToAdd.length ) { await this.createQueryBuilder(uid) - .where({ [attribute.joinColumn.name]: data[attributeName], id: { $ne: id } }) + .where({ [attribute.joinColumn.name]: relIdsToAdd, id: { $ne: id } }) .update({ [attribute.joinColumn.name]: null }) .execute(); } @@ -449,6 +493,7 @@ const createEntityManager = (db) => { const { target } = attribute; // TODO: check it is an id & the entity exists (will throw due to FKs otherwise so not a big pbl in SQL) + const relIdsToAdd = toIds(cleanRelationData.set); await this.createQueryBuilder(target) .where({ [attribute.joinColumn.referencedColumn]: id }) @@ -458,7 +503,7 @@ const createEntityManager = (db) => { await this.createQueryBuilder(target) .update({ [attribute.joinColumn.referencedColumn]: id }) // NOTE: works if it is an array or a single id - .where({ id: data[attributeName] }) + .where({ id: relIdsToAdd }) .execute(); } @@ -466,17 +511,18 @@ const createEntityManager = (db) => { // need to set the column on the target const { joinTable } = attribute; - const { joinColumn, inverseJoinColumn } = joinTable; + const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = + joinTable; - if (isOneToAny(attribute) && isBidirectional(attribute)) { - await this.createQueryBuilder(joinTable.name) - .delete() - .where({ [inverseJoinColumn.name]: castArray(data[attributeName]) }) - .where(joinTable.on || {}) - .execute(); + const relsToAdd = cleanRelationData.set || cleanRelationData.connect; + const relIdsToadd = toIds(relsToAdd); + + if (isBidirectional(attribute) && isOneToAny(attribute)) { + await deletePreviousOneToAnyRelations({ id, attribute, relIdsToadd, db }); } - const insert = toAssocs(data[attributeName]).map((data) => { + // prepare new relations to insert + const insert = relsToAdd.map((data) => { return { [joinColumn.name]: id, [inverseJoinColumn.name]: data.id, @@ -485,11 +531,38 @@ const createEntityManager = (db) => { }; }); - // if there is nothing to insert + // add order value + if (hasOrderColumn(attribute)) { + insert.forEach((rel, idx) => { + rel[orderColumnName] = idx + 1; + }); + } + // add inv_order value + if (hasInverseOrderColumn(attribute)) { + const maxResults = await db + .getConnection() + .select(inverseJoinColumn.name) + .max(inverseOrderColumnName, { as: 'max' }) + .whereIn(inverseJoinColumn.name, relIdsToadd) + .where(joinTable.on || {}) + .groupBy(inverseJoinColumn.name) + .from(joinTable.name); + + const maxMap = maxResults.reduce( + (acc, res) => Object.assign(acc, { [res[inverseJoinColumn.name]]: res.max }), + {} + ); + + insert.forEach((rel) => { + rel[inverseOrderColumnName] = (maxMap[rel[inverseJoinColumn.name]] || 0) + 1; + }); + } + if (insert.length === 0) { continue; } + // insert new relations await this.createQueryBuilder(joinTable.name).insert(insert).execute(); } } @@ -514,6 +587,7 @@ const createEntityManager = (db) => { if (attribute.type !== 'relation' || !has(attributeName, data)) { continue; } + const cleanRelationData = toAssocs(data[attributeName]); if (attribute.relation === 'morphOne' || attribute.relation === 'morphMany') { const { target, morphBy } = attribute; @@ -531,10 +605,11 @@ const createEntityManager = (db) => { .where({ [idColumn.name]: id, [typeColumn.name]: uid }) .execute(); - if (!isNull(data[attributeName])) { + if (!isNull(cleanRelationData.set)) { + const relId = toIds(cleanRelationData.set[0]); await this.createQueryBuilder(target) .update({ [idColumn.name]: id, [typeColumn.name]: uid }) - .where({ id: toId(data[attributeName]) }) + .where({ id: relId }) .execute(); } } else if (targetAttribute.relation === 'morphToMany') { @@ -553,7 +628,11 @@ const createEntityManager = (db) => { }) .execute(); - const rows = toAssocs(data[attributeName]).map((data, idx) => ({ + if (isEmpty(cleanRelationData.set)) { + continue; + } + + const rows = cleanRelationData.set.map((data, idx) => ({ [joinColumn.name]: data.id, [idColumn.name]: id, [typeColumn.name]: uid, @@ -563,10 +642,6 @@ const createEntityManager = (db) => { field: attributeName, })); - if (isEmpty(rows)) { - continue; - } - await this.createQueryBuilder(joinTable.name).insert(rows).execute(); } @@ -592,18 +667,19 @@ const createEntityManager = (db) => { }) .execute(); - const rows = toAssocs(data[attributeName]).map((data) => ({ + if (isEmpty(cleanRelationData.set)) { + continue; + } + + const rows = cleanRelationData.set.map((data, idx) => ({ [joinColumn.name]: id, [idColumn.name]: data.id, [typeColumn.name]: data[typeField], ...(joinTable.on || {}), ...(data.__pivot || {}), + order: idx + 1, })); - if (isEmpty(rows)) { - continue; - } - // delete previous relations await deleteRelatedMorphOneRelationsAfterMorphToManyUpdate(rows, { uid, @@ -633,10 +709,10 @@ const createEntityManager = (db) => { .update({ [attribute.joinColumn.referencedColumn]: null }) .execute(); - if (!isNull(data[attributeName])) { + if (!isNull(cleanRelationData.set)) { + const relIdsToAdd = toIds(cleanRelationData.set); await this.createQueryBuilder(target) - // NOTE: works if it is an array or a single id - .where({ id: data[attributeName] }) + .where({ id: relIdsToAdd }) .update({ [attribute.joinColumn.referencedColumn]: id }) .execute(); } @@ -644,42 +720,218 @@ const createEntityManager = (db) => { if (attribute.joinTable) { const { joinTable } = attribute; - const { joinColumn, inverseJoinColumn } = joinTable; - - // clear previous associations in the joinTable - await this.createQueryBuilder(joinTable.name) - .delete() - .where({ [joinColumn.name]: id }) - .where(joinTable.on || {}) - .execute(); - - if ( - isBidirectional(attribute) && - ['oneToOne', 'oneToMany'].includes(attribute.relation) - ) { - await this.createQueryBuilder(joinTable.name) - .delete() - .where({ [inverseJoinColumn.name]: toIds(data[attributeName]) }) - .where(joinTable.on || {}) - .execute(); + const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = + joinTable; + const select = [joinColumn.name, inverseJoinColumn.name]; + if (hasOrderColumn(attribute)) { + select.push(orderColumnName); + } + if (hasInverseOrderColumn(attribute)) { + select.push(inverseOrderColumnName); } - if (!isNull(data[attributeName])) { - const insert = toAssocs(data[attributeName]).map((data) => { - return { - [joinColumn.name]: id, - [inverseJoinColumn.name]: data.id, - ...(joinTable.on || {}), - ...(data.__pivot || {}), - }; - }); + // only delete relations + if (isNull(cleanRelationData.set)) { + await deleteRelations({ id, attribute, db, relIdsToDelete: 'all' }); + } else { + const isPartialUpdate = !has('set', cleanRelationData); + let relIdsToaddOrMove; - // if there is nothing to insert - if (insert.length === 0) { - continue; + if (isPartialUpdate) { + if (isAnyToOne(attribute)) { + cleanRelationData.connect = cleanRelationData.connect.slice(-1); + } + relIdsToaddOrMove = toIds(cleanRelationData.connect); + const relIdsToDelete = toIds( + differenceWith(isEqual, cleanRelationData.disconnect, cleanRelationData.connect) + ); + + if (!isEmpty(relIdsToDelete)) { + await deleteRelations({ id, attribute, db, relIdsToDelete }); + } + + if (isEmpty(cleanRelationData.connect)) { + continue; + } + + // Fetch current relations to handle ordering + let currentMovingRels; + if (hasOrderColumn(attribute) || hasInverseOrderColumn(attribute)) { + currentMovingRels = await this.createQueryBuilder(joinTable.name) + .select(select) + .where({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: { $in: relIdsToaddOrMove }, + }) + .where(joinTable.on || {}) + .execute(); + } + + // prepare relations to insert + const insert = cleanRelationData.connect.map((relToAdd) => ({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: relToAdd.id, + ...(joinTable.on || {}), + ...(relToAdd.__pivot || {}), + })); + + // add order value + if (hasOrderColumn(attribute)) { + const orderMax = ( + await this.createQueryBuilder(joinTable.name) + .max(orderColumnName) + .where({ [joinColumn.name]: id }) + .where(joinTable.on || {}) + .first() + .execute() + ).max; + + insert.forEach((row, idx) => { + row[orderColumnName] = orderMax + idx + 1; + }); + } + + // add inv order value + if (hasInverseOrderColumn(attribute)) { + const nonExistingRelsIds = difference( + relIdsToaddOrMove, + map(inverseJoinColumn.name, currentMovingRels) + ); + + const maxResults = await db + .getConnection() + .select(inverseJoinColumn.name) + .max(inverseOrderColumnName, { as: 'max' }) + .whereIn(inverseJoinColumn.name, nonExistingRelsIds) + .where(joinTable.on || {}) + .groupBy(inverseJoinColumn.name) + .from(joinTable.name); + + const maxMap = maxResults.reduce( + (acc, res) => Object.assign(acc, { [res[inverseJoinColumn.name]]: res.max }), + {} + ); + + insert.forEach((row) => { + row[inverseOrderColumnName] = (maxMap[row[inverseJoinColumn.name]] || 0) + 1; + }); + } + + // insert rows + const query = this.createQueryBuilder(joinTable.name) + .insert(insert) + .onConflict(joinTable.pivotColumns); + + if (hasOrderColumn(attribute)) { + query.merge([orderColumnName]); + } else { + query.ignore(); + } + + await query.execute(); + + // remove gap between orders + await cleanOrderColumns({ attribute, db, id }); + } else { + if (isAnyToOne(attribute)) { + cleanRelationData.set = cleanRelationData.set.slice(-1); + } + // overwrite all relations + relIdsToaddOrMove = toIds(cleanRelationData.set); + await deleteRelations({ + id, + attribute, + db, + relIdsToDelete: 'all', + relIdsToNotDelete: relIdsToaddOrMove, + }); + + if (isEmpty(cleanRelationData.set)) { + continue; + } + + const insert = cleanRelationData.set.map((relToAdd) => ({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: relToAdd.id, + ...(joinTable.on || {}), + ...(relToAdd.__pivot || {}), + })); + + // add order value + if (hasOrderColumn(attribute)) { + insert.forEach((row, idx) => { + row[orderColumnName] = idx + 1; + }); + } + + // add inv order value + if (hasInverseOrderColumn(attribute)) { + const existingRels = await this.createQueryBuilder(joinTable.name) + .select(inverseJoinColumn.name) + .where({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: { $in: relIdsToaddOrMove }, + }) + .where(joinTable.on || {}) + .execute(); + + const nonExistingRelsIds = difference( + relIdsToaddOrMove, + map(inverseJoinColumn.name, existingRels) + ); + + const maxResults = await db + .getConnection() + .select(inverseJoinColumn.name) + .max(inverseOrderColumnName, { as: 'max' }) + .whereIn(inverseJoinColumn.name, nonExistingRelsIds) + .where(joinTable.on || {}) + .groupBy(inverseJoinColumn.name) + .from(joinTable.name); + + const maxMap = maxResults.reduce( + (acc, res) => Object.assign(acc, { [res[inverseJoinColumn.name]]: res.max }), + {} + ); + + insert.forEach((row) => { + row[inverseOrderColumnName] = (maxMap[row[inverseJoinColumn.name]] || 0) + 1; + }); + } + + // insert rows + const query = this.createQueryBuilder(joinTable.name) + .insert(insert) + .onConflict(joinTable.pivotColumns); + + if (hasOrderColumn(attribute)) { + query.merge([orderColumnName]); + } else { + query.ignore(); + } + + await query.execute(); } - await this.createQueryBuilder(joinTable.name).insert(insert).execute(); + // Delete the previous relations for oneToAny relations + if (isBidirectional(attribute) && isOneToAny(attribute)) { + await deletePreviousOneToAnyRelations({ + id, + attribute, + relIdsToadd: relIdsToaddOrMove, + db, + }); + } + + // Delete the previous relations for anyToOne relations + if (isBidirectional(attribute) && isAnyToOne(attribute)) { + await deletePreviousAnyToOneRelations({ + id, + attribute, + relIdToadd: relIdsToaddOrMove[0], + db, + }); + } } } } @@ -795,14 +1047,7 @@ const createEntityManager = (db) => { } if (attribute.joinTable) { - const { joinTable } = attribute; - const { joinColumn } = joinTable; - - await this.createQueryBuilder(joinTable.name) - .delete() - .where({ [joinColumn.name]: id }) - .where(joinTable.on || {}) - .execute(); + await deleteRelations({ id, attribute, db, relIdsToDelete: 'all' }); } } }, diff --git a/packages/core/database/lib/entity-manager/regular-relations.js b/packages/core/database/lib/entity-manager/regular-relations.js new file mode 100644 index 0000000000..45ebb85b0f --- /dev/null +++ b/packages/core/database/lib/entity-manager/regular-relations.js @@ -0,0 +1,257 @@ +'use strict'; + +const { map, isEmpty } = require('lodash/fp'); +const { + isBidirectional, + isOneToAny, + isManyToAny, + isAnyToOne, + hasOrderColumn, + hasInverseOrderColumn, +} = require('../metadata/relations'); +const { createQueryBuilder } = require('../query'); + +/** + * If some relations currently exist for this oneToX relation, on the one side, this function removes them and update the inverse order if needed. + * @param {Object} params + * @param {string} params.id - entity id on which the relations for entities relIdsToadd are created + * @param {string} params.attribute - attribute of the relation + * @param {string} params.inverseRelIds - entity ids of the inverse side for which the current relations will be deleted + * @param {string} params.db - database instance + */ +const deletePreviousOneToAnyRelations = async ({ id, attribute, relIdsToadd, db }) => { + if (!(isBidirectional(attribute) && isOneToAny(attribute))) { + throw new Error( + 'deletePreviousOneToAnyRelations can only be called for bidirectional oneToAny relations' + ); + } + const { joinTable } = attribute; + const { joinColumn, inverseJoinColumn } = joinTable; + + await createQueryBuilder(joinTable.name, db) + .delete() + .where({ + [inverseJoinColumn.name]: relIdsToadd, + [joinColumn.name]: { $ne: id }, + }) + .where(joinTable.on || {}) + .execute(); + + await cleanOrderColumns({ attribute, db, inverseRelIds: relIdsToadd }); +}; + +/** + * If a relation currently exists for this xToOne relations, this function removes it and update the inverse order if needed. + * @param {Object} params + * @param {string} params.id - entity id on which the relation for entity relIdToadd is created + * @param {string} params.attribute - attribute of the relation + * @param {string} params.relIdToadd - entity id of the new relation + * @param {string} params.db - database instance + */ +const deletePreviousAnyToOneRelations = async ({ id, attribute, relIdToadd, db }) => { + const { joinTable } = attribute; + const { joinColumn, inverseJoinColumn } = joinTable; + + if (!(isBidirectional(attribute) && isAnyToOne(attribute))) { + throw new Error( + 'deletePreviousAnyToOneRelations can only be called for bidirectional anyToOne relations' + ); + } + // handling manyToOne + if (isManyToAny(attribute)) { + // if the database integrity was not broken relsToDelete is supposed to be of length 1 + const relsToDelete = await createQueryBuilder(joinTable.name, db) + .select(inverseJoinColumn.name) + .where({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: { $ne: relIdToadd }, + }) + .where(joinTable.on || {}) + .execute(); + + const relIdsToDelete = map(inverseJoinColumn.name, relsToDelete); + + await createQueryBuilder(joinTable.name, db) + .delete() + .where({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: { $in: relIdsToDelete }, + }) + .where(joinTable.on || {}) + .execute(); + + await cleanOrderColumns({ attribute, db, inverseRelIds: relIdsToDelete }); + + // handling oneToOne + } else { + await createQueryBuilder(joinTable.name, db) + .delete() + .where({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: { $ne: relIdToadd }, + }) + .where(joinTable.on || {}) + .execute(); + } +}; + +/** + * Delete all or some relations of entity field + * @param {Object} params + * @param {string} params.id - entity id for which the relations will be deleted + * @param {string} params.attribute - attribute of the relation + * @param {string} params.db - database instance + * @param {string} params.relIdsToDelete - ids of entities to remove from the relations. Also accepts 'all' + * @param {string} params.relIdsToNotDelete - ids of entities to not remove from the relation when relIdsToDelete equals 'all' + */ +const deleteRelations = async ({ + id, + attribute, + db, + relIdsToNotDelete = [], + relIdsToDelete = [], +}) => { + const { joinTable } = attribute; + const { joinColumn, inverseJoinColumn } = joinTable; + const all = relIdsToDelete === 'all'; + + if (hasOrderColumn(attribute) || hasInverseOrderColumn(attribute)) { + let lastId = 0; + let done = false; + const batchSize = 100; + while (!done) { + const batchToDelete = await createQueryBuilder(joinTable.name, db) + .select(inverseJoinColumn.name) + .where({ + [joinColumn.name]: id, + id: { $gt: lastId }, + [inverseJoinColumn.name]: { $notIn: relIdsToNotDelete }, + ...(all ? {} : { [inverseJoinColumn.name]: { $in: relIdsToDelete } }), + }) + .where(joinTable.on || {}) + .orderBy('id') + .limit(batchSize) + .execute(); + done = batchToDelete.length < batchSize; + lastId = batchToDelete[batchToDelete.length - 1]?.id; + + const batchIds = map(inverseJoinColumn.name, batchToDelete); + + await createQueryBuilder(joinTable.name, db) + .delete() + .where({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: { $in: batchIds }, + }) + .where(joinTable.on || {}) + .execute(); + + await cleanOrderColumns({ attribute, db, id, inverseRelIds: batchIds }); + } + } else { + await createQueryBuilder(joinTable.name, db) + .delete() + .where({ + [joinColumn.name]: id, + [inverseJoinColumn.name]: { $notIn: relIdsToNotDelete }, + ...(all ? {} : { [inverseJoinColumn.name]: { $in: relIdsToDelete } }), + }) + .where(joinTable.on || {}) + .execute(); + } +}; + +/** + * Clean the order columns by ensuring the order value are continuous (ex: 1, 2, 3 and not 1, 5, 10) + * @param {Object} params + * @param {string} params.id - entity id for which the clean will be done + * @param {string} params.attribute - attribute of the relation + * @param {string} params.db - database instance + * @param {string} params.inverseRelIds - entity ids of the inverse side for which the clean will be done + */ +const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds }) => { + if ( + !(hasOrderColumn(attribute) && id) && + !(hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) + ) { + return; + } + + const { joinTable } = attribute; + const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable; + const update = []; + const updateBinding = []; + const select = ['??']; + const selectBinding = ['id']; + const where = []; + const whereBinding = []; + + if (hasOrderColumn(attribute) && id) { + update.push('?? = b.src_order'); + updateBinding.push(orderColumnName); + select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS src_order'); + selectBinding.push(joinColumn.name, orderColumnName); + where.push('?? = ?'); + whereBinding.push(joinColumn.name, id); + } + + if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) { + update.push('?? = b.inv_order'); + updateBinding.push(inverseOrderColumnName); + select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS inv_order'); + selectBinding.push(inverseJoinColumn.name, inverseOrderColumnName); + where.push(`?? IN (${inverseRelIds.map(() => '?').join(', ')})`); + whereBinding.push(inverseJoinColumn.name, ...inverseRelIds); + } + + // raw query as knex doesn't allow updating from a subquery + // https://github.com/knex/knex/issues/2504 + switch (strapi.db.dialect.client) { + case 'mysql': + await db.getConnection().raw( + `UPDATE + ?? as a, + ( + SELECT ${select.join(', ')} + FROM ?? + WHERE ${where.join(' OR ')} + ) AS b + SET ${update.join(', ')} + WHERE b.id = a.id`, + [joinTable.name, ...selectBinding, joinTable.name, ...whereBinding, ...updateBinding] + ); + break; + default: + await db.getConnection().raw( + `UPDATE ?? as a + SET ${update.join(', ')} + FROM ( + SELECT ${select.join(', ')} + FROM ?? + WHERE ${where.join(' OR ')} + ) AS b + WHERE b.id = a.id`, + [joinTable.name, ...updateBinding, ...selectBinding, joinTable.name, ...whereBinding] + ); + /* + `UPDATE :joinTable: as a + SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order + FROM ( + SELECT + id, + ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order, + ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order + FROM :joinTable: + WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds) + ) AS b + WHERE b.id = a.id`, + */ + } +}; + +module.exports = { + deletePreviousOneToAnyRelations, + deletePreviousAnyToOneRelations, + deleteRelations, + cleanOrderColumns, +}; diff --git a/packages/core/database/lib/metadata/index.js b/packages/core/database/lib/metadata/index.js index c2309fa073..f68f1b1bd6 100644 --- a/packages/core/database/lib/metadata/index.js +++ b/packages/core/database/lib/metadata/index.js @@ -123,7 +123,7 @@ const createCompoLinkModelMeta = (baseModelMeta) => { type: 'integer', column: { unsigned: true, - defaultTo: 0, + defaultTo: null, }, }, }, @@ -142,6 +142,11 @@ const createCompoLinkModelMeta = (baseModelMeta) => { name: `${baseModelMeta.tableName}_entity_fk`, columns: ['entity_id'], }, + { + name: `${baseModelMeta.tableName}_unique`, + columns: ['entity_id', 'component_id', 'field', 'component_type'], + type: 'unique', + }, ], foreignKeys: [ { @@ -183,6 +188,7 @@ const createDynamicZone = (attributeName, attribute, meta) => { orderBy: { order: 'asc', }, + pivotColumns: ['entity_id', 'component_id', 'field', 'component_type'], }, }); }; @@ -205,9 +211,11 @@ const createComponent = (attributeName, attribute, meta) => { on: { field: attributeName, }, + orderColumnName: 'order', orderBy: { order: 'asc', }, + pivotColumns: ['entity_id', 'component_id', 'field', 'component_type'], }, }); }; diff --git a/packages/core/database/lib/metadata/relations.js b/packages/core/database/lib/metadata/relations.js index bdef527461..f07955b70f 100644 --- a/packages/core/database/lib/metadata/relations.js +++ b/packages/core/database/lib/metadata/relations.js @@ -10,6 +10,9 @@ const hasInversedBy = _.has('inversedBy'); const hasMappedBy = _.has('mappedBy'); const isOneToAny = (attribute) => ['oneToOne', 'oneToMany'].includes(attribute.relation); +const isManyToAny = (attribute) => ['manyToMany', 'manyToOne'].includes(attribute.relation); +const isAnyToOne = (attribute) => ['oneToOne', 'manyToOne'].includes(attribute.relation); +const isAnyToMany = (attribute) => ['oneToMany', 'manyToMany'].includes(attribute.relation); const isBidirectional = (attribute) => hasInversedBy(attribute) || hasMappedBy(attribute); const isOwner = (attribute) => !isBidirectional(attribute) || hasInversedBy(attribute); const shouldUseJoinTable = (attribute) => attribute.useJoinTable !== false; @@ -269,6 +272,7 @@ const createMorphToMany = (attributeName, attribute, meta, metadata) => { orderBy: { order: 'asc', }, + pivotColumns: [joinColumnName, typeColumnName, idColumnName], }; attribute.joinTable = joinTable; @@ -398,12 +402,20 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => { const joinColumnName = _.snakeCase(`${meta.singularName}_id`); let inverseJoinColumnName = _.snakeCase(`${targetMeta.singularName}_id`); - // if relation is slef referencing + // if relation is self referencing if (joinColumnName === inverseJoinColumnName) { inverseJoinColumnName = `inv_${inverseJoinColumnName}`; } - metadata.add({ + const orderColumnName = _.snakeCase(`${targetMeta.singularName}_order`); + let inverseOrderColumnName = _.snakeCase(`${meta.singularName}_order`); + + // if relation is self referencing + if (attribute.relation === 'manyToMany' && joinColumnName === inverseJoinColumnName) { + inverseOrderColumnName = `inv_${inverseOrderColumnName}`; + } + + const metadataSchema = { uid: joinTableName, tableName: joinTableName, attributes: { @@ -433,6 +445,11 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => { name: `${joinTableName}_inv_fk`, columns: [inverseJoinColumnName], }, + { + name: `${joinTableName}_unique`, + columns: [joinColumnName, inverseJoinColumnName], + type: 'unique', + }, ], foreignKeys: [ { @@ -450,7 +467,7 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => { onDelete: 'CASCADE', }, ], - }); + }; const joinTable = { name: joinTableName, @@ -462,8 +479,46 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => { name: inverseJoinColumnName, referencedColumn: 'id', }, + pivotColumns: [joinColumnName, inverseJoinColumnName], }; + // order + if (isAnyToMany(attribute)) { + metadataSchema.attributes[orderColumnName] = { + type: 'integer', + column: { + unsigned: true, + defaultTo: null, + }, + }; + metadataSchema.indexes.push({ + name: `${joinTableName}_order_fk`, + columns: [orderColumnName], + }); + joinTable.orderColumnName = orderColumnName; + joinTable.orderBy = { [orderColumnName]: 'asc' }; + } + + // inv order + if (isBidirectional(attribute) && isManyToAny(attribute)) { + metadataSchema.attributes[inverseOrderColumnName] = { + type: 'integer', + column: { + unsigned: true, + defaultTo: null, + }, + }; + + metadataSchema.indexes.push({ + name: `${joinTableName}_order_inv_fk`, + columns: [inverseOrderColumnName], + }); + + joinTable.inverseOrderColumnName = inverseOrderColumnName; + } + + metadata.add(metadataSchema); + attribute.joinTable = joinTable; if (isBidirectional(attribute)) { @@ -479,13 +534,30 @@ const createJoinTable = (metadata, { attributeName, attribute, meta }) => { name: joinTableName, joinColumn: joinTable.inverseJoinColumn, inverseJoinColumn: joinTable.joinColumn, + pivotColumns: joinTable.pivotColumns, }; + + if (isManyToAny(attribute)) { + inverseAttribute.joinTable.orderColumnName = inverseOrderColumnName; + inverseAttribute.joinTable.orderBy = { [inverseOrderColumnName]: 'asc' }; + } + if (isAnyToMany(attribute)) { + inverseAttribute.joinTable.inverseOrderColumnName = orderColumnName; + } } }; +const hasOrderColumn = (attribute) => isAnyToMany(attribute); +const hasInverseOrderColumn = (attribute) => isBidirectional(attribute) && isManyToAny(attribute); + module.exports = { createRelation, isBidirectional, isOneToAny, + isManyToAny, + isAnyToOne, + isAnyToMany, + hasOrderColumn, + hasInverseOrderColumn, }; diff --git a/packages/core/database/lib/query/query-builder.js b/packages/core/database/lib/query/query-builder.js index ff2e1399dd..3411b2ba3d 100644 --- a/packages/core/database/lib/query/query-builder.js +++ b/packages/core/database/lib/query/query-builder.js @@ -23,8 +23,13 @@ const createQueryBuilder = (uid, db, initialState = {}) => { offset: null, transaction: null, forUpdate: false, + onConflict: null, + merge: null, + ignore: false, orderBy: [], groupBy: [], + increments: [], + decrements: [], aliasCounter: 0, }, initialState @@ -67,6 +72,24 @@ const createQueryBuilder = (uid, db, initialState = {}) => { return this; }, + onConflict(args) { + state.onConflict = args; + + return this; + }, + + merge(args) { + state.merge = args; + + return this; + }, + + ignore() { + state.ignore = true; + + return this; + }, + delete() { state.type = 'delete'; @@ -84,6 +107,20 @@ const createQueryBuilder = (uid, db, initialState = {}) => { return this; }, + increment(column, amount = 1) { + state.type = 'update'; + state.increments.push({ column, amount }); + + return this; + }, + + decrement(column, amount = 1) { + state.type = 'update'; + state.decrements.push({ column, amount }); + + return this; + }, + count(count = 'id') { state.type = 'count'; state.count = count; @@ -349,7 +386,9 @@ const createQueryBuilder = (uid, db, initialState = {}) => { break; } case 'update': { - qb.update(state.data); + if (state.data) { + qb.update(state.data); + } break; } case 'delete': { @@ -374,6 +413,22 @@ const createQueryBuilder = (uid, db, initialState = {}) => { qb.forUpdate(); } + if (!_.isEmpty(state.increments)) { + state.increments.forEach((incr) => qb.increment(incr.column, incr.amount)); + } + + if (!_.isEmpty(state.decrements)) { + state.decrements.forEach((decr) => qb.decrement(decr.column, decr.amount)); + } + + if (state.onConflict) { + if (state.merge) { + qb.onConflict(state.onConflict).merge(state.merge); + } else if (state.ignore) { + qb.onConflict(state.onConflict).ignore(); + } + } + if (state.limit) { qb.limit(state.limit); } diff --git a/packages/core/strapi/lib/services/entity-service/components.js b/packages/core/strapi/lib/services/entity-service/components.js index 4244feaa95..c64c6dd31d 100644 --- a/packages/core/strapi/lib/services/entity-service/components.js +++ b/packages/core/strapi/lib/services/entity-service/components.js @@ -48,11 +48,10 @@ const createComponents = async (uid, data) => { ); // TODO: add order - componentBody[attributeName] = components.map(({ id }, idx) => { + componentBody[attributeName] = components.map(({ id }) => { return { id, __pivot: { - order: idx + 1, field: attributeName, component_type: componentUID, }, @@ -63,7 +62,6 @@ const createComponents = async (uid, data) => { componentBody[attributeName] = { id: component.id, __pivot: { - order: 1, field: attributeName, component_type: componentUID, }, @@ -81,13 +79,12 @@ const createComponents = async (uid, data) => { } componentBody[attributeName] = await Promise.all( - dynamiczoneValues.map(async (value, idx) => { + dynamiczoneValues.map(async (value) => { const { id } = await createComponent(value.__component, value); return { id, __component: value.__component, __pivot: { - order: idx + 1, field: attributeName, }, }; @@ -145,11 +142,10 @@ const updateComponents = async (uid, entityToUpdate, data) => { componentValue.map((value) => updateOrCreateComponent(componentUID, value)) ); - componentBody[attributeName] = components.filter(_.negate(_.isNil)).map(({ id }, idx) => { + componentBody[attributeName] = components.filter(_.negate(_.isNil)).map(({ id }) => { return { id, __pivot: { - order: idx + 1, field: attributeName, component_type: componentUID, }, @@ -160,7 +156,6 @@ const updateComponents = async (uid, entityToUpdate, data) => { componentBody[attributeName] = component && { id: component.id, __pivot: { - order: 1, field: attributeName, component_type: componentUID, }, @@ -180,14 +175,13 @@ const updateComponents = async (uid, entityToUpdate, data) => { } componentBody[attributeName] = await Promise.all( - dynamiczoneValues.map(async (value, idx) => { + dynamiczoneValues.map(async (value) => { const { id } = await updateOrCreateComponent(value.__component, value); return { id, __component: value.__component, __pivot: { - order: idx + 1, field: attributeName, }, }; diff --git a/packages/plugins/i18n/tests/content-manager/find-existing-relations.test.e2e.js b/packages/plugins/i18n/tests/content-manager/find-existing-relations.test.e2e.js index 77164f17f7..eee6e53daf 100644 --- a/packages/plugins/i18n/tests/content-manager/find-existing-relations.test.e2e.js +++ b/packages/plugins/i18n/tests/content-manager/find-existing-relations.test.e2e.js @@ -99,7 +99,6 @@ describe('i18n - Find existing relations', () => { rq = await createAuthRequest({ strapi }); data.shops = await builder.sanitizedFixturesFor(shopModel.singularName, strapi); - console.log('data.shops', data.shops); data.products = await builder.sanitizedFixturesFor(productModel.singularName, strapi); });