diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index 21e241f58..c2330a02c 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -285,7 +285,9 @@ export class Collection< const [sourceCollectionName, sourceFieldName] = options.source.split('.'); const sourceCollection = this.db.collections.get(sourceCollectionName); if (!sourceCollection) { - throw new Error(`source collection "${sourceCollectionName}" not found`); + throw new Error( + `source collection "${sourceCollectionName}" not found for field "${name}" at collection "${this.name}"`, + ); } const sourceField = sourceCollection.fields.get(sourceFieldName); options = { ...sourceField.options, ...options }; diff --git a/packages/plugins/collection-manager/src/__tests__/fields/belongs-to-many.test.ts b/packages/plugins/collection-manager/src/__tests__/fields/belongs-to-many.test.ts index bbfda25df..5102165e6 100644 --- a/packages/plugins/collection-manager/src/__tests__/fields/belongs-to-many.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/fields/belongs-to-many.test.ts @@ -16,6 +16,7 @@ describe('belongsToMany', () => { await Collection.repository.create({ values: { name: 'posts', + fields: [{ type: 'string', name: 'title' }], }, context: {}, }); @@ -23,6 +24,7 @@ describe('belongsToMany', () => { await Collection.repository.create({ values: { name: 'tags', + fields: [{ type: 'string', name: 'name' }], }, context: {}, }); @@ -76,4 +78,47 @@ describe('belongsToMany', () => { expect(await tableExists(tableName, mainSchema)).toBe(false); } }); + + it('should belongs to many fields after through collection destroyed', async () => { + await Field.repository.create({ + values: { + name: 'tags', + type: 'belongsToMany', + collectionName: 'posts', + interface: 'm2m', + through: 'post_tags', + }, + context: {}, + }); + + const throughCollection = await Collection.repository.findOne({ + filter: { + name: 'post_tags', + }, + }); + + await db.getRepository('posts').create({ + values: [ + { + title: 'p1', + tags: [{ name: 't1' }], + }, + { + title: 'p2', + tags: [{ name: 't2' }], + }, + ], + }); + + await throughCollection.destroy(); + + expect( + await Field.repository.count({ + filter: { + name: 'tags', + collectionName: 'posts', + }, + }), + ).toEqual(0); + }); }); diff --git a/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts index 35c568837..574155e1d 100644 --- a/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts @@ -28,6 +28,117 @@ describe('view collection', function () { await app.destroy(); }); + it('should use view collection as through collection', async () => { + const User = await collectionRepository.create({ + values: { + name: 'users', + fields: [{ name: 'name', type: 'string' }], + }, + context: {}, + }); + + const Role = await collectionRepository.create({ + values: { + name: 'roles', + fields: [{ name: 'name', type: 'string' }], + }, + context: {}, + }); + + const UserCollection = db.getCollection('users'); + + console.log(UserCollection); + + await db.getRepository('users').create({ + values: [{ name: 'u1' }, { name: 'u2' }], + }); + + await db.getRepository('roles').create({ + values: [{ name: 'r1' }, { name: 'r2' }], + }); + + await collectionRepository.create({ + values: { + name: 'user_roles', + fields: [ + { type: 'integer', name: 'user_id' }, + { type: 'integer', name: 'role_id' }, + ], + }, + context: {}, + }); + + const throughCollection = db.getCollection('user_roles'); + + await throughCollection.repository.create({ + values: [ + { user_id: 1, role_id: 1 }, + { user_id: 1, role_id: 2 }, + { user_id: 2, role_id: 1 }, + ], + }); + + const viewName = 'test_view'; + const dropViewSQL = `DROP VIEW IF EXISTS test_view`; + await db.sequelize.query(dropViewSQL); + + const viewSQL = `CREATE VIEW test_view AS select * from ${throughCollection.quotedTableName()}`; + + await db.sequelize.query(viewSQL); + await collectionRepository.create({ + values: { + name: `${viewName}`, + view: true, + viewName, + fields: [ + { type: 'integer', name: 'user_id' }, + { type: 'integer', name: 'role_id' }, + ], + schema: db.inDialect('postgres') ? 'public' : undefined, + }, + context: {}, + }); + + await fieldsRepository.create({ + values: { + collectionName: 'users', + name: 'roles', + type: 'belongsToMany', + target: 'roles', + through: 'test_view', + foreignKey: 'user_id', + otherKey: 'role_id', + }, + context: {}, + }); + + const users = await db.getRepository('users').find({ + appends: ['roles'], + filter: { + name: 'u1', + }, + }); + + const roles = users[0].get('roles'); + expect(roles).toHaveLength(2); + + await collectionRepository.destroy({ + filter: { + name: 'test_view', + }, + context: {}, + }); + + expect( + await fieldsRepository.count({ + filter: { + collectionName: 'users', + name: 'roles', + }, + }), + ).toEqual(0); + }); + it('should save view collection in difference schema', async () => { if (!db.inDialect('postgres')) { return; diff --git a/packages/plugins/collection-manager/src/repositories/collection-repository.ts b/packages/plugins/collection-manager/src/repositories/collection-repository.ts index bd378e47a..018ae6fc2 100644 --- a/packages/plugins/collection-manager/src/repositories/collection-repository.ts +++ b/packages/plugins/collection-manager/src/repositories/collection-repository.ts @@ -1,7 +1,6 @@ import { Repository } from '@nocobase/database'; import { CollectionModel } from '../models/collection'; -import toposort from 'toposort'; -import lodash from 'lodash'; +import { CollectionsGraph } from '@nocobase/utils'; interface LoadOptions { filter?: any; @@ -13,40 +12,62 @@ export class CollectionRepository extends Repository { const { filter, skipExist } = options; const instances = (await this.find({ filter })) as CollectionModel[]; - const inheritedGraph = []; - const throughModels = []; - const generalModels = []; + const graphlib = CollectionsGraph.graphlib(); + + const graph = new graphlib.Graph(); const nameMap = {}; + const viewCollections = []; + for (const instance of instances) { - nameMap[instance.get('name')] = instance; + graph.setNode(instance.get('name')); + if (instance.get('view')) { + viewCollections.push(instance.get('name')); + } + } + + for (const instance of instances) { + const collectionName = instance.get('name'); + + nameMap[collectionName] = instance; + // @ts-ignore const fields = await instance.getFields(); for (const field of fields) { if (field['type'] === 'belongsToMany') { const throughName = field.options.through; if (throughName) { - inheritedGraph.push([throughName, field.options.target]); - inheritedGraph.push([throughName, instance.get('name')]); + graph.setEdge(throughName, collectionName); + graph.setEdge(throughName, field.options.target); } } } if (instance.get('inherits')) { for (const parent of instance.get('inherits')) { - inheritedGraph.push([parent, instance.get('name')]); + graph.setEdge(parent, collectionName); } - } else { - generalModels.push(instance.get('name')); } } - const sortedNames = [...toposort(inheritedGraph), ...lodash.difference(generalModels, throughModels)]; + if (graph.nodeCount() === 0) return; - for (const instanceName of lodash.uniq(sortedNames)) { + if (!graphlib.alg.isAcyclic(graph)) { + const cycles = graphlib.alg.findCycles(graph); + throw new Error(`Cyclic dependencies: ${cycles.map((cycle) => cycle.join(' -> ')).join(', ')}`); + } + + const sortedNames = graphlib.alg.topsort(graph); + + for (const instanceName of sortedNames) { if (!nameMap[instanceName]) continue; - await nameMap[instanceName].load({ skipExist }); + await nameMap[instanceName].load({ skipExist, skipField: viewCollections.includes(instanceName) }); + } + + // load view fields + for (const viewCollectionName of viewCollections) { + await nameMap[viewCollectionName].loadFields({}); } }