fix: rollback when field creation fails (#498)

* fix: rollback when field creation fails

* fix: missing transaction
This commit is contained in:
chenos 2022-06-11 20:46:30 +08:00 committed by GitHub
parent e4b13289d7
commit a92a78cc9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 249 additions and 190 deletions

View File

@ -12,7 +12,6 @@ export async function create(ctx: Context, next) {
updateAssociationValues, updateAssociationValues,
context: ctx, context: ctx,
}); });
ctx.body = instance; ctx.body = instance;
await next(); await next();

View File

@ -186,6 +186,10 @@ export class Collection<
} }
} }
remove() {
this.context.database.removeCollection(this.name);
}
removeField(name) { removeField(name) {
if (!this.fields.has(name)) { if (!this.fields.has(name)) {
return; return;

View File

@ -198,6 +198,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
if (result) { if (result) {
this.emit('afterRemoveCollection', collection); this.emit('afterRemoveCollection', collection);
} }
return result;
} }
getModel<M extends Model>(name: string) { getModel<M extends Model>(name: string) {

View File

@ -75,6 +75,10 @@ export abstract class Field {
return this.options[name]; return this.options[name];
} }
remove() {
return this.collection.removeField(this.name);
}
merge(obj: any) { merge(obj: any) {
Object.assign(this.options, obj); Object.assign(this.options, obj);
} }

View File

@ -48,6 +48,7 @@ export abstract class RelationRepository {
return (<BelongsTo | HasOne | HasMany | BelongsToMany>this.association).accessors; return (<BelongsTo | HasOne | HasMany | BelongsToMany>this.association).accessors;
} }
@transaction()
async create(options?: CreateOptions): Promise<any> { async create(options?: CreateOptions): Promise<any> {
const createAccessor = this.accessors().create; const createAccessor = this.accessors().create;

View File

@ -1,36 +1,24 @@
import { mockServer, MockServer } from '@nocobase/test'; import { mockServer } from '@nocobase/test';
import CollectionManagerPlugin from '../plugin'; import CollectionManagerPlugin from '../plugin';
describe('collections repository', () => { describe('collections repository', () => {
let app: MockServer; it('case 1', async () => {
const app1 = mockServer({
beforeEach(async () => {
app = mockServer({
database: { database: {
tablePrefix: 'through_', tablePrefix: 'through_',
}, },
}); });
await app.cleanDb(); await app1.cleanDb();
app.plugin(CollectionManagerPlugin); app1.plugin(CollectionManagerPlugin);
await app.load(); await app1.load();
await app.install({ clean: true }); await app1.install({ clean: true });
await app.start(); await app1.start();
}); await app1
afterEach(async () => {
await app.destroy();
});
it('case 1', async () => {
await app
.agent() .agent()
.resource('collections') .resource('collections')
.create({ .create({
values: { values: {
name: 'resumes', name: 'resumes',
createdBy: true,
updatedBy: true,
sortable: true,
fields: [ fields: [
{ {
name: 'id', name: 'id',
@ -38,23 +26,17 @@ describe('collections repository', () => {
autoIncrement: true, autoIncrement: true,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: false,
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
interface: 'id',
}, },
], ],
title: '简历',
}, },
}); });
await app await app1
.agent() .agent()
.resource('collections') .resource('collections')
.create({ .create({
values: { values: {
name: 'jobs', name: 'jobs',
createdBy: true,
updatedBy: true,
sortable: true,
fields: [ fields: [
{ {
name: 'id', name: 'id',
@ -62,23 +44,17 @@ describe('collections repository', () => {
autoIncrement: true, autoIncrement: true,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: false,
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
interface: 'id',
}, },
], ],
title: '职位',
}, },
}); });
await app await app1
.agent() .agent()
.resource('collections') .resource('collections')
.create({ .create({
values: { values: {
name: 'matches', name: 'matches',
createdBy: true,
updatedBy: true,
sortable: true,
fields: [ fields: [
{ {
name: 'id', name: 'id',
@ -86,47 +62,57 @@ describe('collections repository', () => {
autoIncrement: true, autoIncrement: true,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: false,
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
interface: 'id',
}, },
], ],
title: '匹配',
}, },
}); });
await app await app1
.agent() .agent()
.resource('collections.fields', 'resumes') .resource('collections.fields', 'resumes')
.create({ .create({
values: { values: {
name: 'jobs', name: 'jobs',
type: 'belongsToMany', type: 'belongsToMany',
uiSchema: { foreignKey: 'rid',
'x-component': 'RecordPicker', otherKey: 'jid',
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
title: '职位',
},
reverseField: { reverseField: {
interface: 'linkTo',
type: 'belongsToMany', type: 'belongsToMany',
uiSchema: { name: 'resumes',
'x-component': 'RecordPicker',
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
title: '简历',
}, },
},
interface: 'linkTo',
target: 'jobs', target: 'jobs',
through: 'matches', through: 'matches',
}, },
}); });
const matchesCollection = app.db.getCollection('matches'); await app1
.agent()
.resource('collections.fields', 'resumes')
.create({
values: {
name: 'matches2',
type: 'hasMany',
target: 'matches',
foreignKey: 'rid',
reverseField: {
name: 'resume',
},
},
});
const matchesFields = [...matchesCollection.fields.entries()]; const job1 = await app1.db.getRepository('jobs').create({});
const matchJobField = matchesFields.find((item) => item[1].options.target == 'jobs'); await app1.db.getRepository('resumes').create({
values: {
expect(matchesCollection.model.rawAttributes[matchJobField[1].options.foreignKey].primaryKey).not.toBeTruthy(); jobs: [job1.get('id')],
},
});
const match1 = await app1.db.getRepository('matches').findOne();
expect(match1.toJSON()).toMatchObject({
id: 1,
rid: 1,
jid: 1,
});
await app1.destroy();
const app2 = mockServer({ const app2 = mockServer({
database: { database: {
@ -137,12 +123,21 @@ describe('collections repository', () => {
await app2.load(); await app2.load();
await app2.start(); await app2.start();
await app2.db.sync(); await app2.db.sync({
force: true,
expect( });
app.db.getCollection('matches').model.rawAttributes[matchJobField[1].options.foreignKey].primaryKey, const job = await app2.db.getRepository('jobs').create({});
).not.toBeTruthy(); await app2.db.getRepository('resumes').create({
values: {
jobs: [job.get('id')],
},
});
const match = await app2.db.getRepository('matches').findOne();
expect(match.toJSON()).toMatchObject({
id: 1,
rid: 1,
jid: 1,
});
await app2.destroy(); await app2.destroy();
}); });
}); });

View File

@ -1,5 +1,5 @@
import { SyncOptions, Transactionable } from 'sequelize';
import Database, { Collection, MagicAttributeModel } from '@nocobase/database'; import Database, { Collection, MagicAttributeModel } from '@nocobase/database';
import { SyncOptions, Transactionable } from 'sequelize';
import { FieldModel } from './field'; import { FieldModel } from './field';
interface LoadOptions extends Transactionable { interface LoadOptions extends Transactionable {
@ -50,10 +50,26 @@ export class CollectionModel extends MagicAttributeModel {
} }
} }
/**
* TODO: drop table from the database
*
* @param options
* @returns
*/
async remove(options?: any) {
const name = this.get('name');
// delete from memory
const result = this.db.removeCollection(name);
// TODO: drop table from the database
// this.db.sequelize.getQueryInterface().dropTable(this.get('name'));
return result;
}
async migrate(options?: SyncOptions & Transactionable) { async migrate(options?: SyncOptions & Transactionable) {
const collection = await this.load({ const collection = await this.load({
transaction: options?.transaction, transaction: options?.transaction,
}); });
try {
await collection.sync({ await collection.sync({
force: false, force: false,
alter: { alter: {
@ -61,5 +77,9 @@ export class CollectionModel extends MagicAttributeModel {
}, },
...options, ...options,
}); });
} catch (error) {
const name = this.get('name');
this.db.removeCollection(name);
}
} }
} }

View File

@ -41,6 +41,32 @@ export class FieldModel extends MagicAttributeModel {
if (!field) { if (!field) {
return; return;
} }
try {
await field.sync(options); await field.sync(options);
} catch (error) {
// field sync failed, delete from memory
field.remove();
throw error;
}
}
/**
* TODO: drop column from the database
*
* @param options
* @returns
*/
async remove(options?: any) {
const collectionName = this.get('collectionName');
const fieldName = this.get('name');
if (!this.db.hasCollection(collectionName)) {
return;
}
const collection = this.db.getCollection(collectionName);
// delete from memory
const result = collection.removeField(this.get('name'));
// TODO: drop column from the database
// this.db.sequelize.getQueryInterface().removeColumn(collectionName, fieldName);
return result;
} }
} }

View File

@ -1,6 +1,5 @@
import { Collection } from '@nocobase/database'; import { Collection } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import { uid } from '@nocobase/utils';
import lodash from 'lodash'; import lodash from 'lodash';
import path from 'path'; import path from 'path';
import { CollectionRepository } from '.'; import { CollectionRepository } from '.';
@ -92,123 +91,132 @@ export class CollectionManagerPlugin extends Plugin {
} }
}); });
this.app.db.on('fields.afterCreateWithAssociations', async (model, { context, transaction }) => { // this.app.db.on('fields.afterCreateWithAssociations', async (model, { context, transaction }) => {
if (!context) { // return;
return; // if (!context) {
} // return;
if (!model.get('through')) { // }
return; // if (!model.get('through')) {
} // return;
const [throughName, sourceName, targetName] = [ // }
model.get('through'), // const [throughName, sourceName, targetName] = [
model.get('collectionName'), // model.get('through'),
model.get('target'), // model.get('collectionName'),
]; // model.get('target'),
const db = this.app.db; // ];
const through = await db.getRepository('collections').findOne({ // const db = this.app.db;
filter: { // const through = await db.getRepository('collections').findOne({
name: throughName, // filter: {
}, // name: throughName,
transaction,
});
if (!through) {
return;
}
const repository = db.getRepository('collections.fields', throughName);
await repository.create({
transaction,
values: {
name: `f_${uid()}`,
type: 'belongsTo',
target: sourceName,
targetKey: model.get('sourceKey'),
foreignKey: model.get('foreignKey'),
interface: 'linkTo',
reverseField: {
interface: 'subTable',
uiSchema: {
type: 'void',
title: through.get('title'),
'x-component': 'TableField',
'x-component-props': {},
},
// uiSchema: {
// title: through.get('title'),
// 'x-component': 'RecordPicker',
// 'x-component-props': {
// // mode: 'tags',
// multiple: true,
// fieldNames: {
// label: 'id',
// value: 'id',
// }, // },
// }, // transaction,
// }, // });
}, // if (!through) {
uiSchema: { // return;
title: db.getCollection(sourceName)?.options?.title || sourceName, // }
'x-component': 'RecordPicker', // const repository = db.getRepository('collections.fields', throughName);
'x-component-props': { // await repository.create({
// mode: 'tags', // transaction,
multiple: false, // values: {
fieldNames: { // name: `f_${uid()}`,
label: 'id', // type: 'belongsTo',
value: 'id', // target: sourceName,
}, // targetKey: model.get('sourceKey'),
}, // foreignKey: model.get('foreignKey'),
},
},
});
await repository.create({
transaction,
values: {
name: `f_${uid()}`,
type: 'belongsTo',
target: targetName,
targetKey: model.get('targetKey'),
foreignKey: model.get('otherKey'),
interface: 'linkTo',
reverseField: {
interface: 'subTable',
uiSchema: {
type: 'void',
title: through.get('title'),
'x-component': 'TableField',
'x-component-props': {},
},
// interface: 'linkTo', // interface: 'linkTo',
// reverseField: {
// interface: 'subTable',
// uiSchema: { // uiSchema: {
// type: 'void',
// title: through.get('title'), // title: through.get('title'),
// 'x-component': 'TableField',
// 'x-component-props': {},
// },
// // uiSchema: {
// // title: through.get('title'),
// // 'x-component': 'RecordPicker',
// // 'x-component-props': {
// // // mode: 'tags',
// // multiple: true,
// // fieldNames: {
// // label: 'id',
// // value: 'id',
// // },
// // },
// // },
// },
// uiSchema: {
// title: db.getCollection(sourceName)?.options?.title || sourceName,
// 'x-component': 'RecordPicker', // 'x-component': 'RecordPicker',
// 'x-component-props': { // 'x-component-props': {
// // mode: 'tags', // // mode: 'tags',
// multiple: true, // multiple: false,
// fieldNames: { // fieldNames: {
// label: 'id', // label: 'id',
// value: 'id', // value: 'id',
// }, // },
// }, // },
// }, // },
}, // },
uiSchema: { // });
title: db.getCollection(targetName)?.options?.title || targetName, // await repository.create({
'x-component': 'RecordPicker', // transaction,
'x-component-props': { // values: {
// mode: 'tags', // name: `f_${uid()}`,
multiple: false, // type: 'belongsTo',
fieldNames: { // target: targetName,
label: 'id', // targetKey: model.get('targetKey'),
value: 'id', // foreignKey: model.get('otherKey'),
}, // interface: 'linkTo',
}, // reverseField: {
}, // interface: 'subTable',
}, // uiSchema: {
}); // type: 'void',
await db.getRepository<CollectionRepository>('collections').load({ // title: through.get('title'),
filter: { // 'x-component': 'TableField',
'name.$in': [throughName, sourceName, targetName], // 'x-component-props': {},
}, // },
// // interface: 'linkTo',
// // uiSchema: {
// // title: through.get('title'),
// // 'x-component': 'RecordPicker',
// // 'x-component-props': {
// // // mode: 'tags',
// // multiple: true,
// // fieldNames: {
// // label: 'id',
// // value: 'id',
// // },
// // },
// // },
// },
// uiSchema: {
// title: db.getCollection(targetName)?.options?.title || targetName,
// 'x-component': 'RecordPicker',
// 'x-component-props': {
// // mode: 'tags',
// multiple: false,
// fieldNames: {
// label: 'id',
// value: 'id',
// },
// },
// },
// },
// });
// await db.getRepository<CollectionRepository>('collections').load({
// filter: {
// 'name.$in': [throughName, sourceName, targetName],
// },
// });
// });
this.app.db.on('fields.afterDestroy', async (model, options) => {
await model.remove(options);
}); });
this.app.db.on('collections.afterDestroy', async (model, options) => {
await model.remove(options);
}); });
this.app.on('beforeStart', async () => { this.app.on('beforeStart', async () => {