mirror of
https://github.com/strapi/strapi.git
synced 2025-07-19 15:06:11 +00:00
482 lines
16 KiB
JavaScript
482 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const { map, isEmpty } = require('lodash/fp');
|
|
const { randomBytes } = require('crypto');
|
|
|
|
const {
|
|
isBidirectional,
|
|
isOneToAny,
|
|
isManyToAny,
|
|
isAnyToOne,
|
|
hasOrderColumn,
|
|
hasInverseOrderColumn,
|
|
} = require('../metadata/relations');
|
|
const { createQueryBuilder } = require('../query');
|
|
const { addSchema } = require('../utils/knex');
|
|
|
|
/**
|
|
* 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,
|
|
transaction: trx,
|
|
}) => {
|
|
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 || {})
|
|
.transacting(trx)
|
|
.execute();
|
|
|
|
await cleanOrderColumns({ attribute, db, inverseRelIds: relIdsToadd, transaction: trx });
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
transaction: trx,
|
|
}) => {
|
|
const { joinTable } = attribute;
|
|
const { joinColumn, inverseJoinColumn } = joinTable;
|
|
|
|
if (!isAnyToOne(attribute)) {
|
|
throw new Error('deletePreviousAnyToOneRelations can only be called for 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 || {})
|
|
.transacting(trx)
|
|
.execute();
|
|
|
|
const relIdsToDelete = map(inverseJoinColumn.name, relsToDelete);
|
|
|
|
await createQueryBuilder(joinTable.name, db)
|
|
.delete()
|
|
.where({
|
|
[joinColumn.name]: id,
|
|
[inverseJoinColumn.name]: { $in: relIdsToDelete },
|
|
})
|
|
.where(joinTable.on || {})
|
|
.transacting(trx)
|
|
.execute();
|
|
|
|
await cleanOrderColumns({ attribute, db, inverseRelIds: relIdsToDelete, transaction: trx });
|
|
|
|
// handling oneToOne
|
|
} else {
|
|
await createQueryBuilder(joinTable.name, db)
|
|
.delete()
|
|
.where({
|
|
[joinColumn.name]: id,
|
|
[inverseJoinColumn.name]: { $ne: relIdToadd },
|
|
})
|
|
.where(joinTable.on || {})
|
|
.transacting(trx)
|
|
.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 = [],
|
|
transaction: trx,
|
|
}) => {
|
|
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)
|
|
.transacting(trx)
|
|
.execute();
|
|
done = batchToDelete.length < batchSize;
|
|
lastId = batchToDelete[batchToDelete.length - 1]?.id || 0;
|
|
|
|
const batchIds = map(inverseJoinColumn.name, batchToDelete);
|
|
|
|
await createQueryBuilder(joinTable.name, db)
|
|
.delete()
|
|
.where({
|
|
[joinColumn.name]: id,
|
|
[inverseJoinColumn.name]: { $in: batchIds },
|
|
})
|
|
.where(joinTable.on || {})
|
|
.transacting(trx)
|
|
.execute();
|
|
|
|
await cleanOrderColumns({ attribute, db, id, inverseRelIds: batchIds, transaction: trx });
|
|
}
|
|
} else {
|
|
await createQueryBuilder(joinTable.name, db)
|
|
.delete()
|
|
.where({
|
|
[joinColumn.name]: id,
|
|
[inverseJoinColumn.name]: { $notIn: relIdsToNotDelete },
|
|
...(all ? {} : { [inverseJoinColumn.name]: { $in: relIdsToDelete } }),
|
|
})
|
|
.where(joinTable.on || {})
|
|
.transacting(trx)
|
|
.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, transaction: trx }) => {
|
|
if (
|
|
!(hasOrderColumn(attribute) && id) &&
|
|
!(hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Handle databases that don't support window function ROW_NUMBER (here it's MySQL 5)
|
|
if (!strapi.db.dialect.supportsWindowFunctions()) {
|
|
await cleanOrderColumnsForOldDatabases({ id, attribute, db, inverseRelIds, transaction: trx });
|
|
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);
|
|
}
|
|
|
|
switch (strapi.db.dialect.client) {
|
|
case 'mysql':
|
|
// Here it's MariaDB and MySQL 8
|
|
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]
|
|
)
|
|
.transacting(trx);
|
|
break;
|
|
/*
|
|
UPDATE
|
|
:joinTable: as a,
|
|
(
|
|
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
|
|
SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order
|
|
WHERE b.id = a.id;
|
|
*/
|
|
default: {
|
|
const joinTableName = addSchema(joinTable.name);
|
|
|
|
// raw query as knex doesn't allow updating from a subquery
|
|
// https://github.com/knex/knex/issues/2504
|
|
await db.connection
|
|
.raw(
|
|
`UPDATE ?? as a
|
|
SET ${update.join(', ')}
|
|
FROM (
|
|
SELECT ${select.join(', ')}
|
|
FROM ??
|
|
WHERE ${where.join(' OR ')}
|
|
) AS b
|
|
WHERE b.id = a.id`,
|
|
[joinTableName, ...updateBinding, ...selectBinding, joinTableName, ...whereBinding]
|
|
)
|
|
.transacting(trx);
|
|
|
|
/*
|
|
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;
|
|
*/
|
|
}
|
|
}
|
|
};
|
|
|
|
/*
|
|
* Ensure that orders are following a 1, 2, 3 sequence, without gap.
|
|
* The use of a session variable instead of a window function makes the query compatible with MySQL 5
|
|
*/
|
|
const cleanOrderColumnsForOldDatabases = async ({
|
|
id,
|
|
attribute,
|
|
db,
|
|
inverseRelIds,
|
|
transaction: trx,
|
|
}) => {
|
|
const { joinTable } = attribute;
|
|
const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
|
|
|
|
const randomSuffix = `${new Date().valueOf()}_${randomBytes(16).toString('hex')}`;
|
|
|
|
if (hasOrderColumn(attribute) && id) {
|
|
// raw query as knex doesn't allow updating from a subquery
|
|
// https://github.com/knex/knex/issues/2504
|
|
const orderVar = `order_${randomSuffix}`;
|
|
await db.connection.raw(`SET @${orderVar} = 0;`).transacting(trx);
|
|
await db.connection
|
|
.raw(
|
|
`UPDATE :joinTableName: as a, (
|
|
SELECT id, (@${orderVar}:=@${orderVar} + 1) AS src_order
|
|
FROM :joinTableName:
|
|
WHERE :joinColumnName: = :id
|
|
ORDER BY :orderColumnName:
|
|
) AS b
|
|
SET :orderColumnName: = b.src_order
|
|
WHERE a.id = b.id
|
|
AND a.:joinColumnName: = :id`,
|
|
{
|
|
joinTableName: joinTable.name,
|
|
orderColumnName,
|
|
joinColumnName: joinColumn.name,
|
|
id,
|
|
}
|
|
)
|
|
.transacting(trx);
|
|
}
|
|
|
|
if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) {
|
|
const orderVar = `order_${randomSuffix}`;
|
|
const columnVar = `col_${randomSuffix}`;
|
|
await db.connection.raw(`SET @${orderVar} = 0;`).transacting(trx);
|
|
await db.connection
|
|
.raw(
|
|
`UPDATE ?? as a, (
|
|
SELECT
|
|
id,
|
|
@${orderVar}:=CASE WHEN @${columnVar} = ?? THEN @${orderVar} + 1 ELSE 1 END AS inv_order,
|
|
@${columnVar}:=?? ??
|
|
FROM ?? a
|
|
WHERE ?? IN(${inverseRelIds.map(() => '?').join(', ')})
|
|
ORDER BY ??, ??
|
|
) AS b
|
|
SET ?? = b.inv_order
|
|
WHERE a.id = b.id
|
|
AND a.?? IN(${inverseRelIds.map(() => '?').join(', ')})`,
|
|
[
|
|
joinTable.name,
|
|
inverseJoinColumn.name,
|
|
inverseJoinColumn.name,
|
|
inverseJoinColumn.name,
|
|
joinTable.name,
|
|
inverseJoinColumn.name,
|
|
...inverseRelIds,
|
|
inverseJoinColumn.name,
|
|
joinColumn.name,
|
|
inverseOrderColumnName,
|
|
inverseJoinColumn.name,
|
|
...inverseRelIds,
|
|
]
|
|
)
|
|
.transacting(trx);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Use this when a relation is added or removed and its inverse order column
|
|
* needs to be re-calculated
|
|
*
|
|
* Example: In this following table
|
|
*
|
|
* | joinColumn | inverseJoinColumn | order | inverseOrder |
|
|
* | --------------- | -------- | ----------- | ------------------ |
|
|
* | 1 | 1 | 1 | 1 |
|
|
* | 2 | 1 | 3 | 2 |
|
|
* | 2 | 2 | 3 | 1 |
|
|
*
|
|
* You add a new relation { joinColumn: 1, inverseJoinColumn: 2 }
|
|
*
|
|
* | joinColumn | inverseJoinColumn | order | inverseOrder |
|
|
* | --------------- | -------- | ----------- | ------------------ |
|
|
* | 1 | 1 | 1 | 1 |
|
|
* | 1 | 2 | 2 | 1 | <- inverseOrder should be 2
|
|
* | 2 | 1 | 3 | 2 |
|
|
* | 2 | 2 | 3 | 1 |
|
|
*
|
|
* This function would make such update, so all inverse order columns related
|
|
* to the given id (1 in this example) are following a 1, 2, 3 sequence, without gap.
|
|
*
|
|
* @param {Object} params
|
|
* @param {string} params.id - entity id to find which inverse order column to clean
|
|
* @param {Object} params.attribute - attribute of the relation
|
|
* @param {Object} params.trx - knex transaction
|
|
*
|
|
*/
|
|
|
|
const cleanInverseOrderColumn = async ({ id, attribute, trx }) => {
|
|
const con = strapi.db.connection;
|
|
const { joinTable } = attribute;
|
|
const { joinColumn, inverseJoinColumn, inverseOrderColumnName } = joinTable;
|
|
|
|
switch (strapi.db.dialect.client) {
|
|
/*
|
|
UPDATE `:joinTableName` AS `t1`
|
|
JOIN (
|
|
SELECT
|
|
`inverseJoinColumn`,
|
|
MAX(`:inverseOrderColumnName`) AS `max_inv_order`
|
|
FROM `:joinTableName`
|
|
GROUP BY `:inverseJoinColumn`
|
|
) AS `t2`
|
|
ON `t1`.`:inverseJoinColumn` = `t2`.`:inverseJoinColumn`
|
|
SET `t1`.`:inverseOrderColumnNAme` = `t2`.`max_inv_order` + 1
|
|
WHERE `t1`.`:joinColumnName` = :id;
|
|
*/
|
|
case 'mysql': {
|
|
// Group by the inverse join column and get the max value of the inverse order column
|
|
const subQuery = con(joinTable.name)
|
|
.select(inverseJoinColumn.name)
|
|
.max(inverseOrderColumnName, { as: 'max_inv_order' })
|
|
.groupBy(inverseJoinColumn.name)
|
|
.as('t2');
|
|
|
|
// Update ids with the new inverse order
|
|
await con(`${joinTable.name} as t1`)
|
|
.join(subQuery, `t1.${inverseJoinColumn.name}`, '=', `t2.${inverseJoinColumn.name}`)
|
|
.where(joinColumn.name, id)
|
|
.update({
|
|
[inverseOrderColumnName]: con.raw('t2.max_inv_order + 1'),
|
|
})
|
|
.transacting(trx);
|
|
break;
|
|
}
|
|
default: {
|
|
/*
|
|
UPDATE `:joinTableName` as `t1`
|
|
SET `:inverseOrderColumnName` = (
|
|
SELECT max(`:inverseOrderColumnName`) + 1
|
|
FROM `:joinTableName` as `t2`
|
|
WHERE t2.:inverseJoinColumn = t1.:inverseJoinColumn
|
|
)
|
|
WHERE `t1`.`:joinColumnName` = :id
|
|
*/
|
|
// New inverse order will be the max value + 1
|
|
const selectMaxInverseOrder = con.raw(`max(${inverseOrderColumnName}) + 1`);
|
|
|
|
const subQuery = con(`${joinTable.name} as t2`)
|
|
.select(selectMaxInverseOrder)
|
|
.whereRaw(`t2.${inverseJoinColumn.name} = t1.${inverseJoinColumn.name}`);
|
|
|
|
await con(`${joinTable.name} as t1`)
|
|
.where(`t1.${joinColumn.name}`, id)
|
|
.update({ [inverseOrderColumnName]: subQuery })
|
|
.transacting(trx);
|
|
}
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
deletePreviousOneToAnyRelations,
|
|
deletePreviousAnyToOneRelations,
|
|
deleteRelations,
|
|
cleanOrderColumns,
|
|
cleanInverseOrderColumn,
|
|
};
|