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,16 +50,36 @@ 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,
}); });
await collection.sync({ try {
force: false, await collection.sync({
alter: { force: false,
drop: false, alter: {
}, drop: false,
...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;
} }
await field.sync(options); try {
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, // },
}); // transaction,
if (!through) { // });
return; // if (!through) {
} // return;
const repository = db.getRepository('collections.fields', throughName); // }
await repository.create({ // const repository = db.getRepository('collections.fields', throughName);
transaction, // await repository.create({
values: { // transaction,
name: `f_${uid()}`, // values: {
type: 'belongsTo', // name: `f_${uid()}`,
target: sourceName, // type: 'belongsTo',
targetKey: model.get('sourceKey'), // target: sourceName,
foreignKey: model.get('foreignKey'), // targetKey: model.get('sourceKey'),
interface: 'linkTo', // foreignKey: model.get('foreignKey'),
reverseField: { // interface: 'linkTo',
interface: 'subTable', // reverseField: {
uiSchema: { // interface: 'subTable',
type: 'void', // uiSchema: {
title: through.get('title'), // type: 'void',
'x-component': 'TableField', // title: through.get('title'),
'x-component-props': {}, // 'x-component': 'TableField',
}, // 'x-component-props': {},
// uiSchema: { // },
// title: through.get('title'), // // uiSchema: {
// 'x-component': 'RecordPicker', // // title: through.get('title'),
// 'x-component-props': { // // 'x-component': 'RecordPicker',
// // mode: 'tags', // // 'x-component-props': {
// multiple: true, // // // mode: 'tags',
// fieldNames: { // // multiple: true,
// label: 'id', // // fieldNames: {
// value: 'id', // // label: 'id',
// }, // // value: 'id',
// }, // // },
// }, // // },
}, // // },
uiSchema: { // },
title: db.getCollection(sourceName)?.options?.title || sourceName, // uiSchema: {
'x-component': 'RecordPicker', // title: db.getCollection(sourceName)?.options?.title || sourceName,
'x-component-props': { // 'x-component': 'RecordPicker',
// mode: 'tags', // 'x-component-props': {
multiple: false, // // mode: 'tags',
fieldNames: { // multiple: false,
label: 'id', // fieldNames: {
value: 'id', // label: 'id',
}, // value: 'id',
}, // },
}, // },
}, // },
}); // },
await repository.create({ // });
transaction, // await repository.create({
values: { // transaction,
name: `f_${uid()}`, // values: {
type: 'belongsTo', // name: `f_${uid()}`,
target: targetName, // type: 'belongsTo',
targetKey: model.get('targetKey'), // target: targetName,
foreignKey: model.get('otherKey'), // targetKey: model.get('targetKey'),
interface: 'linkTo', // foreignKey: model.get('otherKey'),
reverseField: { // interface: 'linkTo',
interface: 'subTable', // reverseField: {
uiSchema: { // interface: 'subTable',
type: 'void', // uiSchema: {
title: through.get('title'), // type: 'void',
'x-component': 'TableField', // title: through.get('title'),
'x-component-props': {}, // 'x-component': 'TableField',
}, // 'x-component-props': {},
// interface: 'linkTo', // },
// uiSchema: { // // interface: 'linkTo',
// title: through.get('title'), // // uiSchema: {
// 'x-component': 'RecordPicker', // // title: through.get('title'),
// 'x-component-props': { // // 'x-component': 'RecordPicker',
// // mode: 'tags', // // 'x-component-props': {
// multiple: true, // // // mode: 'tags',
// fieldNames: { // // multiple: true,
// label: 'id', // // fieldNames: {
// value: 'id', // // label: 'id',
// }, // // value: 'id',
// }, // // },
// }, // // },
}, // // },
uiSchema: { // },
title: db.getCollection(targetName)?.options?.title || targetName, // uiSchema: {
'x-component': 'RecordPicker', // title: db.getCollection(targetName)?.options?.title || targetName,
'x-component-props': { // 'x-component': 'RecordPicker',
// mode: 'tags', // 'x-component-props': {
multiple: false, // // mode: 'tags',
fieldNames: { // multiple: false,
label: 'id', // fieldNames: {
value: 'id', // label: 'id',
}, // value: 'id',
}, // },
}, // },
}, // },
}); // },
await db.getRepository<CollectionRepository>('collections').load({ // });
filter: { // await db.getRepository<CollectionRepository>('collections').load({
'name.$in': [throughName, sourceName, targetName], // 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 () => {