diff --git a/packages/core/actions/src/__tests__/list-action.test.ts b/packages/core/actions/src/__tests__/list-action.test.ts index 92d84fd462..5bd8a49095 100644 --- a/packages/core/actions/src/__tests__/list-action.test.ts +++ b/packages/core/actions/src/__tests__/list-action.test.ts @@ -147,285 +147,3 @@ describe('list action', () => { expect(response.body.count).toEqual(0); }); }); - -describe('list-tree', () => { - let app; - beforeEach(async () => { - app = actionMockServer(); - registerActions(app); - }); - - afterEach(async () => { - await app.destroy(); - }); - - it('should be tree', async () => { - const values = [ - { - name: '1', - __index: '0', - children: [ - { - name: '1-1', - __index: '0.children.0', - children: [ - { - name: '1-1-1', - __index: '0.children.0.children.0', - children: [ - { - name: '1-1-1-1', - __index: '0.children.0.children.0.children.0', - }, - ], - }, - ], - }, - ], - }, - { - name: '2', - __index: '1', - children: [ - { - name: '2-1', - __index: '1.children.0', - children: [ - { - name: '2-1-1', - __index: '1.children.0.children.0', - children: [ - { - name: '2-1-1-1', - __index: '1.children.0.children.0.children.0', - }, - ], - }, - ], - }, - ], - }, - ]; - - const db = app.db; - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'string', - name: 'description', - }, - { - type: 'belongsTo', - name: 'parent', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - treeChildren: true, - }, - ], - }); - await db.sync(); - - await db.getRepository('categories').create({ - values, - }); - - const response = await app - .agent() - .resource('categories') - .list({ - tree: true, - fields: ['id', 'name'], - sort: ['id'], - }); - - expect(response.status).toEqual(200); - expect(response.body.rows).toMatchObject(values); - }); - - it('should be tree', async () => { - const values = [ - { - name: '1', - __index: '0', - children2: [ - { - name: '1-1', - __index: '0.children2.0', - children2: [ - { - name: '1-1-1', - __index: '0.children2.0.children2.0', - children2: [ - { - name: '1-1-1-1', - __index: '0.children2.0.children2.0.children2.0', - }, - ], - }, - ], - }, - ], - }, - { - name: '2', - __index: '1', - children2: [ - { - name: '2-1', - __index: '1.children2.0', - children2: [ - { - name: '2-1-1', - __index: '1.children2.0.children2.0', - children2: [ - { - name: '2-1-1-1', - __index: '1.children2.0.children2.0.children2.0', - }, - ], - }, - ], - }, - ], - }, - ]; - - const db = app.db; - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'string', - name: 'description', - }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'cid', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children2', - foreignKey: 'cid', - treeChildren: true, - }, - ], - }); - await db.sync(); - - await db.getRepository('categories').create({ - values, - }); - - const response = await app - .agent() - .resource('categories') - .list({ - tree: true, - fields: ['id', 'name'], - sort: ['id'], - }); - - expect(response.status).toEqual(200); - expect(response.body.rows).toMatchObject(values); - }); - - it('should filter child nodes for tree', async () => { - const values = [ - { - name: 'A1', - __index: '0', - children3: [ - { - name: 'B', - __index: '0.children3.0', - children3: [ - { - name: 'C', - __index: '0.children3.0.children3.0', - }, - ], - }, - ], - }, - { - name: 'A2', - __index: '1', - children3: [ - { - name: 'B', - __index: '1.children3.0', - }, - ], - }, - ]; - - const db = app.db; - db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'string', - name: 'description', - }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'cid', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children3', - foreignKey: 'cid', - treeChildren: true, - }, - ], - }); - await db.sync(); - - await db.getRepository('categories').create({ - values, - }); - - const response = await app - .agent() - .resource('categories') - .list({ - tree: true, - fields: ['id', 'name'], - appends: ['parent'], - filter: { - name: 'B', - }, - }); - - expect(response.status).toEqual(200); - const rows = response.body.rows; - expect(rows.length).toEqual(2); - expect(rows[0].name).toEqual('B'); - expect(rows[1].name).toEqual('B'); - expect(rows[0].parent.name).toEqual('A1'); - expect(rows[1].parent.name).toEqual('A2'); - }); -}); diff --git a/packages/core/actions/src/actions/list.ts b/packages/core/actions/src/actions/list.ts index 5e42b26765..93d415b82a 100644 --- a/packages/core/actions/src/actions/list.ts +++ b/packages/core/actions/src/actions/list.ts @@ -17,23 +17,15 @@ function totalPage(total, pageSize): number { } function findArgs(ctx: Context) { - const resourceName = ctx.action.resourceName; const params = ctx.action.params; - if (params.tree) { - if (isValidFilter(params.filter)) { - params.tree = false; - } else { - const [collectionName, associationName] = resourceName.split('.'); - const collection = ctx.db.getCollection(resourceName); - // tree collection 或者关系表是 tree collection - if (collection.options.tree && !(associationName && collectionName === collection.name)) { - const foreignKey = collection.treeParentField?.foreignKey || 'parentId'; - assign(params, { filter: { [foreignKey]: null } }, { filter: 'andMerge' }); - } - } - } - const { tree, fields, filter, appends, except, sort } = params; + const { fields, filter, appends, except, sort } = params; + let { tree } = params; + if (tree === true || tree === 'true') { + tree = true; + } else { + tree = false; + } return { tree, filter, fields, appends, except, sort }; } diff --git a/packages/core/database/src/__tests__/fields/string-field.test.ts b/packages/core/database/src/__tests__/fields/string-field.test.ts index 7d0deba113..0d5acc25d9 100644 --- a/packages/core/database/src/__tests__/fields/string-field.test.ts +++ b/packages/core/database/src/__tests__/fields/string-field.test.ts @@ -22,6 +22,28 @@ describe('string field', () => { await db.close(); }); + it.skipIf(process.env['DB_DIALECT'] === 'sqlite')('should define string with length options', async () => { + const Test = db.collection({ + name: 'tests', + fields: [{ type: 'string', name: 'name', length: 10 }], + }); + await db.sync(); + + let err; + + try { + await Test.repository.create({ + values: { + name: '123456789011', + }, + }); + } catch (e) { + err = e; + } + + expect(err).toBeDefined(); + }); + it('define', async () => { const Test = db.collection({ name: 'tests', diff --git a/packages/core/database/src/__tests__/repository/find.test.ts b/packages/core/database/src/__tests__/repository/find.test.ts index fb8981ae8a..824271b34c 100644 --- a/packages/core/database/src/__tests__/repository/find.test.ts +++ b/packages/core/database/src/__tests__/repository/find.test.ts @@ -498,86 +498,6 @@ describe('find with associations', () => { expect(filterResult[0].get('user').get('department')).toBeDefined(); }); - it('should filter by association field', async () => { - const User = db.collection({ - name: 'users', - tree: 'adjacency-list', - fields: [ - { type: 'string', name: 'name' }, - { type: 'hasMany', name: 'posts', target: 'posts', foreignKey: 'user_id' }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'parent_id', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - foreignKey: 'parent_id', - treeChildren: true, - }, - ], - }); - - const Post = db.collection({ - name: 'posts', - fields: [ - { type: 'string', name: 'title' }, - { type: 'belongsTo', name: 'user', target: 'users', foreignKey: 'user_id' }, - ], - }); - - await db.sync(); - - expect(User.options.tree).toBeTruthy(); - - await User.repository.create({ - values: [ - { - name: 'u1', - posts: [ - { - title: 'u1p1', - }, - ], - children: [ - { - name: 'u2', - posts: [ - { - title: '标题2', - }, - ], - }, - ], - }, - ], - }); - - const filter = { - $and: [ - { - children: { - posts: { - title: { - $eq: '标题2', - }, - }, - }, - }, - ], - }; - - const [findResult, count] = await User.repository.findAndCount({ - filter, - offset: 0, - limit: 20, - }); - - expect(findResult[0].get('name')).toEqual('u1'); - }); - it('should find with associations with sort params', async () => { const User = db.collection({ name: 'users', diff --git a/packages/core/database/src/__tests__/tree.test.ts b/packages/core/database/src/__tests__/tree.test.ts deleted file mode 100644 index 9faeeac1bc..0000000000 --- a/packages/core/database/src/__tests__/tree.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import { Database } from '../database'; -import { AdjacencyListRepository } from '../repositories/tree-repository/adjacency-list-repository'; -import { mockDatabase } from './'; - -describe('tree test', function () { - let db: Database; - - beforeEach(async () => { - db = mockDatabase({ - tablePrefix: '', - }); - await db.clean({ drop: true }); - }); - - afterEach(async () => { - await db.close(); - }); - - it('should works with appends option', async () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { type: 'string', name: 'name' }, - { - type: 'belongsTo', - name: 'parent', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - treeChildren: true, - }, - ], - }); - - await db.sync(); - - await collection.repository.create({ - values: [ - { - name: 'c1', - children: [ - { - name: 'c11', - }, - { - name: 'c12', - }, - ], - }, - { - name: 'c2', - }, - ], - }); - - const tree = await collection.repository.find({ - tree: true, - filter: { - parentId: null, - }, - fields: ['name'], - }); - - expect(tree.length).toBe(2); - }); - - it('should not return children property when child nodes are empty', async () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { type: 'string', name: 'name' }, - { - type: 'belongsTo', - name: 'parent', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - treeChildren: true, - }, - ], - }); - - await db.sync(); - - await collection.repository.create({ - values: [ - { - name: 'c1', - children: [ - { - name: 'c11', - }, - { - name: 'c12', - }, - ], - }, - { - name: 'c2', - }, - ], - }); - - const tree = await collection.repository.find({ - filter: { - parentId: null, - }, - tree: true, - }); - - const c2 = tree.find((item) => item.name === 'c2'); - expect(c2.toJSON()['children']).toBeUndefined(); - - const c11 = tree - .find((item) => item.name === 'c1') - .get('children') - .find((item) => item.name === 'c11'); - - expect(c11.toJSON()['children']).toBeUndefined(); - }); - - it('should add sort field', async () => { - const Tasks = db.collection({ - name: 'tasks', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'belongsTo', - name: 'parent', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - treeChildren: true, - }, - { - type: 'string', - name: 'status', - }, - ], - }); - - await db.sync(); - - await Tasks.repository.create({ - values: { - name: 'task1', - status: 'doing', - }, - }); - - await Tasks.repository.create({ - values: { - name: 'task2', - status: 'pending', - }, - }); - - await Tasks.repository.create({ - values: { - name: 'task3', - status: 'pending', - }, - }); - - Tasks.setField('sort', { type: 'sort', scopeKey: 'status' }); - - await db.sync(); - }); - - it('should be auto completed', () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'belongsTo', - name: 'parent', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - treeChildren: true, - }, - ], - }); - expect(collection.treeChildrenField?.name).toBe('children'); - expect(collection.treeParentField?.name).toBe('parent'); - expect(collection.getField('parent').options.target).toBe('categories'); - expect(collection.getField('parent').options.foreignKey).toBe('parentId'); - expect(collection.getField('children').options.target).toBe('categories'); - expect(collection.getField('children').options.foreignKey).toBe('parentId'); - }); - - it('should be auto completed', () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'cid', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - foreignKey: 'cid', - treeChildren: true, - }, - ], - }); - expect(collection.treeChildrenField?.name).toBe('children'); - expect(collection.treeParentField?.name).toBe('parent'); - expect(collection.getField('parent').options.target).toBe('categories'); - expect(collection.getField('parent').options.foreignKey).toBe('cid'); - expect(collection.getField('children').options.target).toBe('categories'); - expect(collection.getField('children').options.foreignKey).toBe('cid'); - }); - - const values = [ - { - name: '1', - __index: '0', - children: [ - { - name: '1-1', - __index: '0.children.0', - children: [ - { - name: '1-1-1', - __index: '0.children.0.children.0', - children: [ - { - name: '1-1-1-1', - __index: '0.children.0.children.0.children.0', - }, - ], - }, - ], - }, - ], - }, - { - name: '2', - __index: '1', - children: [ - { - name: '2-1', - __index: '1.children.0', - children: [ - { - name: '2-1-1', - __index: '1.children.0.children.0', - children: [ - { - name: '2-1-1-1', - __index: '1.children.0.children.0.children.0', - }, - ], - }, - ], - }, - ], - }, - ]; - - it('should be tree', async () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'string', - name: 'description', - }, - { - type: 'belongsTo', - name: 'parent', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - treeChildren: true, - }, - ], - }); - await db.sync(); - - await db.getRepository('categories').create({ - values, - }); - - const instances = await db.getRepository('categories').find({ - filter: { - parentId: null, - }, - tree: true, - fields: ['id', 'name'], - sort: 'id', - }); - - expect(instances.map((i) => i.toJSON())).toMatchObject(values); - - const instance = await db.getRepository('categories').findOne({ - filterByTk: 1, - tree: true, - fields: ['id', 'name'], - }); - - expect(instance.toJSON()).toMatchObject(values[0]); - }); - - it('should find tree collection', async () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'string', - name: 'description', - }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'cid', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - foreignKey: 'cid', - treeChildren: true, - }, - ], - }); - await db.sync(); - - await db.getRepository('categories').create({ - values, - }); - - const instances = await db.getRepository('categories').find({ - filter: { - cid: null, - }, - tree: true, - fields: ['id', 'name'], - sort: 'id', - }); - - expect(instances.map((i) => i.toJSON())).toMatchObject(values); - - const instance = await db.getRepository('categories').findOne({ - filterByTk: 1, - tree: true, - fields: ['id', 'name'], - }); - - expect(instance.toJSON()).toMatchObject(values[0]); - }); - - it('should get adjacency list repository', async () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'parentId', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - foreignKey: 'parentId', - treeChildren: true, - }, - ], - }); - - const repository = db.getRepository('categories'); - expect(repository).toBeInstanceOf(AdjacencyListRepository); - }); - - test('performance', async () => { - const collection = db.collection({ - name: 'categories', - tree: 'adjacency-list', - fields: [ - { - type: 'string', - name: 'name', - }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'parentId', - treeParent: true, - }, - { - type: 'hasMany', - name: 'children', - foreignKey: 'parentId', - treeChildren: true, - }, - ], - }); - await db.sync(); - - const values = []; - for (let i = 0; i < 10; i++) { - const children = []; - for (let j = 0; j < 10; j++) { - const grandchildren = []; - for (let k = 0; k < 10; k++) { - grandchildren.push({ - name: `name-${i}-${j}-${k}`, - }); - } - children.push({ - name: `name-${i}-${j}`, - children: grandchildren, - }); - } - - values.push({ - name: `name-${i}`, - description: `description-${i}`, - children, - }); - } - - await db.getRepository('categories').create({ - values, - }); - - const before = Date.now(); - - const instances = await db.getRepository('categories').find({ - filter: { - parentId: null, - }, - tree: true, - fields: ['id', 'name'], - sort: 'id', - limit: 10, - }); - - const after = Date.now(); - console.log(after - before); - }); -}); diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index 8b22f153ca..b466c0a46d 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -23,7 +23,6 @@ import { BuiltInGroup } from './collection-group-manager'; import { Database } from './database'; import { BelongsToField, Field, FieldOptions, HasManyField } from './fields'; import { Model } from './model'; -import { AdjacencyListRepository } from './repositories/tree-repository/adjacency-list-repository'; import { Repository } from './repository'; import { checkIdentifier, md5, snakeCase } from './utils'; import safeJsonStringify from 'safe-json-stringify'; @@ -268,11 +267,6 @@ export class Collection< if (typeof repository === 'string') { repo = this.context.database.repositories.get(repository) || Repository; } - - if (this.options.tree == 'adjacency-list' || this.options.tree == 'adjacencyList') { - repo = AdjacencyListRepository; - } - this.repository = new repo(this); } diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 6300eb55bf..a3b813b96b 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -469,6 +469,9 @@ export class Database extends EventEmitter implements AsyncEmitter { options.indexes = options.indexes.map((index) => { if (index.fields) { index.fields = index.fields.map((field) => { + if (field.name) { + return { name: snakeCase(field.name), ...field }; + } return snakeCase(field); }); } diff --git a/packages/core/database/src/eager-loading/eager-loading-tree.ts b/packages/core/database/src/eager-loading/eager-loading-tree.ts index b3d6dc3c30..d0d9fb2eeb 100644 --- a/packages/core/database/src/eager-loading/eager-loading-tree.ts +++ b/packages/core/database/src/eager-loading/eager-loading-tree.ts @@ -12,7 +12,7 @@ import { Association, HasOne, HasOneOptions, Includeable, Model, ModelStatic, Op import Database from '../database'; import { appendChildCollectionNameAfterRepositoryFind } from '../listeners/append-child-collection-name-after-repository-find'; import { OptionsParser } from '../options-parser'; -import { AdjacencyListRepository } from '../repositories/tree-repository/adjacency-list-repository'; +import { Collection } from '../collection'; interface EagerLoadingNode { model: ModelStatic; @@ -55,6 +55,33 @@ const EagerLoadingNodeProto = { }, }; +const queryParentSQL = (options: { + db: Database; + nodeIds: any[]; + collection: Collection; + foreignKey: string; + targetKey: string; +}) => { + const { collection, db, nodeIds } = options; + const tableName = collection.quotedTableName(); + const { foreignKey, targetKey } = options; + const foreignKeyField = collection.model.rawAttributes[foreignKey].field; + const targetKeyField = collection.model.rawAttributes[targetKey].field; + + const queryInterface = db.sequelize.getQueryInterface(); + const q = queryInterface.quoteIdentifier.bind(queryInterface); + return `WITH RECURSIVE cte AS ( + SELECT ${q(targetKeyField)}, ${q(foreignKeyField)} + FROM ${tableName} + WHERE ${q(targetKeyField)} IN (${nodeIds.join(',')}) + UNION ALL + SELECT t.${q(targetKeyField)}, t.${q(foreignKeyField)} + FROM ${tableName} AS t + INNER JOIN cte ON t.${q(targetKeyField)} = cte.${q(foreignKeyField)} + ) + SELECT ${q(targetKeyField)} AS ${q(targetKey)}, ${q(foreignKeyField)} AS ${q(foreignKey)} FROM cte`; +}; + export class EagerLoadingTree { public root: EagerLoadingNode; db: Database; @@ -356,7 +383,7 @@ export class EagerLoadingTree { // load parent instances recursively if (node.includeOption.recursively && instances.length > 0) { const targetKey = association.targetKey; - const sql = AdjacencyListRepository.queryParentSQL({ + const sql = queryParentSQL({ db: this.db, collection, foreignKey, diff --git a/packages/core/database/src/fields/string-field.ts b/packages/core/database/src/fields/string-field.ts index 300819f0fc..50431f0565 100644 --- a/packages/core/database/src/fields/string-field.ts +++ b/packages/core/database/src/fields/string-field.ts @@ -12,10 +12,15 @@ import { BaseColumnFieldOptions, Field } from './field'; export class StringField extends Field { get dataType() { + if (this.options.length) { + return DataTypes.STRING(this.options.length); + } + return DataTypes.STRING; } } export interface StringFieldOptions extends BaseColumnFieldOptions { type: 'string'; + length?: number; } diff --git a/packages/core/database/src/relation-repository/multiple-relation-repository.ts b/packages/core/database/src/relation-repository/multiple-relation-repository.ts index 4fdb9205c5..30da978d8f 100644 --- a/packages/core/database/src/relation-repository/multiple-relation-repository.ts +++ b/packages/core/database/src/relation-repository/multiple-relation-repository.ts @@ -25,7 +25,7 @@ import { updateModelByValues } from '../update-associations'; import { UpdateGuard } from '../update-guard'; import { RelationRepository, transaction } from './relation-repository'; -export type FindAndCountOptions = CommonFindOptions; +type FindAndCountOptions = CommonFindOptions; export interface AssociatedOptions extends Transactionable { tk?: TK; diff --git a/packages/core/database/src/repositories/tree-repository/adjacency-list-repository.ts b/packages/core/database/src/repositories/tree-repository/adjacency-list-repository.ts deleted file mode 100644 index e5c9ee6d53..0000000000 --- a/packages/core/database/src/repositories/tree-repository/adjacency-list-repository.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import lodash from 'lodash'; -import { FindOptions, Repository } from '../../repository'; -import Database from '../../database'; -import { Collection } from '../../collection'; - -export class AdjacencyListRepository extends Repository { - static queryParentSQL(options: { - db: Database; - nodeIds: any[]; - collection: Collection; - foreignKey: string; - targetKey: string; - }) { - const { collection, db, nodeIds } = options; - const tableName = collection.quotedTableName(); - const { foreignKey, targetKey } = options; - const foreignKeyField = collection.model.rawAttributes[foreignKey].field; - const targetKeyField = collection.model.rawAttributes[targetKey].field; - - const queryInterface = db.sequelize.getQueryInterface(); - const q = queryInterface.quoteIdentifier.bind(queryInterface); - return `WITH RECURSIVE cte AS ( - SELECT ${q(targetKeyField)}, ${q(foreignKeyField)} - FROM ${tableName} - WHERE ${q(targetKeyField)} IN (${nodeIds.join(',')}) - UNION ALL - SELECT t.${q(targetKeyField)}, t.${q(foreignKeyField)} - FROM ${tableName} AS t - INNER JOIN cte ON t.${q(targetKeyField)} = cte.${q(foreignKeyField)} - ) - SELECT ${q(targetKeyField)} AS ${q(targetKey)}, ${q(foreignKeyField)} AS ${q(foreignKey)} FROM cte`; - } - - async update(options): Promise { - return super.update({ - ...(options || {}), - addIndex: false, - }); - } - - async find(options: FindOptions & { addIndex?: boolean } = {}): Promise { - if (options.raw || !options.tree) { - return await super.find(options); - } - - const collection = this.collection; - const primaryKey = collection.model.primaryKeyAttribute; - - if (options.fields && !options.fields.includes(primaryKey)) { - options.fields.push(primaryKey); - } - - const parentNodes = await super.find(options); - if (parentNodes.length === 0) { - return []; - } - - const { treeParentField } = collection; - const foreignKey = treeParentField.options.foreignKey; - - const childrenKey = collection.treeChildrenField?.name ?? 'children'; - - const parentIds = parentNodes.map((node) => node[primaryKey]); - - if (parentIds.length == 0) { - this.database.logger.warn('parentIds is empty'); - return parentNodes; - } - - const sql = this.querySQL(parentIds, collection); - - const childNodes = await this.database.sequelize.query(sql, { - type: 'SELECT', - transaction: options.transaction, - }); - - const childIds = childNodes.map((node) => node[primaryKey]); - - const findChildrenOptions = { - ...lodash.omit(options, ['limit', 'offset', 'filterByTk']), - filter: { - [primaryKey]: childIds, - }, - }; - - if (findChildrenOptions.fields) { - [primaryKey, foreignKey].forEach((field) => { - if (!findChildrenOptions.fields.includes(field)) { - findChildrenOptions.fields.push(field); - } - }); - } - - const childInstances = await super.find(findChildrenOptions); - - const nodeMap = {}; - - childInstances.forEach((node) => { - if (!nodeMap[`${node[foreignKey]}`]) { - nodeMap[`${node[foreignKey]}`] = []; - } - - nodeMap[`${node[foreignKey]}`].push(node); - }); - - function buildTree(parentId) { - const children = nodeMap[parentId]; - - if (!children) { - return []; - } - - return children.map((child) => { - const childrenValues = buildTree(child.id); - if (childrenValues.length > 0) { - child.setDataValue(childrenKey, childrenValues); - } - return child; - }); - } - - for (const parent of parentNodes) { - const parentId = parent[primaryKey]; - const children = buildTree(parentId); - if (children.length > 0) { - parent.setDataValue(childrenKey, children); - } - } - - this.addIndex(parentNodes, childrenKey, options); - - return parentNodes; - } - - private addIndex(treeArray, childrenKey, options) { - function traverse(node, index) { - // patch for sequelize toJSON - if (node._options.includeNames && !node._options.includeNames.includes(childrenKey)) { - node._options.includeNames.push(childrenKey); - } - - if (options.addIndex !== false) { - node.setDataValue('__index', `${index}`); - } - - const children = node.getDataValue(childrenKey); - - if (children && children.length === 0) { - node.setDataValue(childrenKey, undefined); - } - - if (children && children.length > 0) { - children.forEach((child, i) => { - traverse(child, `${index}.${childrenKey}.${i}`); - }); - } - } - - treeArray.forEach((tree, i) => { - traverse(tree, i); - }); - } - - private querySQL(rootIds, collection) { - const { treeParentField } = collection; - const foreignKey = treeParentField.options.foreignKey; - const foreignKeyField = collection.model.rawAttributes[foreignKey].field; - - const primaryKey = collection.model.primaryKeyAttribute; - - const queryInterface = this.database.sequelize.getQueryInterface(); - const q = queryInterface.quoteIdentifier.bind(queryInterface); - - return ` - WITH RECURSIVE cte AS (SELECT ${q(primaryKey)}, ${q(foreignKeyField)}, 1 AS level - FROM ${collection.quotedTableName()} - WHERE ${q(foreignKeyField)} IN (${rootIds.join(',')}) - UNION ALL - SELECT t.${q(primaryKey)}, t.${q(foreignKeyField)}, cte.level + 1 AS level - FROM ${collection.quotedTableName()} t - JOIN cte ON t.${q(foreignKeyField)} = cte.${q(primaryKey)}) - SELECT ${q(primaryKey)}, ${q(foreignKeyField)} as ${q(foreignKey)}, level - FROM cte - `; - } -} diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index c407060021..e1eab58e52 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -134,7 +134,7 @@ export interface DestroyOptions extends SequelizeDestroyOptions { context?: any; } -type FindAndCountOptions = Omit & CommonFindOptions; +export type FindAndCountOptions = Omit & CommonFindOptions; export interface CreateOptions extends SequelizeCreateOptions { values?: Values | Values[]; diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/list-action.test.ts b/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/list-action.test.ts index 637fac6d7b..418bc40199 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/list-action.test.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/list-action.test.ts @@ -358,121 +358,4 @@ describe('list association action with acl', () => { expect(data['meta']['allowedActions'].view).toContain(1); expect(data['meta']['allowedActions'].view).toContain(2); }); - - it('tree list action allowActions', async () => { - await db.getRepository('roles').create({ - values: { - name: 'newRole', - }, - }); - - const user = await db.getRepository('users').create({ - values: { - roles: ['newRole'], - }, - }); - - const userPlugin = app.getPlugin('users'); - const agent = app.agent().login(user).set('X-With-ACL-Meta', true); - app.acl.allow('table_a', ['*']); - app.acl.allow('collections', ['*']); - - await agent.resource('collections').create({ - values: { - autoGenId: true, - createdBy: false, - updatedBy: false, - createdAt: false, - updatedAt: false, - sortable: false, - name: 'table_a', - template: 'tree', - tree: 'adjacency-list', - fields: [ - { - interface: 'integer', - name: 'parentId', - type: 'bigInt', - isForeignKey: true, - uiSchema: { - type: 'number', - title: '{{t("Parent ID")}}', - 'x-component': 'InputNumber', - 'x-read-pretty': true, - }, - target: 'table_a', - }, - { - interface: 'm2o', - type: 'belongsTo', - name: 'parent', - treeParent: true, - foreignKey: 'parentId', - uiSchema: { - title: '{{t("Parent")}}', - 'x-component': 'AssociationField', - 'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } }, - }, - target: 'table_a', - }, - { - interface: 'o2m', - type: 'hasMany', - name: 'children', - foreignKey: 'parentId', - uiSchema: { - title: '{{t("Children")}}', - 'x-component': 'RecordPicker', - 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, - }, - treeChildren: true, - target: 'table_a', - }, - { - name: 'id', - type: 'bigInt', - autoIncrement: true, - primaryKey: true, - allowNull: false, - uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true }, - interface: 'id', - }, - ], - title: 'table_a', - }, - }); - - await agent.resource('table_a').create({ - values: {}, - }); - - await agent.resource('table_a').create({ - values: { - parent: { - id: 1, - }, - }, - }); - - await agent.resource('table_a').create({ - values: {}, - }); - - await agent.resource('table_a').create({ - values: { - parent: { - id: 3, - }, - }, - }); - - const res = await agent.resource('table_a').list({ - filter: JSON.stringify({ - parentId: null, - }), - tree: true, - }); - - expect(res.body.meta.allowedActions.view.sort()).toMatchObject([1, 2, 3, 4]); - }); }); diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/prepare.ts b/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/prepare.ts index 6afb546a0a..406fd37556 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/prepare.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/server/__tests__/prepare.ts @@ -13,7 +13,16 @@ export async function prepareApp(): Promise { const app = await createMockServer({ registerActions: true, acl: true, - plugins: ['acl', 'error-handler', 'users', 'ui-schema-storage', 'data-source-main', 'auth', 'data-source-manager'], + plugins: [ + 'acl', + 'error-handler', + 'users', + 'ui-schema-storage', + 'data-source-main', + 'auth', + 'data-source-manager', + 'collection-tree', + ], }); return app; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/.npmignore b/packages/plugins/@nocobase/plugin-collection-tree/.npmignore new file mode 100644 index 0000000000..65f5e8779f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@nocobase/plugin-collection-tree/README.md b/packages/plugins/@nocobase/plugin-collection-tree/README.md new file mode 100644 index 0000000000..281ffca119 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/README.md @@ -0,0 +1 @@ +# @nocobase/plugin-collection-tree diff --git a/packages/plugins/@nocobase/plugin-collection-tree/client.d.ts b/packages/plugins/@nocobase/plugin-collection-tree/client.d.ts new file mode 100644 index 0000000000..6c459cbac4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/client.js b/packages/plugins/@nocobase/plugin-collection-tree/client.js new file mode 100644 index 0000000000..b6e3be70e6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/package.json b/packages/plugins/@nocobase/plugin-collection-tree/package.json new file mode 100644 index 0000000000..1e0c75ea71 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/package.json @@ -0,0 +1,18 @@ +{ + "name": "@nocobase/plugin-collection-tree", + "version": "1.3.0-alpha", + "displayName": "Collection: Tree", + "displayName.zh-CN": "数据表:树", + "description": "Provides tree collection template", + "description.zh-CN": "提供树数据表模板", + "keywords": [ + "Collections" + ], + "main": "dist/server/index.js", + "dependencies": {}, + "peerDependencies": { + "@nocobase/client": "1.x", + "@nocobase/server": "1.x", + "@nocobase/test": "1.x" + } +} diff --git a/packages/plugins/@nocobase/plugin-collection-tree/server.d.ts b/packages/plugins/@nocobase/plugin-collection-tree/server.d.ts new file mode 100644 index 0000000000..c41081ddc6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/server.js b/packages/plugins/@nocobase/plugin-collection-tree/server.js new file mode 100644 index 0000000000..972842039a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/client/client.d.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/client/client.d.ts new file mode 100644 index 0000000000..4e96f83fa1 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/client/client.d.ts @@ -0,0 +1,249 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +// CSS modules +type CSSModuleClasses = { readonly [key: string]: string }; + +declare module '*.module.css' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.scss' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.sass' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.less' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.styl' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.stylus' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.pcss' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.sss' { + const classes: CSSModuleClasses; + export default classes; +} + +// CSS +declare module '*.css' { } +declare module '*.scss' { } +declare module '*.sass' { } +declare module '*.less' { } +declare module '*.styl' { } +declare module '*.stylus' { } +declare module '*.pcss' { } +declare module '*.sss' { } + +// Built-in asset types +// see `src/node/constants.ts` + +// images +declare module '*.apng' { + const src: string; + export default src; +} +declare module '*.png' { + const src: string; + export default src; +} +declare module '*.jpg' { + const src: string; + export default src; +} +declare module '*.jpeg' { + const src: string; + export default src; +} +declare module '*.jfif' { + const src: string; + export default src; +} +declare module '*.pjpeg' { + const src: string; + export default src; +} +declare module '*.pjp' { + const src: string; + export default src; +} +declare module '*.gif' { + const src: string; + export default src; +} +declare module '*.svg' { + const src: string; + export default src; +} +declare module '*.ico' { + const src: string; + export default src; +} +declare module '*.webp' { + const src: string; + export default src; +} +declare module '*.avif' { + const src: string; + export default src; +} + +// media +declare module '*.mp4' { + const src: string; + export default src; +} +declare module '*.webm' { + const src: string; + export default src; +} +declare module '*.ogg' { + const src: string; + export default src; +} +declare module '*.mp3' { + const src: string; + export default src; +} +declare module '*.wav' { + const src: string; + export default src; +} +declare module '*.flac' { + const src: string; + export default src; +} +declare module '*.aac' { + const src: string; + export default src; +} +declare module '*.opus' { + const src: string; + export default src; +} +declare module '*.mov' { + const src: string; + export default src; +} +declare module '*.m4a' { + const src: string; + export default src; +} +declare module '*.vtt' { + const src: string; + export default src; +} + +// fonts +declare module '*.woff' { + const src: string; + export default src; +} +declare module '*.woff2' { + const src: string; + export default src; +} +declare module '*.eot' { + const src: string; + export default src; +} +declare module '*.ttf' { + const src: string; + export default src; +} +declare module '*.otf' { + const src: string; + export default src; +} + +// other +declare module '*.webmanifest' { + const src: string; + export default src; +} +declare module '*.pdf' { + const src: string; + export default src; +} +declare module '*.txt' { + const src: string; + export default src; +} + +// wasm?init +declare module '*.wasm?init' { + const initWasm: (options?: WebAssembly.Imports) => Promise; + export default initWasm; +} + +// web worker +declare module '*?worker' { + const workerConstructor: { + new(options?: { name?: string }): Worker; + }; + export default workerConstructor; +} + +declare module '*?worker&inline' { + const workerConstructor: { + new(options?: { name?: string }): Worker; + }; + export default workerConstructor; +} + +declare module '*?worker&url' { + const src: string; + export default src; +} + +declare module '*?sharedworker' { + const sharedWorkerConstructor: { + new(options?: { name?: string }): SharedWorker; + }; + export default sharedWorkerConstructor; +} + +declare module '*?sharedworker&inline' { + const sharedWorkerConstructor: { + new(options?: { name?: string }): SharedWorker; + }; + export default sharedWorkerConstructor; +} + +declare module '*?sharedworker&url' { + const src: string; + export default src; +} + +declare module '*?raw' { + const src: string; + export default src; +} + +declare module '*?url' { + const src: string; + export default src; +} + +declare module '*?inline' { + const src: string; + export default src; +} diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/client/index.tsx b/packages/plugins/@nocobase/plugin-collection-tree/src/client/index.tsx new file mode 100644 index 0000000000..3d230b6763 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/client/index.tsx @@ -0,0 +1,30 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Plugin } from '@nocobase/client'; + +export class PluginCollectionTreeClient extends Plugin { + async afterAdd() { + // await this.app.pm.add() + } + + async beforeLoad() {} + + // You can get and modify the app instance here + async load() { + console.log(this.app); + // this.app.addComponents({}) + // this.app.addScopes({}) + // this.app.addProvider() + // this.app.addProviders() + // this.app.router.add() + } +} + +export default PluginCollectionTreeClient; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/index.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/index.ts new file mode 100644 index 0000000000..be99a2ff1a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/index.ts @@ -0,0 +1,11 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './server'; +export { default } from './server'; diff --git a/packages/core/database/src/__tests__/adjacency-list-repository.test.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/adjacency-list-repository.test.ts similarity index 96% rename from packages/core/database/src/__tests__/adjacency-list-repository.test.ts rename to packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/adjacency-list-repository.test.ts index 3c6d4ab951..e1611d4974 100644 --- a/packages/core/database/src/__tests__/adjacency-list-repository.test.ts +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/adjacency-list-repository.test.ts @@ -7,19 +7,22 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { mockDatabase } from './'; -import Database from '../database'; +import { prepareApp } from './prepare'; +import { Database } from '@nocobase/database'; +import { MockServer } from '@nocobase/test'; describe('adjacency list repository', () => { + let app: MockServer; let db: Database; beforeEach(async () => { - db = mockDatabase(); + app = await prepareApp(); + db = app.db; await db.clean({ drop: true }); }); afterEach(async () => { - await db.close(); + await app.destroy(); }); it('should append relation parent recursively with belongs to assoc', async () => { diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/list-action.test.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/list-action.test.ts new file mode 100644 index 0000000000..9ff60a6cab --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/list-action.test.ts @@ -0,0 +1,299 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { registerActions } from '@nocobase/actions'; +import { createApp } from './prepare'; + +describe('list-tree', () => { + let app; + beforeEach(async () => { + app = await createApp(); + registerActions(app); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should be tree', async () => { + const values = [ + { + name: '1', + __index: '0', + children: [ + { + name: '1-1', + __index: '0.children.0', + children: [ + { + name: '1-1-1', + __index: '0.children.0.children.0', + children: [ + { + name: '1-1-1-1', + __index: '0.children.0.children.0.children.0', + }, + ], + }, + ], + }, + ], + }, + { + name: '2', + __index: '1', + children: [ + { + name: '2-1', + __index: '1.children.0', + children: [ + { + name: '2-1-1', + __index: '1.children.0.children.0', + children: [ + { + name: '2-1-1-1', + __index: '1.children.0.children.0.children.0', + }, + ], + }, + ], + }, + ], + }, + ]; + + const db = app.db; + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'string', + name: 'description', + }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + await db.sync(); + + await db.getRepository('categories').create({ + values, + }); + + const response = await app + .agent() + .resource('categories') + .list({ + tree: true, + fields: ['id', 'name'], + sort: ['id'], + }); + + expect(response.status).toEqual(200); + expect(response.body.data).toMatchObject(values); + }); + + it('should be tree', async () => { + const values = [ + { + name: '1', + __index: '0', + children2: [ + { + name: '1-1', + __index: '0.children2.0', + children2: [ + { + name: '1-1-1', + __index: '0.children2.0.children2.0', + children2: [ + { + name: '1-1-1-1', + __index: '0.children2.0.children2.0.children2.0', + }, + ], + }, + ], + }, + ], + }, + { + name: '2', + __index: '1', + children2: [ + { + name: '2-1', + __index: '1.children2.0', + children2: [ + { + name: '2-1-1', + __index: '1.children2.0.children2.0', + children2: [ + { + name: '2-1-1-1', + __index: '1.children2.0.children2.0.children2.0', + }, + ], + }, + ], + }, + ], + }, + ]; + + const db = app.db; + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'bigInt', + name: 'parentId', + }, + { + type: 'string', + name: 'description', + }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'cid', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children2', + foreignKey: 'cid', + treeChildren: true, + }, + ], + }); + await db.sync(); + + await db.getRepository('categories').create({ + values, + }); + + const response = await app + .agent() + .resource('categories') + .list({ + tree: true, + fields: ['id', 'name'], + sort: ['id'], + }); + + expect(response.status).toEqual(200); + expect(response.body.data).toMatchObject(values); + }); + + it('should filter child nodes for tree', async () => { + const values = [ + { + name: 'A1', + __index: '0', + children3: [ + { + name: 'B', + __index: '0.children3.0', + children3: [ + { + name: 'C', + __index: '0.children3.0.children3.0', + }, + ], + }, + ], + }, + { + name: 'A2', + __index: '1', + children3: [ + { + name: 'B', + __index: '1.children3.0', + }, + ], + }, + ]; + + const db = app.db; + db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'string', + name: 'description', + }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'cid', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children3', + foreignKey: 'cid', + treeChildren: true, + }, + ], + }); + await db.sync(); + + await db.getRepository('categories').create({ + values, + }); + + const response = await app + .agent() + .resource('categories') + .list({ + tree: true, + fields: ['id', 'name'], + appends: ['parent'], + filter: { + name: 'B', + }, + }); + + expect(response.status).toEqual(200); + const rows = response.body.data; + expect(rows.length).toEqual(2); + expect(rows[0].name).toEqual('A1'); + expect(rows[1].name).toEqual('A2'); + expect(rows[0].children3[0].name).toEqual('B'); + expect(rows[1].children3[0].name).toEqual('B'); + expect(rows[0].children3[0].parent.name).toEqual('A1'); + expect(rows[1].children3[0].parent.name).toEqual('A2'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/path.test.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/path.test.ts new file mode 100644 index 0000000000..c53e3e27fb --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/path.test.ts @@ -0,0 +1,573 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { MockServer } from '@nocobase/test'; +import { Database } from '@nocobase/database'; +import { createApp } from './prepare'; + +describe('tree path test', () => { + let app: MockServer; + let agent; + let treeCollection; + let name; + let nodePkColumnName; + let values; + let valuesNoA1Children; + + let db: Database; + beforeEach(async () => { + app = await createApp(); + + agent = app.agent(); + db = app.db; + treeCollection = db.collection({ + name: 'tree', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'parentId', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + foreignKey: 'parentId', + treeChildren: true, + }, + ], + }); + await db.sync(); + name = `main_${treeCollection.name}_path`; + nodePkColumnName = db.getCollection(name).getField('nodePk').columnName(); + values = [ + { + name: 'a1', + children: [ + { + name: 'a2', + children: [ + { + name: 'a3', + children: [ + { + name: 'a4', + children: [ + { + name: 'a5', + __index: '0.children.0.children.0.children.0.children.0', + }, + ], + __index: '0.children.0.children.0.children.0', + }, + ], + __index: '0.children.0.children.0', + }, + ], + __index: '0.children.0', + }, + { + name: 'a1-1', + __index: '0.children.1', + }, + ], + __index: '0', + }, + ]; + valuesNoA1Children = [ + { + name: 'a1', + children: [ + { + name: 'a2', + children: [ + { + name: 'a3', + children: [ + { + name: 'a4', + children: [ + { + name: 'a5', + __index: '0.children.0.children.0.children.0.children.0', + }, + ], + __index: '0.children.0.children.0.children.0', + }, + ], + __index: '0.children.0.children.0', + }, + ], + __index: '0.children.0', + }, + ], + __index: '0', + }, + ]; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('test path table if create', async () => { + expect(await db.getCollection(name).existsInDb()).toBeTruthy(); + }); + + it('test path table data correction', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.find({}); + expect(data.length).toBe(6); + const nodeA1 = await treeCollection.repository.findOne({ + filter: { + name: 'a1', + }, + }); + const nodeA2 = await treeCollection.repository.findOne({ + filter: { + name: 'a2', + }, + }); + const nodeA3 = await treeCollection.repository.findOne({ + filter: { + name: 'a3', + }, + }); + const nodeA4 = await treeCollection.repository.findOne({ + filter: { + name: 'a4', + }, + }); + const nodeA5 = await treeCollection.repository.findOne({ + filter: { + name: 'a5', + }, + }); + const pathNodeA1 = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA1.get(treeCollection.filterTargetKey), + }, + }); + const pathNodeA2 = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA2.get(treeCollection.filterTargetKey), + }, + }); + const pathNodeA3 = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA3.get(treeCollection.filterTargetKey), + }, + }); + const pathNodeA4 = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA4.get(treeCollection.filterTargetKey), + }, + }); + const pathNodeA5 = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA5.get(treeCollection.filterTargetKey), + }, + }); + //test if root primary key data is correct + expect(pathNodeA1.get('rootPk')).toEqual(nodeA1.get(treeCollection.filterTargetKey)); + expect(pathNodeA2.get('rootPk')).toEqual(nodeA1.get(treeCollection.filterTargetKey)); + expect(pathNodeA3.get('rootPk')).toEqual(nodeA1.get(treeCollection.filterTargetKey)); + expect(pathNodeA4.get('rootPk')).toEqual(nodeA1.get(treeCollection.filterTargetKey)); + expect(pathNodeA5.get('rootPk')).toEqual(nodeA1.get(treeCollection.filterTargetKey)); + //test if root node key data is correct + expect(pathNodeA1.get('nodePk')).toEqual(nodeA1.get(treeCollection.filterTargetKey)); + expect(pathNodeA2.get('nodePk')).toEqual(nodeA2.get(treeCollection.filterTargetKey)); + expect(pathNodeA3.get('nodePk')).toEqual(nodeA3.get(treeCollection.filterTargetKey)); + expect(pathNodeA4.get('nodePk')).toEqual(nodeA4.get(treeCollection.filterTargetKey)); + expect(pathNodeA5.get('nodePk')).toEqual(nodeA5.get(treeCollection.filterTargetKey)); + //test if root path data is correct + expect(pathNodeA1.get('path')).toEqual(`/${nodeA1.get(treeCollection.filterTargetKey)}`); + expect(pathNodeA2.get('path')).toEqual( + `/${nodeA1.get(treeCollection.filterTargetKey)}/${nodeA2.get(treeCollection.filterTargetKey)}`, + ); + expect(pathNodeA3.get('path')).toEqual( + `/${nodeA1.get(treeCollection.filterTargetKey)}/${nodeA2.get(treeCollection.filterTargetKey)}/${nodeA3.get( + treeCollection.filterTargetKey, + )}`, + ); + expect(pathNodeA4.get('path')).toEqual( + `/${nodeA1.get(treeCollection.filterTargetKey)}/${nodeA2.get(treeCollection.filterTargetKey)}/${nodeA3.get( + treeCollection.filterTargetKey, + )}/${nodeA4.get(treeCollection.filterTargetKey)}`, + ); + expect(pathNodeA5.get('path')).toEqual( + `/${nodeA1.get(treeCollection.filterTargetKey)}/${nodeA2.get(treeCollection.filterTargetKey)}/${nodeA3.get( + treeCollection.filterTargetKey, + )}/${nodeA4.get(treeCollection.filterTargetKey)}/${nodeA5.get(treeCollection.filterTargetKey)}`, + ); + }); + + it('test node parent changed if the related node path is changed', async () => { + await treeCollection.repository.create({ + values, + }); + const nodeA1 = await treeCollection.repository.findOne({ + filter: { + name: 'a1', + }, + }); + const nodeA2 = await treeCollection.repository.findOne({ + filter: { + name: 'a2', + }, + }); + const nodeA3 = await treeCollection.repository.findOne({ + filter: { + name: 'a3', + }, + }); + const nodeA4 = await treeCollection.repository.findOne({ + filter: { + name: 'a4', + }, + }); + const nodeA5 = await treeCollection.repository.findOne({ + filter: { + name: 'a5', + }, + }); + // test node parent changed if the related node path is changed + await treeCollection.repository.update({ + values: { + parentId: null, + }, + filter: { + name: 'a4', + }, + }); + const pathNodeA4Changed = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA4.get(treeCollection.filterTargetKey), + }, + }); + const pathNodeA5Changed = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA5.get(treeCollection.filterTargetKey), + }, + }); + // node a4 and a5 root path is equal when a4 change parent to null + expect(pathNodeA4Changed.get('rootPk') === pathNodeA5Changed.get('rootPk')).toBeTruthy(); + await treeCollection.repository.update({ + values: { + parentId: nodeA3.get(treeCollection.filterTargetKey), + }, + filter: { + name: 'a4', + }, + }); + const allNodes = await db.getCollection(name).repository.find({}); + // all nodes root primary key is equal when a4 change parent to a3 + for (const node of allNodes) { + expect(nodeA1.get(treeCollection.filterTargetKey) === node.get('rootPk')).toBeTruthy(); + } + await treeCollection.repository.update({ + values: { + parentId: nodeA4.get(treeCollection.filterTargetKey), + }, + filter: { + name: 'a4', + }, + }); + const pathDataA4New = await db.getCollection(name).repository.findOne({ + filter: { + [nodePkColumnName]: nodeA4.get(treeCollection.filterTargetKey), + }, + }); + // node primary key shoud be equal to root primary key to avoid infinite loop + expect(pathDataA4New.get('nodePk') === pathDataA4New.get('rootPk')).toBeTruthy(); + }); + + it('test tree find one', async () => { + await treeCollection.repository.create({ + values, + }); + const nodeA1 = await treeCollection.repository.findOne({ + filter: { + name: 'a1', + }, + }); + expect(nodeA1).toBeTruthy(); + expect(nodeA1.get('name')).toEqual('a1'); + }); + + it('test tree find with tree', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.find({ + tree: true, + }); + expect(data.map((i) => i.toJSON())).toMatchObject(values); + }); + + it('test tree find', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.find({ + filter: { + name: 'a1', + }, + }); + expect(data.length).toEqual(1); + expect(data[0].name).toEqual('a1'); + }); + + it('test tree find with tree', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.find({ + filter: { + name: 'a1', + }, + tree: true, + }); + expect(data.length).toEqual(1); + expect(data[0].get('children')).toBeFalsy(); + expect(data[0].get('name')).toEqual('a1'); + const dataA2 = await treeCollection.repository.find({ + filter: { + name: 'a2', + }, + tree: true, + }); + expect(dataA2.length).toEqual(1); + expect(dataA2[0].get('children')).toBeTruthy(); + expect(dataA2[0].get('name')).toEqual('a1'); + expect(dataA2[0].get('children').length).toEqual(1); + expect(dataA2[0].get('children')[0].get('name')).toEqual('a2'); + }); + + it('test tree find with tree and append parameter', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.find({ + filter: { + name: 'a1', + }, + tree: true, + appends: ['parent'], + }); + expect(data.length).toEqual(1); + expect(data[0].get('children')).toBeFalsy(); + expect(data[0].get('name')).toEqual('a1'); + const dataA2 = await treeCollection.repository.find({ + filter: { + name: 'a2', + }, + tree: true, + appends: ['parent'], + }); + expect(dataA2.length).toEqual(1); + expect(dataA2[0].get('children')).toBeTruthy(); + expect(dataA2[0].get('name')).toEqual('a1'); + expect(dataA2[0].get('children').length).toEqual(1); + expect(dataA2[0].get('children')[0].get('name')).toEqual('a2'); + expect(dataA2[0].get('children')[0].get('parent')).toBeTruthy(); + expect(dataA2[0].get('children')[0].get('parent').get('name')).toEqual('a1'); + }); + + it('test tree find with tree、 appends and fields parameter', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.find({ + filter: { + name: 'a1', + }, + tree: true, + appends: ['parent'], + fields: ['id', 'name'], + }); + const dataExpect = { + id: 1, + name: 'a1', + parent: null, + __index: '0', + }; + expect(data[0].toJSON()).toMatchObject(dataExpect); + expect(data.length).toEqual(1); + expect(data[0].get('children')).toBeFalsy(); + expect(data[0].get('parent')).toBeFalsy(); + expect(data[0].get('name')).toEqual('a1'); + expect(data[0].get('__index')).toEqual('0'); + const dataA3 = await treeCollection.repository.find({ + filter: { + name: 'a5', + }, + tree: true, + appends: ['parent'], + fields: ['id', 'name'], + }); + expect(dataA3.map((i) => i.toJSON())).toMatchObject(valuesNoA1Children); + }); + + it('test tree find with filterByTk parameter', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.find({ + filterByTk: 1, + tree: true, + appends: ['parent'], + fields: ['id', 'name'], + }); + expect(data.length).toEqual(1); + expect(data[0].name).toEqual('a1'); + expect(data[0].children).toBeUndefined(); + const dataA5 = await treeCollection.repository.find({ + filterByTk: 5, + tree: true, + // appends: ['parent'], + fields: ['id', 'name'], + }); + expect(dataA5.length).toEqual(1); + expect(dataA5[0].get('children')).toBeTruthy(); + expect(dataA5.map((i) => i.toJSON())).toMatchObject(valuesNoA1Children); + }); + + it('test tree count', async () => { + await treeCollection.repository.create({ + values, + }); + const count = await treeCollection.repository.count({}); + expect(count).toEqual(6); + + const countWithFilter = await treeCollection.repository.count({ + filter: { + name: { + $startsWith: 'a', + }, + }, + }); + expect(countWithFilter).toEqual(6); + + const countWithTree = await treeCollection.repository.count({ + tree: true, + }); + expect(countWithTree).toEqual(1); + + const countWithFilterByTk = await treeCollection.repository.count({ + tree: true, + filterByTk: 5, + }); + expect(countWithFilterByTk).toEqual(1); + + const countFilter = await treeCollection.repository.count({ + tree: true, + filter: { + name: 'a5', + }, + }); + expect(countFilter).toEqual(1); + }); + + it('test tree find and count', async () => { + await treeCollection.repository.create({ + values, + }); + const data = await treeCollection.repository.findAndCount({}); + const count = data[1]; + expect(count).toEqual(6); + + const countWithFilter = await treeCollection.repository.findAndCount({ + filter: { + name: { + $startsWith: 'a', + }, + }, + }); + expect(countWithFilter[1]).toEqual(6); + + const countWithTree = await treeCollection.repository.findAndCount({ + tree: true, + }); + expect(countWithTree[1]).toEqual(1); + expect(countWithTree[0].map((i) => i.toJSON())).toMatchObject(values); + + const countWithFilterByTk = await treeCollection.repository.findAndCount({ + tree: true, + filterByTk: 5, + }); + expect(countWithFilterByTk[0].map((i) => i.toJSON())).toMatchObject(valuesNoA1Children); + expect(countWithFilterByTk[1]).toEqual(1); + + const countFilter = await treeCollection.repository.findAndCount({ + tree: true, + filter: { + name: 'a5', + }, + }); + expect(countFilter[1]).toEqual(1); + // shoud be root node name of a1 + expect(countFilter[0][0].name).toEqual('a1'); + }); + + it('test tree find one', async () => { + await treeCollection.repository.create({ + values, + }); + + const nodeA1 = await treeCollection.repository.findOne({ + filter: { + name: 'a1', + }, + }); + expect(nodeA1.get('name')).toEqual('a1'); + expect(nodeA1.get('children')).toBeUndefined(); + + const nodeA5 = await treeCollection.repository.findOne({ + filter: { + name: 'a5', + }, + }); + expect(nodeA5.get('name')).toEqual('a5'); + expect(nodeA5.get('children')).toBeUndefined(); + + const nodeA1WithTree = await treeCollection.repository.findOne({ + filter: { + name: 'a1', + }, + tree: true, + }); + expect(nodeA1WithTree.get('name')).toEqual('a1'); + expect(nodeA1WithTree.get('children')).toBeUndefined(); + + const nodeA5WithTree = await treeCollection.repository.findOne({ + filter: { + name: 'a5', + }, + fields: ['id', 'name'], + tree: true, + }); + // shoud be root node name of a1 + expect(nodeA5WithTree.get('name')).toEqual('a1'); + expect(nodeA5WithTree.get('children')).toBeTruthy(); + expect(nodeA5WithTree.toJSON()).toMatchObject(valuesNoA1Children[0]); + }); + + // it('test tree collection destroy then the path table will be destroy', async () => { + // await treeCollection.removeFromDb(); + // expect(await db.getCollection(name).existsInDb()).toBeFalsy(); + // }) +}); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/prepare.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/prepare.ts new file mode 100644 index 0000000000..73cbb6baad --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/prepare.ts @@ -0,0 +1,53 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { createMockServer, MockServer } from '@nocobase/test'; + +export async function prepareApp(): Promise { + const app = await createMockServer({ + registerActions: true, + acl: true, + plugins: [ + 'acl', + 'error-handler', + 'users', + 'ui-schema-storage', + 'data-source-main', + 'data-source-manager', + 'collection-tree', + ], + }); + + return app; +} + +export async function createApp(options: any = {}) { + const app = await createMockServer({ + acl: false, + ...options, + plugins: [ + 'data-source-main', + 'users', + 'collection-tree', + 'error-handler', + 'data-source-manager', + 'ui-schema-storage', + ], + }); + return app; +} + +export async function createAppWithNoUsersPlugin(options: any = {}) { + const app = await createMockServer({ + acl: false, + ...options, + plugins: ['data-source-main', 'collection-tree', 'error-handler', 'data-source-manager', 'ui-schema-storage'], + }); + return app; +} diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/sync.test.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/sync.test.ts new file mode 100644 index 0000000000..2a1aae5043 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/sync.test.ts @@ -0,0 +1,195 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { MockDatabase, MockServer, createMockServer } from '@nocobase/test'; +import Migration from '../migrations/20240802141435-collection-tree'; +import { Repository } from '@nocobase/database'; + +describe('tree collection sync', async () => { + let app: MockServer; + let db: MockDatabase; + + beforeEach(async () => { + app = await createMockServer({ + version: '1.3.0-alpha', + plugins: ['data-source-main', 'data-source-manager', 'error-handler', 'collection-tree'], + }); + db = app.db; + }); + + afterEach(async () => { + await db.clean({ drop: true }); + await app.destroy(); + }); + + it('should create path collection when creating tree collection', async () => { + const collection = db.collection({ + name: 'test_tree', + tree: 'adjacency-list', + fields: [ + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + await collection.sync(); + const name = `main_${collection.name}_path`; + const pathCollection = db.getCollection(name); + expect(pathCollection).toBeTruthy(); + expect(await pathCollection.existsInDb()).toBeTruthy(); + }); +}); + +describe('collection tree migrate test', () => { + let app: MockServer; + let db: MockDatabase; + let repo: Repository; + + beforeEach(async () => { + app = await createMockServer({ + version: '1.3.0-alpha', + plugins: ['data-source-main', 'data-source-manager', 'error-handler', 'collection-tree'], + }); + db = app.db; + repo = app.db.getRepository('applicationPlugins'); + await db.getRepository('collections').create({ + values: { + name: 'test_tree', + tree: 'adjacencyList', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }, + }); + const collection = db.collection({ + name: 'test_tree', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + await collection.sync(); + await collection.repository.create({ + values: [ + { + name: 'c1', + children: [ + { + name: 'c1-1', + children: [ + { + name: 'c1-1-1', + }, + ], + }, + { + name: 'c12', + }, + ], + }, + ], + }); + }); + + afterEach(async () => { + await app.db.clean({ drop: true }); + await app.destroy(); + }); + + it('should sync path collection for old tree collections when upgrading', async () => { + const plugin = await repo.create({ + values: { + name: 'collection-tree', + version: '1.3.0-alpha', + enabled: true, + installed: true, + builtIn: true, + }, + }); + const name = `main_test_tree_path`; + const pathCollection = db.getCollection(name); + expect(pathCollection).toBeTruthy(); + expect(await pathCollection.existsInDb()).toBeTruthy(); + const pathData = await pathCollection.repository.find({}); + expect(pathData.length).toEqual(4); + await pathCollection.removeFromDb(); + const migration = new Migration({ + db: db, + // @ts-ignore + app, + }); + await migration.up(); + const p = await repo.findOne({ + filter: { + id: plugin.id, + }, + }); + expect(p.name).toBe('collection-tree'); + const collection1 = db.collection({ + name: 'test_tree', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + const pathCollection1 = db.getCollection(name); + expect(pathCollection1).toBeTruthy(); + expect(await pathCollection1.existsInDb()).toBeTruthy(); + const collectionData = await collection1.repository.find({}); + expect(collectionData.length).toEqual(4); + const pathData1 = await pathCollection1.repository.find({ context: {} }); + expect(pathData1.length).toEqual(4); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/tree-legacy.test.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/tree-legacy.test.ts new file mode 100644 index 0000000000..b36753fe12 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/tree-legacy.test.ts @@ -0,0 +1,269 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { MockServer } from '@nocobase/test'; +import { Database } from '@nocobase/database'; +import { createApp } from './prepare'; + +describe('tree test', () => { + let app: MockServer; + let agent; + + let db: Database; + beforeEach(async () => { + app = await createApp(); + + agent = app.agent(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should not return children property when child nodes are empty', async () => { + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { type: 'string', name: 'name' }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + + await db.sync(); + + await collection.repository.create({ + values: [ + { + name: 'c1', + children: [ + { + name: 'c11', + }, + { + name: 'c12', + }, + ], + }, + { + name: 'c2', + }, + ], + }); + + const tree = await collection.repository.find({ + // filter: { + // parentId: null, + // }, + tree: true, + }); + + const c2 = tree.find((item) => item.name === 'c2'); + expect(c2.toJSON()['children']).toBeUndefined(); + + const c11 = tree + .find((item) => item.name === 'c1') + .get('children') + .find((item) => item.name === 'c11'); + + expect(c11.toJSON()['children']).toBeUndefined(); + + const treeNew = await collection.repository.find({ + filter: { + parentId: null, + }, + tree: true, + }); + const c1 = treeNew.find((item) => item.name === 'c1'); + expect(c1.toJSON()['children']).toBeUndefined(); + }); + + const values = [ + { + name: '1', + __index: '0', + children: [ + { + name: '1-1', + __index: '0.children.0', + children: [ + { + name: '1-1-1', + __index: '0.children.0.children.0', + children: [ + { + name: '1-1-1-1', + __index: '0.children.0.children.0.children.0', + }, + ], + }, + ], + }, + ], + }, + { + name: '2', + __index: '1', + children: [ + { + name: '2-1', + __index: '1.children.0', + children: [ + { + name: '2-1-1', + __index: '1.children.0.children.0', + children: [ + { + name: '2-1-1-1', + __index: '1.children.0.children.0.children.0', + }, + ], + }, + ], + }, + ], + }, + ]; + + it('should be tree', async () => { + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'string', + name: 'description', + }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + await db.sync(); + + await db.getRepository('categories').create({ + values, + }); + + // new tree implantation not need to pass primary key to null + const instances = await db.getRepository('categories').find({ + // filter: { + // parentId: null, + // }, + tree: true, + fields: ['id', 'name'], + sort: 'id', + }); + + expect(instances.map((i) => i.toJSON())).toMatchObject(values); + + // new tree implantation if the filterByTk pass to the find then will return the data from root id + const instance = await db.getRepository('categories').findOne({ + filterByTk: 4, + tree: true, + fields: ['id', 'name'], + }); + + expect(instance.toJSON()).toMatchObject(values[0]); + + const instanceNew = await db.getRepository('categories').findOne({ + filterByTk: 1, + tree: true, + fields: ['id', 'name'], + }); + + const valuesNew = { id: 1, name: '1', __index: '0' }; + expect(instanceNew.toJSON()).toMatchObject(valuesNew); + }); + + it('should find tree collection', async () => { + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'string', + name: 'description', + }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'cid', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + foreignKey: 'cid', + treeChildren: true, + }, + ], + }); + await db.sync(); + + await db.getRepository('categories').create({ + values, + }); + + // new tree implantation not need to pass primary key to null + const instances = await db.getRepository('categories').find({ + // filter: { + // cid: null, + // }, + tree: true, + fields: ['id', 'name'], + sort: 'id', + }); + + expect(instances.map((i) => i.toJSON())).toMatchObject(values); + + // new tree implantation if the filterByTk pass to the find then will return the data from root id + const instance = await db.getRepository('categories').findOne({ + filterByTk: 4, + tree: true, + fields: ['id', 'name'], + }); + + expect(instance.toJSON()).toMatchObject(values[0]); + + const instanceNew = await db.getRepository('categories').findOne({ + filterByTk: 1, + tree: true, + fields: ['id', 'name'], + }); + + const valuesNew = { id: 1, name: '1', __index: '0' }; + expect(instanceNew.toJSON()).toMatchObject(valuesNew); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/tree.test.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/tree.test.ts new file mode 100644 index 0000000000..94bb64d0c5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/__tests__/tree.test.ts @@ -0,0 +1,722 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Database } from '@nocobase/database'; +import { MockServer } from '@nocobase/test'; +import { createApp, createAppWithNoUsersPlugin, prepareApp } from './prepare'; +import { AdjacencyListRepository } from '../adjacency-list-repository'; + +describe('tree', () => { + let app: MockServer; + let agent; + + let db: Database; + beforeEach(async () => { + app = await prepareApp(); + + agent = app.agent(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('tree list action allowActions', async () => { + await db.getRepository('roles').create({ + values: { + name: 'newRole', + }, + }); + + const user = await db.getRepository('users').create({ + values: { + roles: ['newRole'], + }, + }); + + const userPlugin = app.getPlugin('users'); + const agent = app.agent().login(user).set('X-With-ACL-Meta', true); + app.acl.allow('table_a', ['*']); + app.acl.allow('collections', ['*']); + + await agent.resource('collections').create({ + values: { + autoGenId: true, + createdBy: false, + updatedBy: false, + createdAt: false, + updatedAt: false, + sortable: false, + name: 'table_a', + template: 'tree', + tree: 'adjacency-list', + fields: [ + { + interface: 'integer', + name: 'parentId', + type: 'bigInt', + isForeignKey: true, + uiSchema: { + type: 'number', + title: '{{t("Parent ID")}}', + 'x-component': 'InputNumber', + 'x-read-pretty': true, + }, + target: 'table_a', + }, + { + interface: 'm2o', + type: 'belongsTo', + name: 'parent', + treeParent: true, + foreignKey: 'parentId', + uiSchema: { + title: '{{t("Parent")}}', + 'x-component': 'AssociationField', + 'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } }, + }, + target: 'table_a', + }, + { + interface: 'o2m', + type: 'hasMany', + name: 'children', + foreignKey: 'parentId', + uiSchema: { + title: '{{t("Children")}}', + 'x-component': 'RecordPicker', + 'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } }, + }, + treeChildren: true, + target: 'table_a', + }, + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true }, + interface: 'id', + }, + ], + title: 'table_a', + }, + }); + + await agent.resource('table_a').create({ + values: {}, + }); + + await agent.resource('table_a').create({ + values: { + parent: { + id: 1, + }, + }, + }); + + await agent.resource('table_a').create({ + values: {}, + }); + + await agent.resource('table_a').create({ + values: { + parent: { + id: 3, + }, + }, + }); + + const res = await agent.resource('table_a').list({ + filter: JSON.stringify({ + parentId: null, + }), + tree: true, + }); + + expect(res.body.meta.allowedActions.view.sort()).toMatchObject([1, 2, 3, 4]); + }); +}); + +describe('find with association test case 1', () => { + let app: MockServer; + let agent; + + let db: Database; + beforeEach(async () => { + app = await createAppWithNoUsersPlugin(); + + agent = app.agent(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should filter by association field', async () => { + const User = db.collection({ + name: 'users', + tree: 'adjacency-list', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts', target: 'posts', foreignKey: 'user_id' }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'parent_id', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + foreignKey: 'parent_id', + treeChildren: true, + }, + ], + }); + + const Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'user', target: 'users', foreignKey: 'user_id' }, + ], + }); + + await db.sync(); + + expect(User.options.tree).toBeTruthy(); + + await User.repository.create({ + values: [ + { + name: 'u1', + posts: [ + { + title: 'u1p1', + }, + ], + children: [ + { + name: 'u2', + posts: [ + { + title: '标题2', + }, + ], + }, + ], + }, + ], + }); + + const filter = { + $and: [ + { + children: { + posts: { + title: { + $eq: '标题2', + }, + }, + }, + }, + ], + }; + + const [findResult, count] = await User.repository.findAndCount({ + filter, + offset: 0, + limit: 20, + }); + + expect(findResult[0].get('name')).toEqual('u1'); + }); +}); + +describe('find with association test case 2', () => { + let app: MockServer; + let agent; + + let db: Database; + beforeEach(async () => { + app = await createAppWithNoUsersPlugin(); + + agent = app.agent(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should filter by association field', async () => { + await db.getRepository('collections').create({ + values: { + name: 'users', + tree: 'adjacency-list', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'posts', target: 'posts', foreignKey: 'user_id' }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'parent_id', + treeParent: true, + target: 'users', + }, + { + type: 'hasMany', + name: 'children', + foreignKey: 'parent_id', + treeChildren: true, + target: 'users', + }, + ], + }, + context: {}, + }); + + await db.getRepository('collections').create({ + values: { + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'user', target: 'users', foreignKey: 'user_id' }, + ], + }, + context: {}, + }); + + const UserCollection = db.getCollection('users'); + + expect(UserCollection.options.tree).toBeTruthy(); + + await db.getRepository('users').create({ + values: [ + { + name: 'u1', + posts: [ + { + title: 'u1p1', + }, + ], + children: [ + { + name: 'u2', + posts: [ + { + title: '标题2', + }, + ], + }, + ], + }, + { + name: 'u3', + children: [ + { + name: 'u4', + posts: [ + { + title: '标题五', + }, + ], + }, + ], + }, + ], + }); + + const filter = { + $and: [ + { + children: { + posts: { + title: { + $eq: '标题五', + }, + }, + }, + }, + ], + }; + + const items = await db.getRepository('users').find({ + filter, + appends: ['children'], + }); + + expect(items[0].name).toEqual('u3'); + + const response2 = await agent.resource('users').list({ + filter, + appends: ['children'], + }); + + expect(response2.statusCode).toEqual(200); + expect(response2.body.data[0].name).toEqual('u3'); + }); +}); + +describe('list-tree', () => { + let app; + let db: Database; + let agent; + beforeEach(async () => { + app = await createApp(); + agent = app.agent(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should list tree', async () => { + await db.getRepository('collections').create({ + values: { + name: 'categories', + tree: 'adjacency-list', + fields: [ + { type: 'string', name: 'name' }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'parent_id', + treeParent: true, + target: 'categories', + }, + { + type: 'bigInt', + name: 'parentId', + }, + { + type: 'hasMany', + name: 'children', + foreignKey: 'parent_id', + treeChildren: true, + target: 'categories', + }, + ], + }, + context: {}, + }); + + const c1 = await db.getRepository('categories').create({ + values: { name: 'c1' }, + }); + + await db.getRepository('categories').create({ + values: [ + { + name: 'c2', + parent: { + name: 'c1', + id: c1.get('id'), + }, + }, + ], + }); + + const listResponse = await agent.resource('categories').list({ + appends: ['parent'], + }); + + expect(listResponse.statusCode).toBe(200); + + // update c1 + await db.getRepository('categories').update({ + filter: { + name: 'c1', + }, + values: { + __index: '1231', // should ignore + name: 'c11', + }, + }); + + await c1.reload(); + + expect(c1.get('name')).toBe('c11'); + }); +}); + +describe('tree test', () => { + let app: MockServer; + let agent; + + let db: Database; + beforeEach(async () => { + app = await createApp(); + + agent = app.agent(); + db = app.db; + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should works with appends option', async () => { + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { type: 'string', name: 'name' }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + + await db.sync(); + + await collection.repository.create({ + values: [ + { + name: 'c1', + children: [ + { + name: 'c11', + }, + { + name: 'c12', + }, + ], + }, + { + name: 'c2', + }, + ], + }); + + const tree = await collection.repository.find({ + tree: true, + filter: { + parentId: null, + }, + fields: ['name'], + }); + + expect(tree.length).toBe(2); + }); + + it('should add sort field', async () => { + const Tasks = db.collection({ + name: 'tasks', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + { + type: 'string', + name: 'status', + }, + ], + }); + + await db.sync(); + + await Tasks.repository.create({ + values: { + name: 'task1', + status: 'doing', + }, + }); + + await Tasks.repository.create({ + values: { + name: 'task2', + status: 'pending', + }, + }); + + await Tasks.repository.create({ + values: { + name: 'task3', + status: 'pending', + }, + }); + + Tasks.setField('sort', { type: 'sort', scopeKey: 'status' }); + + await db.sync(); + }); + + it('should be auto completed', () => { + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'belongsTo', + name: 'parent', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + treeChildren: true, + }, + ], + }); + expect(collection.treeChildrenField?.name).toBe('children'); + expect(collection.treeParentField?.name).toBe('parent'); + expect(collection.getField('parent').options.target).toBe('categories'); + expect(collection.getField('parent').options.foreignKey).toBe('parentId'); + expect(collection.getField('children').options.target).toBe('categories'); + expect(collection.getField('children').options.foreignKey).toBe('parentId'); + }); + + it('should be auto completed', () => { + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'cid', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + foreignKey: 'cid', + treeChildren: true, + }, + ], + }); + expect(collection.treeChildrenField?.name).toBe('children'); + expect(collection.treeParentField?.name).toBe('parent'); + expect(collection.getField('parent').options.target).toBe('categories'); + expect(collection.getField('parent').options.foreignKey).toBe('cid'); + expect(collection.getField('children').options.target).toBe('categories'); + expect(collection.getField('children').options.foreignKey).toBe('cid'); + }); + + it('should get adjacency list repository', async () => { + const collection = db.collection({ + name: 'categories', + tree: 'adjacency-list', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'belongsTo', + name: 'parent', + foreignKey: 'parentId', + treeParent: true, + }, + { + type: 'hasMany', + name: 'children', + foreignKey: 'parentId', + treeChildren: true, + }, + ], + }); + + const repository = db.getRepository('categories'); + expect(repository).toBeInstanceOf(AdjacencyListRepository); + }); + + // test('performance', async () => { + // const collection = db.collection({ + // name: 'categories', + // tree: 'adjacency-list', + // fields: [ + // { + // type: 'string', + // name: 'name', + // }, + // { + // type: 'belongsTo', + // name: 'parent', + // foreignKey: 'parentId', + // treeParent: true, + // }, + // { + // type: 'hasMany', + // name: 'children', + // foreignKey: 'parentId', + // treeChildren: true, + // }, + // ], + // }); + // await db.sync(); + // + // const values = []; + // for (let i = 0; i < 10; i++) { + // const children = []; + // for (let j = 0; j < 10; j++) { + // const grandchildren = []; + // for (let k = 0; k < 10; k++) { + // grandchildren.push({ + // name: `name-${i}-${j}-${k}`, + // }); + // } + // children.push({ + // name: `name-${i}-${j}`, + // children: grandchildren, + // }); + // } + // + // values.push({ + // name: `name-${i}`, + // description: `description-${i}`, + // children, + // }); + // } + // + // await db.getRepository('categories').create({ + // values, + // }); + // + // const before = Date.now(); + // + // const instances = await db.getRepository('categories').find({ + // filter: { + // parentId: null, + // }, + // tree: true, + // fields: ['id', 'name'], + // sort: 'id', + // limit: 10, + // }); + // + // const after = Date.now(); + // console.log(after - before); + // }); +}); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/adjacency-list-repository.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/adjacency-list-repository.ts new file mode 100644 index 0000000000..40fdb73e7a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/adjacency-list-repository.ts @@ -0,0 +1,315 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import lodash from 'lodash'; +import { CountOptions, FindOptions, Repository, FindAndCountOptions, Transactionable, Model } from '@nocobase/database'; +import { isValidFilter } from '@nocobase/utils'; +import { TreeCollection } from './tree-collection'; + +export class AdjacencyListRepository extends Repository { + declare collection: TreeCollection; + + async update(options): Promise { + return super.update({ + ...(options || {}), + addIndex: false, + }); + } + + buildRootNodeDataMap(nodeData: Model[]) { + const rootPathDataMap: { + [key: string]: Set; + } = {}; + for (const node of nodeData) { + const rootPk = node.get('rootPk'); + const pathSet = new Set( + node + .get('path') + .split('/') + .filter((item: string) => item !== ''), + ); + if (rootPathDataMap[rootPk]) { + const set = rootPathDataMap[rootPk]; + for (const path of pathSet) { + set.add(path); + } + rootPathDataMap[rootPk] = set; + } else { + rootPathDataMap[rootPk] = pathSet; + } + } + + return rootPathDataMap; + } + + async buildTree(paths: Model[], options: FindOptions & { addIndex?: boolean } = {}, rootNodes?: Model[]) { + const collection = this.collection; + const primaryKey = collection.model.primaryKeyAttribute; + const foreignKey = collection.treeForeignKey; + const childrenKey = collection.treeChildrenField?.name ?? 'children'; + const treePathMap = this.buildRootNodeDataMap(paths); + if (!rootNodes) { + const rootIds = Object.keys(treePathMap); + if (!rootIds.length) { + this.database.logger.warn('adjacency-list-repository: rootIds is empty'); + return []; + } + rootNodes = await super.find({ + filter: { + [primaryKey]: { + $in: rootIds, + }, + }, + ...lodash.omit(options, ['filter', 'filterByTk']), + transaction: options.transaction, + }); + } + const childIds: any[] = []; + for (const nodeIdSet of Object.values(treePathMap)) { + for (const nodeId of nodeIdSet) { + childIds.push(nodeId); + } + } + const findChildrenOptions = { + ...lodash.omit(options, ['limit', 'offset', 'filterByTk']), + filter: { + [primaryKey]: childIds, + }, + }; + if (findChildrenOptions.fields) { + [primaryKey, foreignKey].forEach((field) => { + if (!findChildrenOptions.fields.includes(field)) { + findChildrenOptions.fields.push(field); + } + }); + } + const childInstances = await super.find(findChildrenOptions); + + const nodeMap = {}; + childInstances.forEach((node) => { + if (!nodeMap[`${node[foreignKey]}`]) { + nodeMap[`${node[foreignKey]}`] = []; + } + + nodeMap[`${node[foreignKey]}`].push(node); + }); + + function buildTree(rootId: string | number) { + const children = nodeMap[rootId]; + + if (!children) { + return []; + } + + return children.map((child) => { + const childrenValues = buildTree(child.id); + if (childrenValues.length > 0) { + child.setDataValue(childrenKey, childrenValues); + } + return child; + }); + } + + for (const root of rootNodes) { + const rootId = root[primaryKey]; + const children = buildTree(rootId); + if (children.length > 0) { + root.setDataValue(childrenKey, children); + } + } + + this.addIndex(rootNodes, childrenKey, options); + + return rootNodes; + } + + async findWithoutFilter(options: FindOptions & { addIndex?: boolean } = {}): Promise { + const foreignKey = this.collection.treeForeignKey; + const rootNodes = await super.find({ ...options, filter: { [foreignKey]: null } }); + if (!rootNodes.length) { + return []; + } + const collection = this.collection; + const primaryKey = collection.model.primaryKeyAttribute; + const rootPks = rootNodes.map((node) => node[primaryKey]); + const paths = await this.queryPathByRoot({ + rootPks, + dataSourceName: options.context?.dataSource?.name ?? 'main', + transaction: options.transaction, + }); + return await this.buildTree(paths, options, rootNodes); + } + + async countWithoutFilter(options: CountOptions & { raw?: boolean; tree?: boolean }): Promise { + const foreignKey = this.collection.treeForeignKey; + return await super.count({ ...options, filter: { [foreignKey]: null } }); + } + + async filterAndGetPaths(options: FindOptions & { addIndex?: boolean } = {}): Promise<{ + filterNodes: Model[]; + paths: Model[]; + }> { + const primaryKey = this.collection.model.primaryKeyAttribute; + const filterNodes = await super.find({ + fields: [primaryKey], + ...lodash.omit(options, ['limit', 'offset', 'fields']), + }); + if (!filterNodes.length) { + return { filterNodes: [], paths: [] }; + } + + const filterPks = filterNodes.map((node: Model) => node[primaryKey]); + if (!filterPks.length) { + this.database.logger.debug('adjacency-list-repository: filterIds is empty'); + return { filterNodes, paths: [] }; + } + const paths = await this.queryPathByNode({ + nodePks: filterPks, + dataSourceName: options.context?.dataSource?.name ?? 'main', + transaction: options.transaction, + }); + return { filterNodes, paths }; + } + + async find(options: FindOptions & { addIndex?: boolean } = {}): Promise { + if (options.raw || !options.tree) { + return await super.find(options); + } + + const primaryKey = this.collection.model.primaryKeyAttribute; + if (options.fields && !options.fields.includes(primaryKey)) { + options.fields.push(primaryKey); + } + + if (!isValidFilter(options.filter) && !options.filterByTk) { + return await this.findWithoutFilter(options); + } + + const { filterNodes, paths } = await this.filterAndGetPaths(options); + if (!paths.length) { + return filterNodes; + } + return await this.buildTree(paths, options); + } + + countByPaths(paths: Model[]): number { + const rootIds = new Set(); + for (const path of paths) { + rootIds.add(path.get('rootPk')); + } + return rootIds.size; + } + + async count(countOptions?: CountOptions & { raw?: boolean; tree?: boolean }): Promise { + if (countOptions.raw || !countOptions.tree) { + return await super.count(countOptions); + } + if (!isValidFilter(countOptions.filter) && !countOptions.filterByTk) { + return await this.countWithoutFilter(countOptions); + } + const { paths } = await this.filterAndGetPaths(countOptions); + return this.countByPaths(paths); + } + + async findAndCount(options?: FindAndCountOptions & { filterByTk?: number | string }): Promise<[Model[], number]> { + options = { + ...options, + transaction: await this.getTransaction(options.transaction), + }; + + if (options.raw || !options.tree) { + return await super.findAndCount(options); + } + if (!isValidFilter(options.filter) && !options.filterByTk) { + const count = await this.countWithoutFilter(options); + const results = count ? await this.findWithoutFilter(options) : []; + return [results, count]; + } + const { filterNodes, paths } = await this.filterAndGetPaths(options); + if (!paths.length) { + return [filterNodes, 0]; + } + const results = await this.buildTree(paths, options); + const count = this.countByPaths(paths); + return [results, count]; + } + + private addIndex(treeArray, childrenKey, options) { + function traverse(node, index) { + // patch for sequelize toJSON + if (node._options.includeNames && !node._options.includeNames.includes(childrenKey)) { + node._options.includeNames.push(childrenKey); + } + + if (options.addIndex !== false) { + node.setDataValue('__index', `${index}`); + } + + const children = node.getDataValue(childrenKey); + + if (children && children.length === 0) { + node.setDataValue(childrenKey, undefined); + } + + if (children && children.length > 0) { + children.forEach((child, i) => { + traverse(child, `${index}.${childrenKey}.${i}`); + }); + } + } + + treeArray.forEach((tree, i) => { + traverse(tree, i); + }); + } + + private async queryPathByNode({ + nodePks, + dataSourceName, + transaction, + }: { nodePks: (string | number)[]; dataSourceName: string } & Transactionable): Promise { + const collection = this.collection; + const pathTableName = `${dataSourceName}_${collection.name}_path`; + const repo = this.database.getRepository(pathTableName); + if (repo) { + return await repo.find({ + filter: { + nodePk: { + $in: nodePks, + }, + }, + transaction, + }); + } + this.database.logger.warn(`Collection tree path table: ${pathTableName} not found`); + return []; + } + + private async queryPathByRoot({ + rootPks, + dataSourceName, + transaction, + }: { rootPks: (string | number)[]; dataSourceName: string } & Transactionable): Promise { + const collection = this.collection; + const pathTableName = `${dataSourceName}_${collection.name}_path`; + const repo = this.database.getRepository(pathTableName); + if (repo) { + return await repo.find({ + filter: { + rootPk: { + $in: rootPks, + }, + }, + transaction, + }); + } + this.database.logger.warn(`Collection tree path table: ${pathTableName} not found`); + return []; + } +} diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/collections/.gitkeep b/packages/plugins/@nocobase/plugin-collection-tree/src/server/collections/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/index.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/index.ts new file mode 100644 index 0000000000..be989de7c3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/index.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { default } from './plugin'; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/migrations/20240802141435-collection-tree.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/migrations/20240802141435-collection-tree.ts new file mode 100644 index 0000000000..ef2fb3d90b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/migrations/20240802141435-collection-tree.ts @@ -0,0 +1,115 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Migration } from '@nocobase/server'; +import { Model, SyncOptions } from '@nocobase/database'; +import { Transaction } from 'sequelize'; +import lodash from 'lodash'; + +export default class extends Migration { + on = 'afterLoad'; // 'beforeLoad' or 'afterLoad' + appVersion = '<=1.3.0-alpha'; + + async up() { + await this.db.sequelize.transaction(async (transaction) => { + const treeCollections = await this.app.db.getRepository('collections').find({ + appends: ['fields'], + filter: { + 'options.tree': 'adjacencyList', + }, + transaction, + }); + + for (const treeCollection of treeCollections) { + const name = `main_${treeCollection.name}_path`; + this.app.db.collection({ + name, + autoGenId: false, + timestamps: false, + fields: [ + { type: 'integer', name: 'nodePk' }, + { type: 'string', name: 'path', length: 1024 }, + { type: 'integer', name: 'rootPk' }, + ], + indexes: [ + { + fields: [{ name: 'path', length: 191 }], + }, + ], + }); + const treeExistsInDb = await this.app.db.getCollection(name).existsInDb({ transaction }); + if (!treeExistsInDb) { + await this.app.db.getCollection(name).sync({ transaction } as SyncOptions); + this.app.db.collection({ + name: treeCollection.name, + autoGenId: false, + timestamps: false, + fields: [ + { type: 'integer', name: 'id' }, + { type: 'integer', name: 'parentId' }, + ], + }); + const chunkSize = 1000; + await this.app.db.getRepository(treeCollection.name).chunk({ + chunkSize: chunkSize, + callback: async (rows, options) => { + const pathData = []; + for (const data of rows) { + let path = `/${data.get('id')}`; + path = await this.getTreePath(data, path, treeCollection, name, transaction); + pathData.push({ + nodePk: data.get('id'), + path: path, + rootPk: path.split('/')[1], + }); + } + await this.app.db.getModel(name).bulkCreate(pathData, { transaction }); + }, + transaction, + }); + } + } + }); + } + + async getTreePath( + model: Model, + path: string, + collection: Model, + pathCollectionName: string, + transaction: Transaction, + ) { + if (model.get('parentId') !== null) { + const parent = await this.app.db.getRepository(collection.name).findOne({ + filter: { + id: model.get('parentId') as any, + }, + transaction, + }); + if (parent && parent.get('parentId') !== model.get('id')) { + path = `/${parent.get('id')}${path}`; + const collectionTreePath = this.app.db.getCollection(pathCollectionName); + const nodePkColumnName = collectionTreePath.getField('nodePk').columnName(); + const parentPathData = await this.app.db.getRepository(pathCollectionName).findOne({ + filter: { + [nodePkColumnName]: parent.get('id'), + }, + transaction, + }); + const parentPath = lodash.get(parentPathData, 'path', null); + if (parentPath == null) { + path = await this.getTreePath(parent, path, collection, pathCollectionName, transaction); + } else { + path = `${parentPath}/${model.get('id')}`; + } + } + } + return path; + } +} diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/plugin.ts new file mode 100644 index 0000000000..93faf0f44d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/plugin.ts @@ -0,0 +1,195 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Plugin } from '@nocobase/server'; +import { Collection, Model, SyncOptions, DestroyOptions } from '@nocobase/database'; +import { DataSource, SequelizeCollectionManager } from '@nocobase/data-source-manager'; +import { Transaction } from 'sequelize'; +import lodash from 'lodash'; +import { TreeCollection } from './tree-collection'; + +const getFilterTargetKey = (model: Model) => { + // @ts-ignore + return model.constructor.collection.filterTargetKey; +}; + +class PluginCollectionTreeServer extends Plugin { + async beforeLoad() { + const condition = (options) => { + return options.tree; + }; + + this.app.db.collectionFactory.registerCollectionType(TreeCollection, { + condition, + }); + + this.app.dataSourceManager.afterAddDataSource((dataSource: DataSource) => { + const collectionManager = dataSource.collectionManager; + if (collectionManager instanceof SequelizeCollectionManager) { + collectionManager.db.on('afterDefineCollection', (collection: Collection) => { + if (!condition(collection.options)) { + return; + } + const name = `${dataSource.name}_${collection.name}_path`; + const parentForeignKey = collection.treeParentField?.foreignKey || 'parentId'; + + //always define tree path collection + this.defineTreePathCollection(name); + + //afterSync + collectionManager.db.on(`${collection.name}.afterSync`, async ({ transaction }) => { + // trigger tree path collection create logic + await this.db.getCollection(name).sync({ transaction } as SyncOptions); + }); + + //afterCreate + this.db.on(`${collection.name}.afterCreate`, async (model: Model, options) => { + const { transaction } = options; + const tk = getFilterTargetKey(model); + let path = `/${model.get(tk)}`; + path = await this.getTreePath(model, path, collection, name, transaction); + const rootPk = path.split('/')[1]; + await this.app.db.getRepository(name).create({ + values: { + nodePk: model.get(tk), + path: path, + rootPk: rootPk ? Number(rootPk) : null, + }, + transaction, + }); + }); + + //afterUpdate + this.db.on(`${collection.name}.afterUpdate`, async (model: Model, options) => { + const tk = getFilterTargetKey(model); + // only update parentId and filterTargetKey + if (!(model._changed.has(tk) || model._changed.has(parentForeignKey))) { + return; + } + const { transaction } = options; + let path = `/${model.get(tk)}`; + path = await this.getTreePath(model, path, collection, name, transaction); + const collectionTreePath = this.db.getCollection(name); + const nodePkColumnName = collectionTreePath.getField('nodePk').columnName(); + const pathData = await this.app.db.getRepository(name).findOne({ + filter: { + [nodePkColumnName]: model.get(tk), + }, + transaction, + }); + + const relatedNodes = await this.app.db.getRepository(name).find({ + filter: { + path: { + $startsWith: `${pathData.get('path')}`, + }, + }, + transaction, + }); + const rootPk = path.split('/')[1]; + for (const node of relatedNodes) { + await this.app.db.getRepository(name).update({ + values: { + path: node.get('path').replace(`${pathData.get('path')}`, path), + rootPk: rootPk ? Number(rootPk) : null, + }, + filter: { + [nodePkColumnName]: node.get('nodePk'), + }, + transaction, + }); + } + }); + + //afterDestroy + this.db.on(`${collection.name}.afterDestroy`, async (model: Model, options: DestroyOptions) => { + const tk = getFilterTargetKey(model); + await this.app.db.getRepository(name).destroy({ + filter: { + nodePk: model.get(tk), + }, + transaction: options.transaction, + }); + }); + }); + } + }); + + this.db.on('collections.afterDestroy', async (collection: Model, { transaction }) => { + const name = `main_${collection.get('name')}_path`; + if (!condition(collection.options)) { + return; + } + + const collectionTree = this.db.getCollection(name); + if (collectionTree) { + await this.db.getCollection(name).removeFromDb({ transaction }); + } + }); + } + + private async defineTreePathCollection(name: string) { + this.db.collection({ + name, + autoGenId: false, + timestamps: false, + fields: [ + { type: 'integer', name: 'nodePk' }, + { type: 'string', name: 'path', length: 1024 }, + { type: 'integer', name: 'rootPk' }, + ], + indexes: [ + { + fields: [{ name: 'path', length: 191 }], + }, + ], + }); + } + + private async getTreePath( + model: Model, + path: string, + collection: Collection, + pathCollectionName: string, + transaction?: Transaction, + ) { + const tk = getFilterTargetKey(model); + const parentForeignKey = collection.treeParentField?.foreignKey || 'parentId'; + if (model.get(parentForeignKey) && model.get(parentForeignKey) !== null) { + const parent = await this.app.db.getRepository(collection.name).findOne({ + filter: { + [tk]: model.get(parentForeignKey), + }, + transaction, + }); + if (parent && parent.get(parentForeignKey) !== model.get(tk)) { + path = `/${parent.get(tk)}${path}`; + if (parent.get(parentForeignKey) !== null) { + const collectionTreePath = this.app.db.getCollection(pathCollectionName); + const nodePkColumnName = collectionTreePath.getField('nodePk').columnName(); + const parentPathData = await this.app.db.getRepository(pathCollectionName).findOne({ + filter: { + [nodePkColumnName]: parent.get(tk), + }, + transaction, + }); + const parentPath = lodash.get(parentPathData, 'path', null); + if (parentPath == null) { + path = await this.getTreePath(parent, path, collection, pathCollectionName, transaction); + } else { + path = `${parentPath}/${model.get(tk)}`; + } + } + } + } + return path; + } +} + +export default PluginCollectionTreeServer; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/server/tree-collection.ts b/packages/plugins/@nocobase/plugin-collection-tree/src/server/tree-collection.ts new file mode 100644 index 0000000000..a291ac482c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/server/tree-collection.ts @@ -0,0 +1,21 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Collection } from '@nocobase/database'; +import { AdjacencyListRepository } from './adjacency-list-repository'; + +export class TreeCollection extends Collection { + setRepository() { + this.repository = new AdjacencyListRepository(this); + } + + get treeForeignKey() { + return this.treeParentField?.options.foreignKey || 'parent'; + } +} diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/find.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/find.test.ts deleted file mode 100644 index a5ad1973b3..0000000000 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/find.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import { Database } from '@nocobase/database'; -import { MockServer } from '@nocobase/test'; -import { createApp } from '../index'; - -describe('find with association', () => { - let app: MockServer; - let agent; - - let db: Database; - beforeEach(async () => { - app = await createApp(); - - agent = app.agent(); - db = app.db; - }); - - afterEach(async () => { - await app.destroy(); - }); - - it('should filter by association field', async () => { - await db.getRepository('collections').create({ - values: { - name: 'users', - tree: 'adjacency-list', - fields: [ - { type: 'string', name: 'name' }, - { type: 'hasMany', name: 'posts', target: 'posts', foreignKey: 'user_id' }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'parent_id', - treeParent: true, - target: 'users', - }, - { - type: 'hasMany', - name: 'children', - foreignKey: 'parent_id', - treeChildren: true, - target: 'users', - }, - ], - }, - context: {}, - }); - - await db.getRepository('collections').create({ - values: { - name: 'posts', - fields: [ - { type: 'string', name: 'title' }, - { type: 'belongsTo', name: 'user', target: 'users', foreignKey: 'user_id' }, - ], - }, - context: {}, - }); - - const UserCollection = db.getCollection('users'); - - expect(UserCollection.options.tree).toBeTruthy(); - - await db.getRepository('users').create({ - values: [ - { - name: 'u1', - posts: [ - { - title: 'u1p1', - }, - ], - children: [ - { - name: 'u2', - posts: [ - { - title: '标题2', - }, - ], - }, - ], - }, - { - name: 'u3', - children: [ - { - name: 'u4', - posts: [ - { - title: '标题五', - }, - ], - }, - ], - }, - ], - }); - - const filter = { - $and: [ - { - children: { - posts: { - title: { - $eq: '标题五', - }, - }, - }, - }, - ], - }; - - const items = await db.getRepository('users').find({ - filter, - appends: ['children'], - }); - - expect(items[0].name).toEqual('u3'); - - const response2 = await agent.resource('users').list({ - filter, - appends: ['children'], - }); - - expect(response2.statusCode).toEqual(200); - expect(response2.body.data[0].name).toEqual('u3'); - }); -}); diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/tree.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/tree.test.ts deleted file mode 100644 index 20ab129aeb..0000000000 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/server/__tests__/http-api/tree.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import { MockServer } from '@nocobase/test'; -import { Database } from '@nocobase/database'; -import { createApp } from '../index'; - -describe('tree', () => { - let app: MockServer; - let agent; - - let db: Database; - beforeEach(async () => { - app = await createApp(); - - agent = app.agent(); - db = app.db; - }); - - afterEach(async () => { - await app.destroy(); - }); - - it('should list tree', async () => { - await db.getRepository('collections').create({ - values: { - name: 'categories', - tree: 'adjacency-list', - fields: [ - { type: 'string', name: 'name' }, - { - type: 'belongsTo', - name: 'parent', - foreignKey: 'parent_id', - treeParent: true, - target: 'categories', - }, - { - type: 'hasMany', - name: 'children', - foreignKey: 'parent_id', - treeChildren: true, - target: 'categories', - }, - ], - }, - context: {}, - }); - - const c1 = await db.getRepository('categories').create({ - values: { name: 'c1' }, - }); - - await db.getRepository('categories').create({ - values: [ - { - name: 'c2', - parent: { - name: 'c1', - id: c1.get('id'), - }, - }, - ], - }); - - const listResponse = await agent.resource('categories').list({ - appends: ['parent'], - }); - - expect(listResponse.statusCode).toBe(200); - - // update c1 - await db.getRepository('categories').update({ - filter: { - name: 'c1', - }, - values: { - __index: '1231', // should ignore - name: 'c11', - }, - }); - - await c1.reload(); - - expect(c1.get('name')).toBe('c11'); - }); -}); diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index 81df40af4c..18c7d275c4 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -24,6 +24,7 @@ "@nocobase/plugin-calendar": "1.3.0-alpha", "@nocobase/plugin-charts": "1.3.0-alpha", "@nocobase/plugin-client": "1.3.0-alpha", + "@nocobase/plugin-collection-tree": "1.3.0-alpha", "@nocobase/plugin-collection-sql": "1.3.0-alpha", "@nocobase/plugin-data-source-main": "1.3.0-alpha", "@nocobase/plugin-data-source-manager": "1.3.0-alpha", diff --git a/packages/presets/nocobase/src/server/index.ts b/packages/presets/nocobase/src/server/index.ts index 7bc069a99c..44d8a948ed 100644 --- a/packages/presets/nocobase/src/server/index.ts +++ b/packages/presets/nocobase/src/server/index.ts @@ -53,6 +53,7 @@ export class PresetNocoBase extends Plugin { 'action-duplicate', 'action-print', 'collection-sql', + 'collection-tree', ]; localPlugins = [