feat: reference check (#989)

* chore: test

* chore: test

* chore: test code

* feat:  on delete restrict

* feat: on delete cascade

* feat:  on delete set null

* feat: reference unbind

* fix: test

* fix: acl test

* fix: test on Windows

* fix: database recreate

* fix: application reload

* fix: multi-app-manager test

* fix: test

* feat: ondelete

* fix: hasOne field onDelete

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2022-10-31 22:45:39 +08:00 committed by GitHub
parent 67f3c84d27
commit 9f5f2d6028
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 467 additions and 81 deletions

View File

@ -6,16 +6,10 @@ import bodyParser from 'koa-bodyparser';
import qs from 'qs'; import qs from 'qs';
import supertest, { SuperAgentTest } from 'supertest'; import supertest, { SuperAgentTest } from 'supertest';
import db2resource from '../../../server/src/middlewares/db2resource'; import db2resource from '../../../server/src/middlewares/db2resource';
import { uid } from '@nocobase/utils';
export function generatePrefixByPath() { export function generatePrefixByPath() {
const { id } = require.main; return `mock_${uid(6)}`;
const key = id
.replace(`${process.env.PWD}/packages`, '')
.replace(/src\/__tests__/g, '')
.replace('.test.ts', '')
.replace(/[^\w]/g, '_')
.replace(/_+/g, '_');
return key;
} }
export function getConfig(config = {}, options?: any): DatabaseOptions { export function getConfig(config = {}, options?: any): DatabaseOptions {

View File

@ -14,6 +14,5 @@ export async function create(ctx: Context, next) {
}); });
ctx.body = instance; ctx.body = instance;
await next(); await next();
} }

View File

