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
This commit is contained in:
ChengLei Shao 2024-07-07 16:41:06 +08:00 committed by GitHub
parent 5f5a482ec7
commit 558bb49f57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 249 additions and 1 deletions

View File

@ -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: {},
});
});
});

View File

@ -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}`,
);
}
};
}

View File

@ -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);

View File

@ -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();