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 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 {

View File

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

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

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> = {
reverse: {
type: 'void',

View File

@ -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();
});
});
});

View File

@ -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();
});
});
});

View File

@ -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);

View File

@ -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<string, Collection>();
pendingFields = new Map<string, RelationField[]>();
modelCollection = new Map<ModelCtor<any>, 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);

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 { 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

View File

@ -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

View File

@ -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

View File

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

View File

@ -210,11 +210,14 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this.middleware = new Toposort<any>();
this.plugins = new Map<string, Plugin>();
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,17 +253,13 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
private createDatabase(options: ApplicationOptions) {
if (options.database instanceof Database) {
return options.database;
} else {
return new Database({
...options.database,
...(options.database instanceof Database ? options.database.options : options.database),
migrator: {
context: { app: this },
},
});
}
}
getVersion() {
return packageJson.version;
@ -315,8 +314,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> 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);

View File

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

View File

@ -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 });

View File

@ -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');
}

View File

@ -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);

View File

@ -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}`);

View File

@ -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();
});
});

View File

@ -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) {