From 9f5f2d60281673626d823686ba391b6c8eced8a8 Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Mon, 31 Oct 2022 22:45:39 +0800 Subject: [PATCH] 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 --- packages/core/actions/src/__tests__/index.ts | 10 +- packages/core/actions/src/actions/create.ts | 1 - .../src/collection-manager/interfaces/m2o.tsx | 3 +- .../src/collection-manager/interfaces/o2m.tsx | 3 +- .../src/collection-manager/interfaces/o2o.tsx | 4 +- .../interfaces/properties/index.ts | 17 +++ .../__tests__/fields/belongs-to-field.test.ts | 116 +++++++++++++++++- .../__tests__/fields/has-many-field.test.ts | 83 +++++++++++++ packages/core/database/src/collection.ts | 2 + packages/core/database/src/database.ts | 25 +++- .../database/src/features/ReferencesMap.ts | 64 ++++++++++ .../features/referential-integrity-check.ts | 61 +++++++++ .../database/src/fields/belongs-to-field.ts | 22 +++- .../database/src/fields/has-many-field.ts | 27 +++- .../core/database/src/fields/has-one-field.ts | 24 +++- packages/core/database/src/repository.ts | 1 + packages/core/server/src/application.ts | 22 ++-- .../src/__tests__/remove-collection.test.ts | 1 + .../src/models/collection.ts | 2 + .../plugins/collection-manager/src/server.ts | 40 +----- packages/plugins/error-handler/src/server.ts | 2 +- .../src/server/__tests__/action.test.ts | 3 +- .../src/__tests__/mock-get-schema.test.ts | 4 + .../src/models/application.ts | 11 +- 24 files changed, 467 insertions(+), 81 deletions(-) create mode 100644 packages/core/database/src/features/ReferencesMap.ts create mode 100644 packages/core/database/src/features/referential-integrity-check.ts diff --git a/packages/core/actions/src/__tests__/index.ts b/packages/core/actions/src/__tests__/index.ts index 5e4ec4125..aafb435b4 100644 --- a/packages/core/actions/src/__tests__/index.ts +++ b/packages/core/actions/src/__tests__/index.ts @@ -6,16 +6,10 @@ import bodyParser from 'koa-bodyparser'; import qs from 'qs'; import supertest, { SuperAgentTest } from 'supertest'; import db2resource from '../../../server/src/middlewares/db2resource'; +import { uid } from '@nocobase/utils'; export function generatePrefixByPath() { - const { id } = require.main; - const key = id - .replace(`${process.env.PWD}/packages`, '') - .replace(/src\/__tests__/g, '') - .replace('.test.ts', '') - .replace(/[^\w]/g, '_') - .replace(/_+/g, '_'); - return key; + return `mock_${uid(6)}`; } export function getConfig(config = {}, options?: any): DatabaseOptions { diff --git a/packages/core/actions/src/actions/create.ts b/packages/core/actions/src/actions/create.ts index 2a08776de..d17bd904c 100644 --- a/packages/core/actions/src/actions/create.ts +++ b/packages/core/actions/src/actions/create.ts @@ -14,6 +14,5 @@ export async function create(ctx: Context, next) { }); ctx.body = instance; - await next(); } diff --git a/packages/core/client/src/collection-manager/interfaces/m2o.tsx b/packages/core/client/src/collection-manager/interfaces/m2o.tsx index da547c4d4..774cb8e8f 100644 --- a/packages/core/client/src/collection-manager/interfaces/m2o.tsx +++ b/packages/core/client/src/collection-manager/interfaces/m2o.tsx @@ -1,6 +1,6 @@ import { ISchema } from '@formily/react'; import { cloneDeep } from 'lodash'; -import { recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; +import { constraintsProps, recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; import { IField } from './types'; export const m2o: IField = { @@ -203,6 +203,7 @@ export const m2o: IField = { }, }, }, + ...constraintsProps, ...reverseFieldProperties, }, filterable: { diff --git a/packages/core/client/src/collection-manager/interfaces/o2m.tsx b/packages/core/client/src/collection-manager/interfaces/o2m.tsx index f2387e341..fd5f80271 100644 --- a/packages/core/client/src/collection-manager/interfaces/o2m.tsx +++ b/packages/core/client/src/collection-manager/interfaces/o2m.tsx @@ -1,6 +1,6 @@ import { ISchema } from '@formily/react'; import { cloneDeep } from 'lodash'; -import { recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; +import { constraintsProps, recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; import { IField } from './types'; export const o2m: IField = { @@ -240,6 +240,7 @@ export const o2m: IField = { }, }, }, + ...constraintsProps, ...reverseFieldProperties, }, filterable: { diff --git a/packages/core/client/src/collection-manager/interfaces/o2o.tsx b/packages/core/client/src/collection-manager/interfaces/o2o.tsx index a926ddf5c..78705303e 100644 --- a/packages/core/client/src/collection-manager/interfaces/o2o.tsx +++ b/packages/core/client/src/collection-manager/interfaces/o2o.tsx @@ -1,6 +1,6 @@ import { ISchema } from '@formily/react'; import { cloneDeep } from 'lodash'; -import { recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; +import { constraintsProps, recordPickerSelector, recordPickerViewer, relationshipType, reverseFieldProperties } from './properties'; import { IField } from './types'; const internalSchameInitialize = (schema: ISchema, { field, block, readPretty, action }) => { @@ -382,6 +382,7 @@ export const oho: IField = { }, }, }, + ...constraintsProps, ...reverseFieldProperties, }, filterable: { @@ -548,6 +549,7 @@ export const obo: IField = { }, }, }, + ...constraintsProps, ...reverseFieldProperties, }, filterable: { diff --git a/packages/core/client/src/collection-manager/interfaces/properties/index.ts b/packages/core/client/src/collection-manager/interfaces/properties/index.ts index d24801a09..4013acc77 100644 --- a/packages/core/client/src/collection-manager/interfaces/properties/index.ts +++ b/packages/core/client/src/collection-manager/interfaces/properties/index.ts @@ -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 = { reverse: { type: 'void', diff --git a/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts b/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts index a0b3326a7..6542deeae 100644 --- a/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts +++ b/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts @@ -64,10 +64,13 @@ describe('belongs to field', () => { }); it('custom targetKey and foreignKey', async () => { - const Post = db.collection({ - name: 'posts', - fields: [{ type: 'string', name: 'key', unique: true }], + db.collection({ + name: "posts", + fields: [ + { type: "string", name: "key" }, + ] }); + const Comment = db.collection({ name: 'comments', fields: [ @@ -79,6 +82,7 @@ describe('belongs to field', () => { }, ], }); + const association = Comment.model.associations.post; expect(association).toBeDefined(); expect(association.foreignKey).toBe('postKey'); @@ -99,7 +103,7 @@ describe('belongs to field', () => { let error; try { - const Comment = db.collection({ + db.collection({ name: 'comments1', fields: [ { @@ -114,6 +118,7 @@ describe('belongs to field', () => { error = e; } + expect(error).toBeInstanceOf(IdentifierError); }); @@ -194,4 +199,107 @@ describe('belongs to field', () => { const association = Post.model.associations; 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(); + }); + }); }); diff --git a/packages/core/database/src/__tests__/fields/has-many-field.test.ts b/packages/core/database/src/__tests__/fields/has-many-field.test.ts index 4a3c23c56..c055f521c 100644 --- a/packages/core/database/src/__tests__/fields/has-many-field.test.ts +++ b/packages/core/database/src/__tests__/fields/has-many-field.test.ts @@ -171,4 +171,87 @@ describe('has many field', () => { 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(); + }); + }); }); diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index db6248236..d4beb5504 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -74,6 +74,8 @@ export class Collection< this.bindFieldEventListener(); this.modelInit(); + this.db.modelCollection.set(this.model, this); + this.setFields(options.fields); this.setRepository(options.repository); this.setSortable(options.sortable); diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index bae45f1f9..07695e895 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -14,7 +14,7 @@ import { Sequelize, SyncOptions, Transactionable, - Utils + Utils, } from 'sequelize'; import { SequelizeStorage, Umzug } from 'umzug'; import { Collection, CollectionOptions, RepositoryType } from './collection'; @@ -52,8 +52,10 @@ import { SyncListener, UpdateListener, UpdateWithAssociationsListener, - ValidateListener + ValidateListener, } from './types'; +import { referentialIntegrityCheck } from './features/referential-integrity-check'; +import ReferencesMap from './features/ReferencesMap'; export interface MergeOptions extends merge.Options {} @@ -146,6 +148,7 @@ export class Database extends EventEmitter implements AsyncEmitter { collections = new Map(); pendingFields = new Map(); modelCollection = new Map, Collection>(); + referenceMap = new ReferencesMap(); modelHook: ModelHook; version: DatabaseVersion; @@ -235,6 +238,10 @@ export class Database extends EventEmitter implements AsyncEmitter { } }); + this.initListener(); + } + + initListener() { this.on('afterCreate', async (instance) => { instance?.toChangedWithAssociations?.(); }); @@ -242,6 +249,14 @@ export class Database extends EventEmitter implements AsyncEmitter { this.on('afterUpdate', async (instance) => { instance?.toChangedWithAssociations?.(); }); + + this.on('beforeDestroy', async (instance, options) => { + await referentialIntegrityCheck({ + db: this, + referencedInstance: instance, + transaction: options.transaction, + }); + }); } addMigration(item: MigrationItem) { @@ -283,7 +298,6 @@ export class Database extends EventEmitter implements AsyncEmitter { }); this.collections.set(collection.name, collection); - this.modelCollection.set(collection.model, collection); this.emit('afterDefineCollection', collection); @@ -497,7 +511,10 @@ export class Database extends EventEmitter implements AsyncEmitter { on(event: ModelSaveWithAssociationsEventTypes, listener: SaveWithAssociationsListener): this; on(event: DatabaseBeforeDefineCollectionEventType, listener: BeforeDefineCollectionListener): 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 { // NOTE: to match if event is a sequelize or model type const type = this.modelHook.match(event); diff --git a/packages/core/database/src/features/ReferencesMap.ts b/packages/core/database/src/features/ReferencesMap.ts new file mode 100644 index 000000000..44ff484e1 --- /dev/null +++ b/packages/core/database/src/features/ReferencesMap.ts @@ -0,0 +1,64 @@ +export interface Reference { + sourceCollectionName: string; + sourceField: string; + targetField: string; + targetCollectionName: string; + onDelete: string; +} + +class ReferencesMap { + protected map: Map = 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; diff --git a/packages/core/database/src/features/referential-integrity-check.ts b/packages/core/database/src/features/referential-integrity-check.ts new file mode 100644 index 000000000..bffc628af --- /dev/null +++ b/packages/core/database/src/features/referential-integrity-check.ts @@ -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, + }); + } + } +} diff --git a/packages/core/database/src/fields/belongs-to-field.ts b/packages/core/database/src/fields/belongs-to-field.ts index 027d6d083..28c7f5ee0 100644 --- a/packages/core/database/src/fields/belongs-to-field.ts +++ b/packages/core/database/src/fields/belongs-to-field.ts @@ -2,6 +2,7 @@ import { omit } from 'lodash'; import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; import { checkIdentifier } from '../utils'; import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { Reference } from '../features/ReferencesMap'; export class BelongsToField extends RelationField { static type = 'belongsTo'; @@ -11,6 +12,18 @@ export class BelongsToField extends RelationField { 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() { const { database, collection } = this.context; const Target = this.TargetModel; @@ -30,7 +43,7 @@ export class BelongsToField extends RelationField { const association = collection.model.belongsTo(Target, { as: this.name, constraints: false, - ...omit(this.options, ['name', 'type', 'target']), + ...omit(this.options, ['name', 'type', 'target', 'onDelete']), }); // inverse relation @@ -56,6 +69,9 @@ export class BelongsToField extends RelationField { } this.collection.addIndex([this.options.foreignKey]); + + this.database.referenceMap.addReference(this.reference(association)); + return true; } @@ -73,6 +89,10 @@ export class BelongsToField extends RelationField { if (!field1 && !field2) { collection.model.removeAttribute(foreignKey); } + + const association = collection.model.associations[this.name]; + this.database.referenceMap.removeReference(this.reference(association)); + // 删掉 model 的关联字段 delete collection.model.associations[this.name]; // @ts-ignore diff --git a/packages/core/database/src/fields/has-many-field.ts b/packages/core/database/src/fields/has-many-field.ts index 6a3dcd449..5fb345581 100644 --- a/packages/core/database/src/fields/has-many-field.ts +++ b/packages/core/database/src/fields/has-many-field.ts @@ -5,11 +5,12 @@ import { ForeignKeyOptions, HasManyOptions, HasManyOptions as SequelizeHasManyOptions, - Utils + Utils, } from 'sequelize'; import { Collection } from '../collection'; import { checkIdentifier } from '../utils'; import { MultipleRelationFieldOptions, RelationField } from './relation-field'; +import { Reference } from '../features/ReferencesMap'; 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('_')); } + 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() { const { database, collection } = this.context; const Target = this.TargetModel; @@ -95,7 +108,7 @@ export class HasManyField extends RelationField { const association = collection.model.hasMany(Target, { constraints: false, - ...omit(this.options, ['name', 'type', 'target']), + ...omit(this.options, ['name', 'type', 'target', 'onDelete']), as: this.name, foreignKey: this.foreignKey, }); @@ -130,6 +143,9 @@ export class HasManyField extends RelationField { if (tcoll) { tcoll.addIndex([this.options.foreignKey]); } + + this.database.referenceMap.addReference(this.reference(association)); + return true; } @@ -149,6 +165,13 @@ export class HasManyField extends RelationField { if (!field) { tcoll.model.removeAttribute(foreignKey); } + + const association = collection.model.associations[this.name]; + + if (association) { + this.database.referenceMap.removeReference(this.reference(association)); + } + // 删掉 model 的关联字段 delete collection.model.associations[this.name]; // @ts-ignore diff --git a/packages/core/database/src/fields/has-one-field.ts b/packages/core/database/src/fields/has-one-field.ts index 6d4a0a879..4c2986ce2 100644 --- a/packages/core/database/src/fields/has-one-field.ts +++ b/packages/core/database/src/fields/has-one-field.ts @@ -5,11 +5,12 @@ import { ForeignKeyOptions, HasOneOptions, HasOneOptions as SequelizeHasOneOptions, - Utils + Utils, } from 'sequelize'; import { Collection } from '../collection'; import { checkIdentifier } from '../utils'; import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { Reference } from '../features/ReferencesMap'; export interface HasOneFieldOptions extends HasOneOptions { /** @@ -86,9 +87,22 @@ export class HasOneField extends RelationField { 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() { const { database, collection } = this.context; const Target = this.TargetModel; + if (!Target) { database.addPendingField(this); return false; @@ -96,7 +110,7 @@ export class HasOneField extends RelationField { const association = collection.model.hasOne(Target, { constraints: false, - ...omit(this.options, ['name', 'type', 'target']), + ...omit(this.options, ['name', 'type', 'target', 'onDelete']), as: this.name, foreignKey: this.foreignKey, }); @@ -129,6 +143,8 @@ export class HasOneField extends RelationField { if (tcoll) { tcoll.addIndex([this.options.foreignKey]); } + + this.database.referenceMap.addReference(this.reference(association)); return true; } @@ -148,6 +164,10 @@ export class HasOneField extends RelationField { if (!field) { tcoll.model.removeAttribute(foreignKey); } + + const association = collection.model.associations[this.name]; + this.database.referenceMap.removeReference(this.reference(association)); + // 删掉 model 的关联字段 delete collection.model.associations[this.name]; // @ts-ignore diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index 224c22f54..8d4240d04 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -338,6 +338,7 @@ export class Repository(values, { ...options, transaction, diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 5be6a87d2..f13d4b180 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -210,11 +210,14 @@ export class Application exten this.middleware = new Toposort(); this.plugins = new Map(); this._acl = createACL(); + if (this._db) { // MaxListenersExceededWarning this._db.removeAllListeners(); } + this._db = this.createDatabase(options); + this._resourcer = createResourcer(options); this._cli = new Command('nocobase').usage('[command] [options]'); this._i18n = createI18n(options); @@ -250,16 +253,12 @@ export class Application exten } private createDatabase(options: ApplicationOptions) { - if (options.database instanceof Database) { - return options.database; - } else { - return new Database({ - ...options.database, - migrator: { - context: { app: this }, - }, - }); - } + return new Database({ + ...(options.database instanceof Database ? options.database.options : options.database), + migrator: { + context: { app: this }, + }, + }); } getVersion() { @@ -315,8 +314,11 @@ export class Application exten async load(options?: any) { if (options?.reload) { + const oldDb = this._db; this.init(); + await oldDb.close(); } + await this.emitAsync('beforeLoad', this, options); await this.pm.load(options); await this.emitAsync('afterLoad', this, options); diff --git a/packages/plugins/collection-manager/src/__tests__/remove-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/remove-collection.test.ts index 49292d654..a00f5af25 100644 --- a/packages/plugins/collection-manager/src/__tests__/remove-collection.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/remove-collection.test.ts @@ -11,6 +11,7 @@ describe('collections repository', () => { beforeEach(async () => { app = await createApp(); db = app.db; + Collection = db.getCollection('collections'); Field = db.getCollection('fields'); }); diff --git a/packages/plugins/collection-manager/src/models/collection.ts b/packages/plugins/collection-manager/src/models/collection.ts index 4ab0245be..029344c43 100644 --- a/packages/plugins/collection-manager/src/models/collection.ts +++ b/packages/plugins/collection-manager/src/models/collection.ts @@ -57,12 +57,14 @@ export class CollectionModel extends MagicAttributeModel { if (!collection) { return; } + const fields = await this.db.getRepository('fields').find({ filter: { 'type.$in': ['belongsToMany', 'belongsTo', 'hasMany', 'hasOne'], }, transaction, }); + for (const field of fields) { if (field.get('target') && field.get('target') === name) { await field.destroy({ transaction }); diff --git a/packages/plugins/collection-manager/src/server.ts b/packages/plugins/collection-manager/src/server.ts index 742df3aba..04376c8c6 100644 --- a/packages/plugins/collection-manager/src/server.ts +++ b/packages/plugins/collection-manager/src/server.ts @@ -13,12 +13,12 @@ import { beforeCreateForChildrenCollection, beforeCreateForReverseField, beforeDestroyForeignKey, - beforeInitOptions + beforeInitOptions, } from './hooks'; + import { CollectionModel, FieldModel } from './models'; export class CollectionManagerPlugin extends Plugin { - async beforeLoad() { this.app.db.registerModels({ CollectionModel, @@ -145,42 +145,6 @@ export class CollectionManagerPlugin extends Plugin { 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', ['create', 'update', 'destroy'], 'allowConfigure'); } diff --git a/packages/plugins/error-handler/src/server.ts b/packages/plugins/error-handler/src/server.ts index d8df76ec2..ac3d75121 100644 --- a/packages/plugins/error-handler/src/server.ts +++ b/packages/plugins/error-handler/src/server.ts @@ -7,7 +7,6 @@ import enUS from './locale/en_US'; import zhCN from './locale/zh_CN'; export class PluginErrorHandler extends Plugin { - errorHandler: ErrorHandler = new ErrorHandler(); i18nNs: string = 'error-handler'; @@ -46,6 +45,7 @@ export class PluginErrorHandler extends Plugin { }, ); } + async load() { this.app.i18n.addResources('zh-CN', this.i18nNs, zhCN); this.app.i18n.addResources('en-US', this.i18nNs, enUS); diff --git a/packages/plugins/file-manager/src/server/__tests__/action.test.ts b/packages/plugins/file-manager/src/server/__tests__/action.test.ts index 22cded215..aff86cc18 100644 --- a/packages/plugins/file-manager/src/server/__tests__/action.test.ts +++ b/packages/plugins/file-manager/src/server/__tests__/action.test.ts @@ -3,7 +3,6 @@ import path from 'path'; import { getApp } from '.'; import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../constants'; - const { LOCAL_STORAGE_BASE_URL, APP_PORT = '13000' } = process.env; 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 destPath = path.resolve( - path.isAbsolute(documentRoot) ? documentRoot : path.join(process.env.PWD, documentRoot), + path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot), storage.path, ); const file = await fs.readFile(`${destPath}/${attachment.filename}`); diff --git a/packages/plugins/multi-app-manager/src/__tests__/mock-get-schema.test.ts b/packages/plugins/multi-app-manager/src/__tests__/mock-get-schema.test.ts index 1f618ec82..bbe1e865d 100644 --- a/packages/plugins/multi-app-manager/src/__tests__/mock-get-schema.test.ts +++ b/packages/plugins/multi-app-manager/src/__tests__/mock-get-schema.test.ts @@ -89,8 +89,11 @@ describe('test with start', () => { let app = mockServer(); await app.cleanDb(); + + app.plugin(PluginMultiAppManager); + await app.loadAndInstall(); await app.start(); @@ -139,6 +142,7 @@ describe('test with start', () => { await newApp.appManager.applications.get(name).destroy(); + await newApp.destroy(); await app.destroy(); }); }); diff --git a/packages/plugins/multi-app-manager/src/models/application.ts b/packages/plugins/multi-app-manager/src/models/application.ts index 00383ff37..80d983847 100644 --- a/packages/plugins/multi-app-manager/src/models/application.ts +++ b/packages/plugins/multi-app-manager/src/models/application.ts @@ -9,11 +9,12 @@ export interface registerAppOptions extends Transactionable { export class ApplicationModel extends Model { static getDatabaseConfig(app: Application): IDatabaseOptions { - return lodash.cloneDeep( - lodash.isPlainObject(app.options.database) - ? (app.options.database as IDatabaseOptions) - : (app.options.database as Database).options, - ); + const oldConfig = + app.options.database instanceof Database + ? (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) {