From 558bb49f57fca6bded2162b2bf73dcdf1e527726 Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Sun, 7 Jul 2024 16:41:06 +0800 Subject: [PATCH] chore(datasource-main): throw error when destory field if field is used by association field (#4833) * chore(datasource-main): throw error when destory field if field is used by association field * fix: test * chore: destory field * chore: test * Update beforeDestoryField.ts --- .../associations/destroy-foreign-key.test.ts | 191 ++++++++++++++++++ .../src/server/hooks/beforeDestoryField.ts | 51 +++++ .../src/server/models/collection.ts | 4 + .../src/server/server.ts | 4 +- 4 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/associations/destroy-foreign-key.test.ts create mode 100644 packages/plugins/@nocobase/plugin-data-source-main/src/server/hooks/beforeDestoryField.ts diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/associations/destroy-foreign-key.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/associations/destroy-foreign-key.test.ts new file mode 100644 index 0000000000..16e941a1b0 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/associations/destroy-foreign-key.test.ts @@ -0,0 +1,191 @@ +import Database from '@nocobase/database'; +import { MockServer } from '@nocobase/test'; +import { createApp } from '..'; + +describe('destory key that used by association field', () => { + let db: Database; + let app: MockServer; + beforeEach(async () => { + app = await createApp(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should destroy field cascade', async () => { + await db.getRepository('collections').create({ + values: { + name: 'posts', + autoGenId: false, + fields: [ + { + type: 'string', + name: 'title', + primaryKey: true, + }, + { + type: 'string', + name: 'content', + }, + ], + }, + context: {}, + }); + + await db.getRepository('collections').create({ + values: { + name: 'comments', + fields: [ + { + type: 'string', + name: 'comment', + }, + ], + }, + context: {}, + }); + + // add has many field + await db.getRepository('fields').create({ + values: { + name: 'post', + collectionName: 'comments', + type: 'belongsTo', + target: 'posts', + foreignKey: 'postTitle', + targetKey: 'title', + }, + context: {}, + }); + + const CommentCollection = db.getCollection('comments'); + + expect(CommentCollection.getField('post')).toBeTruthy(); + + await db.getRepository('collections').create({ + values: { + name: 'posts2', + autoGenId: false, + fields: [ + { + type: 'string', + name: 'title', + primaryKey: true, + }, + ], + }, + context: {}, + }); + + await db.getRepository('collections').create({ + values: { + name: 'comments2', + fields: [ + { + type: 'string', + name: 'comment', + }, + ], + }, + context: {}, + }); + + await db.getRepository('fields').create({ + values: { + name: 'post', + collectionName: 'comments2', + type: 'belongsTo', + target: 'posts2', + foreignKey: 'postTitle', + targetKey: 'title', + }, + context: {}, + }); + + // destroy collection cascade + await db.getRepository('collections').destroy({ + filter: { + name: 'posts', + }, + cascade: true, + }); + + expect(CommentCollection.getField('post')).toBeUndefined(); + }); + + it('should throw error when destory a source key of hasMany field', async () => { + await db.getRepository('collections').create({ + values: { + name: 'posts', + autoGenId: false, + fields: [ + { + type: 'string', + name: 'title', + primaryKey: true, + }, + { + type: 'string', + name: 'content', + }, + ], + }, + context: {}, + }); + + await db.getRepository('collections').create({ + values: { + name: 'comments', + fields: [ + { + type: 'string', + name: 'comment', + }, + ], + }, + context: {}, + }); + + // add has many field + await db.getRepository('fields').create({ + values: { + name: 'comments', + collectionName: 'posts', + type: 'hasMany', + target: 'comments', + foreignKey: 'postTitle', + sourceKey: 'title', + }, + context: {}, + }); + + // it should throw error when destroy title field + let error; + try { + await db.getRepository('fields').destroy({ + filter: { + name: 'title', + collectionName: 'posts', + }, + }); + } catch (e) { + error = e; + } + + expect(error).toBeTruthy(); + expect(error.message).toBe( + `Can't delete field title of posts, it is used by field comments in collection posts as sourceKey`, + ); + + // it should destroy posts collection + await db.getRepository('collections').destroy({ + filter: { + name: 'posts', + }, + cascade: true, + context: {}, + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/hooks/beforeDestoryField.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/hooks/beforeDestoryField.ts new file mode 100644 index 0000000000..a9879a5ef5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/hooks/beforeDestoryField.ts @@ -0,0 +1,51 @@ +import { Database } from '@nocobase/database'; + +export function beforeDestoryField(db: Database) { + return async (model, opts) => { + const { transaction } = opts; + const { name, type, collectionName } = model.get(); + + if (['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(type)) { + return; + } + + const relatedFields = await db.getRepository('fields').find({ + filter: { + $or: [ + { + ['options.sourceKey']: name, + collectionName, + }, + { + ['options.targetKey']: name, + ['options.target']: collectionName, + }, + ], + }, + transaction, + }); + + for (const field of relatedFields) { + const keys = [ + { + name: 'sourceKey', + condition: (associationField) => + associationField.options['sourceKey'] === name && associationField.collectionName === collectionName, + }, + { + name: 'targetKey', + condition: (associationField) => + associationField.options['targetKey'] === name && associationField.options['target'] === collectionName, + }, + ]; + + const usedAs = keys.find((key) => key.condition(field))['name']; + + throw new Error( + `Can't delete field ${name} of ${collectionName}, it is used by field ${field.get( + 'name', + )} in collection ${field.get('collectionName')} as ${usedAs}`, + ); + } + }; +} diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts index 980da5f24d..f65c77f4fb 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/models/collection.ts @@ -158,6 +158,10 @@ export class CollectionModel extends MagicAttributeModel { } else if (field.get('through') && field.get('through') === name) { await field.destroy({ transaction }); } + + if (field.get('collectionName') === name) { + await field.destroy({ transaction }); + } } await collection.removeFromDb(options); diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts index ff1a10b679..f1d3140e42 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/server/server.ts @@ -28,6 +28,7 @@ import { CollectionModel, FieldModel } from './models'; import collectionActions from './resourcers/collections'; import viewResourcer from './resourcers/views'; import { FieldNameExistsError } from './errors/field-name-exists-error'; +import { beforeDestoryField } from './hooks/beforeDestoryField'; export class PluginDataSourceMainServer extends Plugin { public schema: string; @@ -85,7 +86,7 @@ export class PluginDataSourceMainServer extends Plugin { removeOptions['transaction'] = options.transaction; } - const cascade = lodash.get(options, 'context.action.params.cascade', false); + const cascade = options.cascade || lodash.get(options, 'context.action.params.cascade', false); if (cascade === true || cascade === 'true') { removeOptions['cascade'] = true; @@ -243,6 +244,7 @@ export class PluginDataSourceMainServer extends Plugin { }); // before field remove + this.app.db.on('fields.beforeDestroy', beforeDestoryField(this.app.db)); this.app.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.app.db)); const mutex = new Mutex();