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:
parent
67f3c84d27
commit
9f5f2d6028
@ -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 {
|
||||
|
@ -14,6 +14,5 @@ export async function create(ctx: Context, next) {
|
||||
});
|
||||
|
||||
ctx.body = instance;
|
||||
|
||||
await next();
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
64
packages/core/database/src/features/ReferencesMap.ts
Normal file
64
packages/core/database/src/features/ReferencesMap.ts
Normal 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;
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -11,6 +11,7 @@ describe('collections repository', () => {
|
||||
beforeEach(async () => {
|
||||
app = await createApp();
|
||||
db = app.db;
|
||||
|
||||
Collection = db.getCollection('collections');
|
||||
Field = db.getCollection('fields');
|
||||
});
|
||||
|
@ -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 });
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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}`);
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user