diff --git a/packages/core/database/lib/index.js b/packages/core/database/lib/index.js index 2407adf88d..7446c179f0 100644 --- a/packages/core/database/lib/index.js +++ b/packages/core/database/lib/index.js @@ -11,8 +11,7 @@ const errors = require('./errors'); // TODO: move back into strapi const { transformContentTypes } = require('./utils/content-types'); -const { getJoinTableName } = require('./metadata/relations'); -const types = require('./types'); +const { validateDatabase } = require('./validations'); class Database { constructor(config) { @@ -74,80 +73,11 @@ class Database { } } -const getLinks = ({ db }) => { - const relationsToUpdate = {}; - - db.metadata.forEach((contentType) => { - const attributes = contentType.attributes; - - // For each relation type, add the joinTable name to tablesToUpdate - Object.values(attributes).forEach((attribute) => { - if (!types.isRelation(attribute.type)) return; - - if (attribute.inversedBy) { - const invRelation = db.metadata.get(attribute.target).attributes[attribute.inversedBy]; - - // Both relations use inversedBy - if (invRelation?.inversedBy) { - relationsToUpdate[attribute.joinTable.name] = { - relation: attribute, - invRelation, - }; - } - } - }); - }); - - return Object.values(relationsToUpdate); -}; - // TODO: move into strapi Database.transformContentTypes = transformContentTypes; Database.init = async (config) => { const db = new Database(config); - - // TODO: Create validations folder for this. - const links = getLinks({ db }); - - const errorList = []; - - for (const { relation, invRelation } of links) { - // Generate the join table name based on the relation target - // table and attribute name. - const joinTableName = getJoinTableName( - db.metadata.get(relation.target).tableName, - relation.inversedBy - ); - - const contentType = db.metadata.get(invRelation.target); - const invContentType = db.metadata.get(relation.target); - - // If both sides use inversedBy - if (relation.inversedBy && invRelation.inversedBy) { - // If the generated join table name is the same as the one assigned in relation.joinTable, - // relation is on the inversed side of the bidirectional relation. - // and the other is on the owner side. - if (joinTableName === relation.joinTable.name) { - errorList.push( - `Error on attribute "${invRelation.inversedBy}" in model "${invContentType.tableName}"(${invContentType.uid}):` + - ` One of the sides of the relationship must be the owning side. You should use mappedBy` + - ` instead of inversedBy in the relation "${invRelation.inversedBy}".` - ); - } else { - errorList.push( - `Error on attribute "${relation.inversedBy}" in model "${contentType.tableName}"(${contentType.uid}):` + - ` One of the sides of the relationship must be the owning side. You should use mappedBy` + - ` instead of inversedBy in the relation "${relation.inversedBy}".` - ); - } - } - } - - if (errorList.length > 0) { - errorList.forEach((error) => strapi.log.error(error)); - throw new Error('There are errors in some of your models. Please check the logs above.'); - } - + await validateDatabase(db); return db; }; diff --git a/packages/core/database/lib/validations/index.js b/packages/core/database/lib/validations/index.js new file mode 100644 index 0000000000..a410331940 --- /dev/null +++ b/packages/core/database/lib/validations/index.js @@ -0,0 +1,20 @@ +'use strict'; + +const { validateRelations } = require('./relations'); + +/** + * Validate if the database is in a valid state before starting the server. + * + * @param {*} db - Database instance + */ +async function validateDatabase(db) { + const relationErrors = await validateRelations(db); + const errorList = [...relationErrors]; + + if (errorList.length > 0) { + errorList.forEach((error) => strapi.log.error(error)); + throw new Error('There are errors in some of your models. Please check the logs above.'); + } +} + +module.exports = { validateDatabase }; diff --git a/packages/core/database/lib/validations/relations/bidirectional.js b/packages/core/database/lib/validations/relations/bidirectional.js new file mode 100644 index 0000000000..b205e769ee --- /dev/null +++ b/packages/core/database/lib/validations/relations/bidirectional.js @@ -0,0 +1,99 @@ +'use strict'; + +const types = require('../../types'); +const { getJoinTableName } = require('../../metadata/relations'); + +const getLinksWithoutMappedBy = (db) => { + const relationsToUpdate = {}; + + db.metadata.forEach((contentType) => { + const attributes = contentType.attributes; + + // For each relation attribute, add the joinTable name to tablesToUpdate + Object.values(attributes).forEach((attribute) => { + if (!types.isRelation(attribute.type)) return; + + if (attribute.inversedBy) { + const invRelation = db.metadata.get(attribute.target).attributes[attribute.inversedBy]; + + // Both relations use inversedBy. + if (invRelation?.inversedBy) { + relationsToUpdate[attribute.joinTable.name] = { + relation: attribute, + invRelation, + }; + } + } + }); + }); + + return Object.values(relationsToUpdate); +}; + +const isLinkTableEmpty = async (db, linkTableName) => { + // If the table doesn't exist, it's empty + const exists = await db.getConnection().schema.hasTable(linkTableName); + if (!exists) return true; + + const result = await db.getConnection().count('* as count').from(linkTableName); + return result.count === 0; +}; + +/** + * Validates bidirectional relations before starting the server. + * - If both sides use inversedBy, one of the sides must switch to mappedBy. + * When this happens, two join tables exist in the database. + * This makes sure you switch the side which does not delete any data. + * + * @param {*} db + * @return {*} + */ +const validateBidirectionalRelations = async (db) => { + const invalidLinks = getLinksWithoutMappedBy(db); + const errorList = []; + + for (const { relation, invRelation } of invalidLinks) { + // Generate the join table name based on the relation target + // table and attribute name. + const inverseJoinTableName = getJoinTableName( + db.metadata.get(relation.target).tableName, + relation.inversedBy + ); + const joinTableName = getJoinTableName( + db.metadata.get(invRelation.target).tableName, + invRelation.inversedBy + ); + + const contentType = db.metadata.get(invRelation.target); + const invContentType = db.metadata.get(relation.target); + + // If both sides use inversedBy + if (relation.inversedBy && invRelation.inversedBy) { + const linkTableEmpty = await isLinkTableEmpty(db, joinTableName); + const inverseLinkTableEmpty = await isLinkTableEmpty(db, inverseJoinTableName); + + if (linkTableEmpty) { + errorList.push( + `Error on attribute "${relation.inversedBy}" in model "${contentType.tableName}"(${contentType.uid}):` + + ` One of the sides of the relationship must be the owning side. You should use mappedBy` + + ` instead of inversedBy in the relation "${relation.inversedBy}".` + ); + } else if (inverseLinkTableEmpty) { + // Its safe to delete the inverse join table + errorList.push( + `Error on attribute "${invRelation.inversedBy}" in model "${invContentType.tableName}"(${invContentType.uid}):` + + ` One of the sides of the relationship must be the owning side. You should use mappedBy` + + ` instead of inversedBy in the relation "${invRelation.inversedBy}".` + ); + } else { + // Both sides have data in the join table + } + } + } + + return errorList; +}; + +module.exports = { + validateBidirectionalRelations, +}; diff --git a/packages/core/database/lib/validations/relations/index.js b/packages/core/database/lib/validations/relations/index.js new file mode 100644 index 0000000000..02e8cffc93 --- /dev/null +++ b/packages/core/database/lib/validations/relations/index.js @@ -0,0 +1,14 @@ +'use strict'; + +const { validateBidirectionalRelations } = require('./bidirectional'); + +/** + * Validates if relations data and tables are in a valid state before + * starting the server. + */ +const validateRelations = async (db) => { + const bidirectionalRelationsErrors = await validateBidirectionalRelations(db); + return [...bidirectionalRelationsErrors]; +}; + +module.exports = { validateRelations };