@ -1,6 +1,6 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; import { constraintsProps, recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties';
import { IField } from './types'; import { IField } from './types';
export const m2o: IField = { export const m2o: IField = {
@ -203,6 +203,7 @@ export const m2o: IField = {
}, },
}, },
}, },
...constraintsProps,
...reverseFieldProperties, ...reverseFieldProperties,
}, },
filterable: { filterable: {

View File

@ -1,6 +1,6 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; import { constraintsProps, recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties';
import { IField } from './types'; import { IField } from './types';
export const o2m: IField = { export const o2m: IField = {
@ -240,6 +240,7 @@ export const o2m: IField = {
}, },
}, },
}, },
...constraintsProps,
...reverseFieldProperties, ...reverseFieldProperties,
}, },
filterable: { filterable: {

View File

@ -1,6 +1,6 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; import { constraintsProps, recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties';
import { IField } from './types'; import { IField } from './types';
const internalSchameInitialize = (schema: ISchema, { field, block, readPretty, action }) => { const internalSchameInitialize = (schema: ISchema, { field, block, readPretty, action }) => {
@ -382,6 +382,7 @@ export const oho: IField = {
}, },
}, },
}, },
...constraintsProps,
...reverseFieldProperties, ...reverseFieldProperties,
}, },
filterable: { filterable: {
@ -548,6 +549,7 @@ export const obo: IField = {
}, },
}, },
}, },
...constraintsProps,
...reverseFieldProperties, ...reverseFieldProperties,
}, },
filterable: { filterable: {

View File

@ -53,6 +53,23 @@ export const relationshipType: ISchema = {
], ],
}; };
export const constraintsProps = {
onDelete: {
type: 'string',
title: '{{t("ON DELETE")}}',
required: true,
default: 'SET NULL',
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{ label: "{{t('SET NULL')}}", value: 'SET NULL' },
{ label: "{{t('RESTRICT')}}", value: 'RESTRICT' },
{ label: "{{t('CASCADE')}}", value: 'CASCADE' },
{ label: "{{t('NO ACTION')}}", value: 'NO ACTION' },
],
},
};
export const reverseFieldProperties: Record<string, ISchema> = { export const reverseFieldProperties: Record<string, ISchema> = {
reverse: { reverse: {
type: 'void', type: 'void',

View File

@ -64,10 +64,13 @@ describe('belongs to field', () => {
}); });
it('custom targetKey and foreignKey', async () => { it('custom targetKey and foreignKey', async () => {
const Post = db.collection({ db.collection({
name: 'posts', name: "posts",
fields: [{ type: 'string', name: 'key', unique: true }], fields: [
{ type: "string", name: "key" },
]
}); });
const Comment = db.collection({ const Comment = db.collection({
name: 'comments', name: 'comments',
fields: [ fields: [
@ -79,6 +82,7 @@ describe('belongs to field', () => {
}, },
], ],
}); });
const association = Comment.model.associations.post; const association = Comment.model.associations.post;
expect(association).toBeDefined(); expect(association).toBeDefined();
expect(association.foreignKey).toBe('postKey'); expect(association.foreignKey).toBe('postKey');
@ -99,7 +103,7 @@ describe('belongs to field', () => {
let error; let error;
try { try {
const Comment = db.collection({ db.collection({
name: 'comments1', name: 'comments1',
fields: [ fields: [
{ {
@ -114,6 +118,7 @@ describe('belongs to field', () => {
error = e; error = e;
} }
expect(error).toBeInstanceOf(IdentifierError); expect(error).toBeInstanceOf(IdentifierError);
}); });
@ -194,4 +199,107 @@ describe('belongs to field', () => {
const association = Post.model.associations; const association = Post.model.associations;
expect(association['comments']).toBeDefined(); expect(association['comments']).toBeDefined();
}); });
describe('foreign constraints', () => {
it('should set null on delete', async () => {
const Product = db.collection({
name: 'products',
fields: [{ type: 'string', name: 'name' }],
});
const Order = db.collection({
name: 'order',
fields: [{ type: 'belongsTo', name: 'product', onDelete: 'SET NULL' }],
});
await db.sync();
const p = await Product.repository.create({ values: { name: 'p1' } });
const o = await Order.repository.create({ values: { product: p.id } });
expect(o.productId).toBe(p.id);
await Product.repository.destroy({
filterByTk: p.id,
});
const newO = await o.reload();
expect(newO.productId).toBeNull();
});
it('should delete reference map item when field unbind', async () => {
const Product = db.collection({
name: 'products',
fields: [{ type: 'string', name: 'name' }],
});
const Order = db.collection({
name: 'order',
fields: [{ type: 'belongsTo', name: 'product', onDelete: 'CASCADE' }],
});
await db.sync();
Order.removeField('product');
expect(db.referenceMap.getReferences(Product.name)).toHaveLength(0);
});
it('should delete cascade', async () => {
const Product = db.collection({
name: 'products',
fields: [{ type: 'string', name: 'name' }],
});
const Order = db.collection({
name: 'order',
fields: [{ type: 'belongsTo', name: 'product', onDelete: 'CASCADE' }],
});
await db.sync();
const p = await Product.repository.create({ values: { name: 'p1' } });
await Order.repository.create({ values: { product: p.id } });
await Order.repository.create({ values: { product: p.id } });
expect(await Order.repository.count({ filter: { productId: p.id } })).toBe(2);
await Product.repository.destroy({
filterByTk: p.id,
});
expect(await Order.repository.count({ filter: { productId: p.id } })).toBe(0);
});
it('should delete restrict', async () => {
const Product = db.collection({
name: 'products',
fields: [{ type: 'string', name: 'name' }],
});
const Order = db.collection({
name: 'order',
fields: [{ type: 'belongsTo', name: 'product', onDelete: 'RESTRICT' }],
});
await db.sync();
const p = await Product.repository.create({ values: { name: 'p1' } });
const o = await Order.repository.create({ values: { product: p.id } });
expect(o.productId).toBe(p.id);
let error = null;
try {
await Product.repository.destroy({
filterByTk: p.id,
});
} catch (e) {
error = e;
}
expect(error).not.toBeNull();
});
});
}); });

View File

@ -171,4 +171,87 @@ describe('has many field', () => {
expect(error).toBeInstanceOf(IdentifierError); expect(error).toBeInstanceOf(IdentifierError);
}); });
describe('foreign key constraint', function () {
it('should cascade delete', async () => {
const Post = db.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments', onDelete: 'CASCADE' },
],
});
const Comment = db.collection({
name: 'comments',
fields: [
{ type: 'string', name: 'content' },
{ type: 'belongsTo', name: 'post', onDelete: "CASCADE" },
],
});
await db.sync();
const post = await Post.repository.create({
values: {
title: 'post1',
},
});
const comment = await Comment.repository.create({
values: {
content: 'comment1',
postId: post.id,
},
});
await Post.repository.destroy({
filterByTk: post.id,
});
expect(await Comment.repository.count()).toEqual(0);
});
it('should throw error when foreign key constraint is violated', async function () {
const Post = db.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{ type: 'hasMany', name: 'comments', onDelete: 'RESTRICT' },
],
});
const Comment = db.collection({
name: 'comments',
fields: [{ type: 'string', name: 'content' }],
});
await db.sync();
const post = await Post.repository.create({
values: {
title: 'post1',
},
});
const comment = await Comment.repository.create({
values: {
content: 'comment1',
postId: post.id,
},
});
let error;
try {
await Post.repository.destroy({
filterByTk: post.id,
});
} catch (e) {
error = e;
}
expect(error).toBeDefined();
});
});
}); });

