fix: rollback when field creation fails (#498)
* fix: rollback when field creation fails * fix: missing transaction
This commit is contained in:
parent
e4b13289d7
commit
a92a78cc9e
@ -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();
|
||||||
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user