View File

@ -74,6 +74,8 @@ export class Collection<
this.bindFieldEventListener(); this.bindFieldEventListener();
this.modelInit(); this.modelInit();
this.db.modelCollection.set(this.model, this);
this.setFields(options.fields); this.setFields(options.fields);
this.setRepository(options.repository); this.setRepository(options.repository);
this.setSortable(options.sortable); this.setSortable(options.sortable);

View File

@ -14,7 +14,7 @@ import {
Sequelize, Sequelize,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection'; import { Collection, CollectionOptions, RepositoryType } from './collection';
@ -52,8 +52,10 @@ import {
SyncListener, SyncListener,
UpdateListener, UpdateListener,
UpdateWithAssociationsListener, UpdateWithAssociationsListener,
ValidateListener ValidateListener,
} from './types'; } from './types';
import { referentialIntegrityCheck } from './features/referential-integrity-check';
import ReferencesMap from './features/ReferencesMap';
export interface MergeOptions extends merge.Options {} export interface MergeOptions extends merge.Options {}
@ -146,6 +148,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
collections = new Map<string, Collection>(); collections = new Map<string, Collection>();
pendingFields = new Map<string, RelationField[]>(); pendingFields = new Map<string, RelationField[]>();
modelCollection = new Map<ModelCtor<any>, Collection>(); modelCollection = new Map<ModelCtor<any>, Collection>();
referenceMap = new ReferencesMap();
modelHook: ModelHook; modelHook: ModelHook;
version: DatabaseVersion; version: DatabaseVersion;
@ -235,6 +238,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
} }
}); });
this.initListener();
}
initListener() {
this.on('afterCreate', async (instance) => { this.on('afterCreate', async (instance) => {
instance?.toChangedWithAssociations?.(); instance?.toChangedWithAssociations?.();
}); });
@ -242,6 +249,14 @@ export class Database extends EventEmitter implements AsyncEmitter {
this.on('afterUpdate', async (instance) => { this.on('afterUpdate', async (instance) => {
instance?.toChangedWithAssociations?.(); instance?.toChangedWithAssociations?.();
}); });
this.on('beforeDestroy', async (instance, options) => {
await referentialIntegrityCheck({
db: this,
referencedInstance: instance,
transaction: options.transaction,
});
});
} }
addMigration(item: MigrationItem) { addMigration(item: MigrationItem) {
@ -283,7 +298,6 @@ export class Database extends EventEmitter implements AsyncEmitter {
}); });
this.collections.set(collection.name, collection); this.collections.set(collection.name, collection);
this.modelCollection.set(collection.model, collection);
this.emit('afterDefineCollection', collection); this.emit('afterDefineCollection', collection);
@ -497,7 +511,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
on(event: ModelSaveWithAssociationsEventTypes, listener: SaveWithAssociationsListener): this; on(event: ModelSaveWithAssociationsEventTypes, listener: SaveWithAssociationsListener): this;
on(event: DatabaseBeforeDefineCollectionEventType, listener: BeforeDefineCollectionListener): this; on(event: DatabaseBeforeDefineCollectionEventType, listener: BeforeDefineCollectionListener): this;
on(event: DatabaseAfterDefineCollectionEventType, listener: AfterDefineCollectionListener): this; on(event: DatabaseAfterDefineCollectionEventType, listener: AfterDefineCollectionListener): this;
on(event: DatabaseBeforeRemoveCollectionEventType | DatabaseAfterRemoveCollectionEventType, listener: RemoveCollectionListener): this; on(
event: DatabaseBeforeRemoveCollectionEventType | DatabaseAfterRemoveCollectionEventType,
listener: RemoveCollectionListener,
): this;
on(event: EventType, listener: any): this { on(event: EventType, listener: any): this {
// NOTE: to match if event is a sequelize or model type // NOTE: to match if event is a sequelize or model type
const type = this.modelHook.match(event); const type = this.modelHook.match(event);

View File

@ -0,0 +1,64 @@
export interface Reference {
sourceCollectionName: string;
sourceField: string;
targetField: string;
targetCollectionName: string;
onDelete: string;
}
class ReferencesMap {
protected map: Map<string, Reference[]> = new Map();
addReference(reference: Reference) {
const existReference = this.existReference(reference);
if (existReference) {
if (reference.onDelete && existReference.onDelete !== reference.onDelete) {
throw new Error(
`On Delete Conflict, exist reference ${JSON.stringify(existReference)}, new reference ${JSON.stringify(
reference,
)}`,
);
}
return;
}
if (!reference.onDelete) {
reference.onDelete = 'SET NULL';
}
this.map.set(reference.targetCollectionName, [...(this.map.get(reference.targetCollectionName) || []), reference]);
}
getReferences(collectionName) {
return this.map.get(collectionName);
}
existReference(reference: Reference) {
const references = this.map.get(reference.targetCollectionName);
if (!references) {
return null;
}
const keys = Object.keys(reference).filter((k) => k !== 'onDelete');
return references.find((ref) => keys.every((key) => ref[key] === reference[key]));
}
removeReference(reference: Reference) {
const references = this.map.get(reference.targetCollectionName);
if (!references) {
return;
}
const keys = Object.keys(reference);
this.map.set(
reference.targetCollectionName,
references.filter((ref) => !keys.every((key) => ref[key] === reference[key])),
);
}
}
export default ReferencesMap;

View File

@ -0,0 +1,61 @@
import Database from '../database';
import { Model, Transactionable } from 'sequelize';
interface ReferentialIntegrityCheckOptions extends Transactionable {
db: Database;
referencedInstance: Model;
}
export async function referentialIntegrityCheck(options: ReferentialIntegrityCheckOptions) {
const { referencedInstance, db, transaction } = options;
// @ts-ignore
const collection = db.modelCollection.get(referencedInstance.constructor);
const collectionName = collection.name;
const references = db.referenceMap.getReferences(collectionName);
if (!references) {
return;
}
for (const reference of references) {
const { sourceCollectionName, sourceField, targetField, onDelete } = reference;
const sourceCollection = db.collections.get(sourceCollectionName);
const sourceRepository = sourceCollection.repository;
const filter = {
[sourceField]: referencedInstance[targetField],
};
const referencingExists = await sourceRepository.count({
filter,
transaction,
});
if (!referencingExists) {
continue;
}
if (onDelete === 'RESTRICT') {
throw new Error('RESTRICT');
}
if (onDelete === 'CASCADE') {
await sourceRepository.destroy({
filter: filter,
transaction,
});
}
if (onDelete === 'SET NULL') {
await sourceRepository.update({
filter,
values: {
[sourceField]: null,
},
hooks: false,
transaction,
});
}
}
}

View File

@ -2,6 +2,7 @@ import { omit } from 'lodash';
import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
import { Reference } from '../features/ReferencesMap';
export class BelongsToField extends RelationField { export class BelongsToField extends RelationField {
static type = 'belongsTo'; static type = 'belongsTo';
@ -11,6 +12,18 @@ export class BelongsToField extends RelationField {
return target || Utils.pluralize(name); return target || Utils.pluralize(name);
} }
reference(association): Reference {
const targetKey = association.targetKey;
return {
sourceCollectionName: this.database.modelCollection.get(association.source).name,
sourceField: association.foreignKey,
targetField: targetKey,
targetCollectionName: this.database.modelCollection.get(association.target).name,
onDelete: this.options.onDelete,
};
}
bind() { bind() {
const { database, collection } = this.context; const { database, collection } = this.context;
const Target = this.TargetModel; const Target = this.TargetModel;
@ -30,7 +43,7 @@ export class BelongsToField extends RelationField {
const association = collection.model.belongsTo(Target, { const association = collection.model.belongsTo(Target, {
as: this.name, as: this.name,
constraints: false, constraints: false,
...omit(this.options, ['name', 'type', 'target']), ...omit(this.options, ['name', 'type', 'target', 'onDelete']),
}); });
// inverse relation // inverse relation
@ -56,6 +69,9 @@ export class BelongsToField extends RelationField {
} }
this.collection.addIndex([this.options.foreignKey]); this.collection.addIndex([this.options.foreignKey]);
this.database.referenceMap.addReference(this.reference(association));
return true; return true;
} }
@ -73,6 +89,10 @@ export class BelongsToField extends RelationField {
if (!field1 && !field2) { if (!field1 && !field2) {
collection.model.removeAttribute(foreignKey); collection.model.removeAttribute(foreignKey);
} }
const association = collection.model.associations[this.name];
this.database.referenceMap.removeReference(this.reference(association));
// 删掉 model 的关联字段 // 删掉 model 的关联字段
delete collection.model.associations[this.name]; delete collection.model.associations[this.name];
// @ts-ignore // @ts-ignore

View File

@ -5,11 +5,12 @@ import {
ForeignKeyOptions, ForeignKeyOptions,
HasManyOptions, HasManyOptions,
HasManyOptions as SequelizeHasManyOptions, HasManyOptions as SequelizeHasManyOptions,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { MultipleRelationFieldOptions, RelationField } from './relation-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field';
import { Reference } from '../features/ReferencesMap';
export interface HasManyFieldOptions extends HasManyOptions { export interface HasManyFieldOptions extends HasManyOptions {
/** /**
@ -81,6 +82,18 @@ export class HasManyField extends RelationField {
return Utils.camelize([model.options.name.singular, this.sourceKey || model.primaryKeyAttribute].join('_')); return Utils.camelize([model.options.name.singular, this.sourceKey || model.primaryKeyAttribute].join('_'));
} }
reference(association): Reference {
const sourceKey = association.sourceKey;
return {
sourceCollectionName: this.database.modelCollection.get(association.target).name,
sourceField: association.foreignKey,
targetField: sourceKey,
targetCollectionName: this.database.modelCollection.get(association.source).name,
onDelete: this.options.onDelete,
};
}
bind() { bind() {
const { database, collection } = this.context; const { database, collection } = this.context;
const Target = this.TargetModel; const Target = this.TargetModel;
@ -95,7 +108,7 @@ export class HasManyField extends RelationField {
const association = collection.model.hasMany(Target, { const association = collection.model.hasMany(Target, {
constraints: false, constraints: false,
...omit(this.options, ['name', 'type', 'target']), ...omit(this.options, ['name', 'type', 'target', 'onDelete']),
as: this.name, as: this.name,
foreignKey: this.foreignKey, foreignKey: this.foreignKey,
}); });
@ -130,6 +143,9 @@ export class HasManyField extends RelationField {
if (tcoll) { if (tcoll) {
tcoll.addIndex([this.options.foreignKey]); tcoll.addIndex([this.options.foreignKey]);
} }
this.database.referenceMap.addReference(this.reference(association));
return true; return true;
} }
@ -149,6 +165,13 @@ export class HasManyField extends RelationField {
if (!field) { if (!field) {
tcoll.model.removeAttribute(foreignKey); tcoll.model.removeAttribute(foreignKey);
} }
const association = collection.model.associations[this.name];
if (association) {
this.database.referenceMap.removeReference(this.reference(association));
}
// 删掉 model 的关联字段 // 删掉 model 的关联字段
delete collection.model.associations[this.name]; delete collection.model.associations[this.name];
// @ts-ignore // @ts-ignore

View File

@ -5,11 +5,12 @@ import {
ForeignKeyOptions, ForeignKeyOptions,
HasOneOptions, HasOneOptions,
HasOneOptions as SequelizeHasOneOptions, HasOneOptions as SequelizeHasOneOptions,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { checkIdentifier } from '../utils'; import { checkIdentifier } from '../utils';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
import { Reference } from '../features/ReferencesMap';
export interface HasOneFieldOptions extends HasOneOptions { export interface HasOneFieldOptions extends HasOneOptions {
/** /**
@ -86,9 +87,22 @@ export class HasOneField extends RelationField {
return Utils.camelize([model.options.name.singular, model.primaryKeyAttribute].join('_')); return Utils.camelize([model.options.name.singular, model.primaryKeyAttribute].join('_'));
} }
reference(association): Reference {
const sourceKey = association.sourceKey;
return {
sourceCollectionName: this.database.modelCollection.get(association.target).name,
sourceField: association.foreignKey,
targetField: sourceKey,
targetCollectionName: this.database.modelCollection.get(association.source).name,
onDelete: this.options.onDelete,
};
}
bind() { bind() {
const { database, collection } = this.context; const { database, collection } = this.context;
const Target = this.TargetModel; const Target = this.TargetModel;
if (!Target) { if (!Target) {
database.addPendingField(this); database.addPendingField(this);
return false; return false;
@ -96,7 +110,7 @@ export class HasOneField extends RelationField {
const association = collection.model.hasOne(Target, { const association = collection.model.hasOne(Target, {
constraints: false, constraints: false,
...omit(this.options, ['name', 'type', 'target']), ...omit(this.options, ['name', 'type', 'target', 'onDelete']),
as: this.name, as: this.name,
foreignKey: this.foreignKey, foreignKey: this.foreignKey,
}); });
@ -129,6 +143,8 @@ export class HasOneField extends RelationField {
if (tcoll) { if (tcoll) {
tcoll.addIndex([this.options.foreignKey]); tcoll.addIndex([this.options.foreignKey]);
} }
this.database.referenceMap.addReference(this.reference(association));
return true; return true;
} }
@ -148,6 +164,10 @@ export class HasOneField extends RelationField {
if (!field) { if (!field) {
tcoll.model.removeAttribute(foreignKey); tcoll.model.removeAttribute(foreignKey);
} }
const association = collection.model.associations[this.name];
this.database.referenceMap.removeReference(this.reference(association));
// 删掉 model 的关联字段 // 删掉 model 的关联字段
delete collection.model.associations[this.name]; delete collection.model.associations[this.name];
// @ts-ignore // @ts-ignore

View File

@ -338,6 +338,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
const guard = UpdateGuard.fromOptions(this.model, { ...options, action: 'create' }); const guard = UpdateGuard.fromOptions(this.model, { ...options, action: 'create' });
const values = guard.sanitize(options.values || {}); const values = guard.sanitize(options.values || {});
const instance = await this.model.create<any>(values, { const instance = await this.model.create<any>(values, {
...options, ...options,
transaction, transaction,

View File

@ -210,11 +210,14 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this.middleware = new Toposort<any>(); this.middleware = new Toposort<any>();
this.plugins = new Map<string, Plugin>(); this.plugins = new Map<string, Plugin>();
this._acl = createACL(); this._acl = createACL();
if (this._db) { if (this._db) {
// MaxListenersExceededWarning // MaxListenersExceededWarning
this._db.removeAllListeners(); this._db.removeAllListeners();
} }
this._db = this.createDatabase(options); this._db = this.createDatabase(options);
this._resourcer = createResourcer(options); this._resourcer = createResourcer(options);
this._cli = new Command('nocobase').usage('[command] [options]'); this._cli = new Command('nocobase').usage('[command] [options]');
this._i18n = createI18n(options); this._i18n = createI18n(options);
@ -250,17 +253,13 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
} }
private createDatabase(options: ApplicationOptions) { private createDatabase(options: ApplicationOptions) {
if (options.database instanceof Database) {
return options.database;
} else {
return new Database({ return new Database({
...options.database, ...(options.database instanceof Database ? options.database.options : options.database),
migrator: { migrator: {
context: { app: this }, context: { app: this },
}, },
}); });
} }
}
getVersion() { getVersion() {
return packageJson.version; return packageJson.version;
@ -315,8 +314,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
async load(options?: any) { async load(options?: any) {
if (options?.reload) { if (options?.reload) {
const oldDb = this._db;
this.init(); this.init();
await oldDb.close();
} }
await this.emitAsync('beforeLoad', this, options); await this.emitAsync('beforeLoad', this, options);
await this.pm.load(options); await this.pm.load(options);
await this.emitAsync('afterLoad', this, options); await this.emitAsync('afterLoad', this, options);

View File

@ -11,6 +11,7 @@ describe('collections repository', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');
}); });

View File

@ -57,12 +57,14 @@ export class CollectionModel extends MagicAttributeModel {
if (!collection) { if (!collection) {
return; return;
} }
const fields = await this.db.getRepository('fields').find({ const fields = await this.db.getRepository('fields').find({
filter: { filter: {
'type.$in': ['belongsToMany', 'belongsTo', 'hasMany', 'hasOne'], 'type.$in': ['belongsToMany', 'belongsTo', 'hasMany', 'hasOne'],
}, },
transaction, transaction,
}); });
for (const field of fields) { for (const field of fields) {
if (field.get('target') && field.get('target') === name) { if (field.get('target') && field.get('target') === name) {
await field.destroy({ transaction }); await field.destroy({ transaction });

View File

@ -13,12 +13,12 @@ import {
beforeCreateForChildrenCollection, beforeCreateForChildrenCollection,
beforeCreateForReverseField, beforeCreateForReverseField,
beforeDestroyForeignKey, beforeDestroyForeignKey,
beforeInitOptions beforeInitOptions,
} from './hooks'; } from './hooks';
import { CollectionModel, FieldModel } from './models'; import { CollectionModel, FieldModel } from './models';
export class CollectionManagerPlugin extends Plugin { export class CollectionManagerPlugin extends Plugin {
async beforeLoad() { async beforeLoad() {
this.app.db.registerModels({ this.app.db.registerModels({
CollectionModel, CollectionModel,
@ -145,42 +145,6 @@ export class CollectionManagerPlugin extends Plugin {
await next(); await next();
}); });
// this.app.resourcer.use(async (ctx, next) => {
// const { resourceName, actionName } = ctx.action;
// if (actionName === 'update') {
// const { updateAssociationValues = [] } = ctx.action.params;
// const [collectionName, associationName] = resourceName.split('.');
// if (!associationName) {
// const collection: Collection = ctx.db.getCollection(collectionName);
// if (collection) {
// for (const [, field] of collection.fields) {
// if (['subTable', 'o2m'].includes(field.options.interface)) {
// updateAssociationValues.push(field.name);
// }
// }
// }
// } else {
// const association = ctx.db.getCollection(collectionName)?.getField?.(associationName);
// if (association?.target) {
// const collection: Collection = ctx.db.getCollection(association?.target);
// if (collection) {
// for (const [, field] of collection.fields) {
// if (['subTable', 'o2m'].includes(field.options.interface)) {
// updateAssociationValues.push(field.name);
// }
// }
// }
// }
// }
// if (updateAssociationValues.length) {
// ctx.action.mergeParams({
// updateAssociationValues,
// });
// }
// }
// await next();
// });
this.app.acl.allow('collections', 'list', 'loggedIn'); this.app.acl.allow('collections', 'list', 'loggedIn');
this.app.acl.allow('collections', ['create', 'update', 'destroy'], 'allowConfigure'); this.app.acl.allow('collections', ['create', 'update', 'destroy'], 'allowConfigure');
} }

View File

@ -7,7 +7,6 @@ import enUS from './locale/en_US';
import zhCN from './locale/zh_CN'; import zhCN from './locale/zh_CN';
export class PluginErrorHandler extends Plugin { export class PluginErrorHandler extends Plugin {
errorHandler: ErrorHandler = new ErrorHandler(); errorHandler: ErrorHandler = new ErrorHandler();
i18nNs: string = 'error-handler'; i18nNs: string = 'error-handler';
@ -46,6 +45,7 @@ export class PluginErrorHandler extends Plugin {
}, },
); );
} }
async load() { async load() {
this.app.i18n.addResources('zh-CN', this.i18nNs, zhCN); this.app.i18n.addResources('zh-CN', this.i18nNs, zhCN);
this.app.i18n.addResources('en-US', this.i18nNs, enUS); this.app.i18n.addResources('en-US', this.i18nNs, enUS);

View File

@ -3,7 +3,6 @@ import path from 'path';
import { getApp } from '.'; import { getApp } from '.';
import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants'; import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants';
const { LOCAL_STORAGE_BASE_URL, APP_PORT = '13000' } = process.env; const { LOCAL_STORAGE_BASE_URL, APP_PORT = '13000' } = process.env;
const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `http://localhost:${APP_PORT}/uploads`; const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `http://localhost:${APP_PORT}/uploads`;
@ -73,7 +72,7 @@ describe('action', () => {
const { documentRoot = 'uploads' } = storage.options || {}; const { documentRoot = 'uploads' } = storage.options || {};
const destPath = path.resolve( const destPath = path.resolve(
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.env.PWD, documentRoot), path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
storage.path, storage.path,
); );
const file = await fs.readFile(`${destPath}/${attachment.filename}`); const file = await fs.readFile(`${destPath}/${attachment.filename}`);

View File

@ -89,8 +89,11 @@ describe('test with start', () => {
let app = mockServer(); let app = mockServer();
await app.cleanDb(); await app.cleanDb();
app.plugin(PluginMultiAppManager); app.plugin(PluginMultiAppManager);
await app.loadAndInstall(); await app.loadAndInstall();
await app.start(); await app.start();
@ -139,6 +142,7 @@ describe('test with start', () => {
await newApp.appManager.applications.get(name).destroy(); await newApp.appManager.applications.get(name).destroy();
await newApp.destroy();
await app.destroy(); await app.destroy();
}); });
}); });

View File

@ -9,11 +9,12 @@ export interface registerAppOptions extends Transactionable {
export class ApplicationModel extends Model { export class ApplicationModel extends Model {
static getDatabaseConfig(app: Application): IDatabaseOptions { static getDatabaseConfig(app: Application): IDatabaseOptions {
return lodash.cloneDeep( const oldConfig =
lodash.isPlainObject(app.options.database) app.options.database instanceof Database
? (app.options.database as IDatabaseOptions) ? (app.options.database as Database).options
: (app.options.database as Database).options, : (app.options.database as IDatabaseOptions);
);
return lodash.cloneDeep(lodash.omit(oldConfig, ['migrator']));
} }
static async handleAppStart(app: Application, options: registerAppOptions) { static async handleAppStart(app: Application, options: registerAppOptions) {