feat: allows to filter child nodes in tree table blocks (#5096)
Some checks are pending
Build Docker Image / build-and-push (push) Waiting to run
Build Pro Image / build-and-push (push) Waiting to run
E2E / Build (push) Waiting to run
E2E / Core and plugins (push) Blocked by required conditions
E2E / plugin-workflow (push) Blocked by required conditions
E2E / plugin-workflow-approval (push) Blocked by required conditions
E2E / plugin-data-source-main (push) Blocked by required conditions
E2E / Comment on PR (push) Blocked by required conditions
NocoBase Backend Test / sqlite-test (20, false) (push) Waiting to run
NocoBase Backend Test / sqlite-test (20, true) (push) Waiting to run
NocoBase Backend Test / postgres-test (public, 20, nocobase, false) (push) Waiting to run
NocoBase Backend Test / postgres-test (public, 20, nocobase, true) (push) Waiting to run
NocoBase Backend Test / postgres-test (public, 20, public, false) (push) Waiting to run
NocoBase Backend Test / postgres-test (public, 20, public, true) (push) Waiting to run
NocoBase Backend Test / postgres-test (user_schema, 20, nocobase, false) (push) Waiting to run
NocoBase Backend Test / postgres-test (user_schema, 20, nocobase, true) (push) Waiting to run
NocoBase Backend Test / postgres-test (user_schema, 20, public, false) (push) Waiting to run
NocoBase Backend Test / postgres-test (user_schema, 20, public, true) (push) Waiting to run
NocoBase Backend Test / mysql-test (20, false) (push) Waiting to run
NocoBase Backend Test / mysql-test (20, true) (push) Waiting to run
NocoBase Backend Test / mariadb-test (20, false) (push) Waiting to run
NocoBase Backend Test / mariadb-test (20, true) (push) Waiting to run
NocoBase FrontEnd Test / frontend-test (18) (push) Waiting to run
Test on Windows / build (push) Waiting to run

* feat(database): allows to filter child nodes in tree collections (#4770)

* feat(collection-tree): add collection tree plugin

* feat(collection-tree): add collection tree path handle function

* feat(collection-tree): add collection tree path root and depth column handle function

* feat(collection-tree): add exist tree collection data migrate function

* feat(collection-tree): improve exist tree collection data migrate function

* feat(collection-tree): improve exist tree collection data migrate function

* feat(collection-tree): add collection tree to build in plugin

* feat(collection-tree): modify collection tree plugin version

* feat(collection-tree): fix collection tree build bug

* feat(collection-tree): fix tree collection path create bug and add tree table search function

* feat(collection-tree): add tree table pagination function

* feat(collection-tree): fix tree table pagination function

* feat(collection-tree): fix tree table pagination function

* feat(collection-tree): fix tree table search function

* feat(collection-tree): fix tree table search function

* feat(collection-tree): improve tree table search filter function

* feat(collection-tree): update collection tree plugin version and preset package dependancy

* feat(collection-tree): improve tree collection function

* feat(collection-tree): remove the duplicate function

* feat(collection-tree): fix tree collection db exist bug

* feat(collection-tree): improve tree collection db  variable name

* feat(collection-tree): remove migration file

* feat(collection-tree): add collection tree sync create function

* feat(collection-tree): add collection tree sync create function

* feat(collection-tree): disable collection tree search function

* feat(collection-tree): enable collection tree search function

* feat(collection-tree): modify collection tree signal to adapt test case

* feat(collection-tree): modify code to fix test case

* feat(collection-tree): improve code and abstract define collection tree path

* feat(collection-tree): modify rootPK to rootPk to avoid underscored behavior

* feat(collection-tree): update collection tree version and add collection tree plugin for test

* feat(collection-tree): fix filter collection tree root null bug

* feat(collection-tree): migrate tree test case to collection tree plugin directory

* feat(collection-tree): add transaction for collection tree table delete

* feat(collection-tree): fix collection tree switch bug

* feat(collection-tree): remove tree filter switch

* feat(collection-tree): fix test case

* feat(collection-tree): fix test case

* feat(collection-tree): fix DB UNDERSCORED bug

* feat(collection-tree): fix relate collections not exist bug

* feat(collection-tree): add common parent function

* feat(collection-tree): add compatible function for sqlite

* feat(collection-tree): modify collection tree path create method

* feat(collection-tree): migrate tree test case to no acl

* feat(collection-tree): migrate tree test case to no acl and fix collections undefined bug

* feat(collection-tree): migrate tree test case

* feat(collection-tree): fix test case bug

* feat(collection-tree): fix test case bug

* feat(collection-tree): fix test case bug

* feat(collection-tree): improve tree search function

* feat(collection-tree): merge the next branch code to fix confilct

* feat(collection-tree): merge the next branch code to fix confilct

* feat(collection-tree): merge the next branch code to fix confilct

* feat(collection-tree): split the collection tree test to new file and fix filterbytk bug

* feat(collection-tree): fix filter tree collection primary key bug

* feat(collection-tree): remove recursive test case and fix collection tree filter bug

* feat(collection-tree): fix collection tree filter bug

* feat(collection-tree): fix collection tree filter bug and modify test case

* feat(collection-tree): add parentid column for tree collection and modify test case

* feat(collection-tree): disable sync exist tree collection path table create logic

* feat(collection-tree): add sync exist tree collection path table create logic on plugin afterLoad

* feat(collection-tree): remove debug code

* feat(collection-tree): fix collection tree delete bug

* feat(collection-tree): improve collection tree filter find and count implement

* feat(collection-tree): improve path table name variable implement

* feat(collection-tree): remove unnecessary plugin for test case code

* feat(collection-tree): add await for delete synchronous path function

* feat(collection-tree): improve tree path create function

* feat(collection-tree): remove unnecessary code

* feat(collection-tree): remove unnecessary code

* feat(collection-tree): improve tree path create function

* feat(collection-tree): improve tree filter function

* feat(collection-tree): improve tree filter datasource to dynamic

* feat(collection-tree): improve find common parent code

* feat(collection-tree): add collection tree path table not found warning log

* feat(collection-tree): improve get collection primary key implementation

* feat(collection-tree): fix tree root path not delete bug and tree path definition bug

* feat(collection-tree): fix findAndCount function variable definition bug

* feat(collection-tree): modify migrate exist collection tree migration function

* feat(collection-tree): correct variable name

* feat(collection-tree): remove duplicate code

* feat(collection-tree): fix sync exist collection tree path function variable bug

* feat(collection-tree): improve collection tree path update logic

* feat(collection-tree): remove await for get collection

* test: add test cases

* feat(collection-tree): modify filter parameter for collection tree

* feat(collection-tree): remove await for get collection

* feat(collection-tree): remove necessary code

* feat(collection-tree): add exist tree collection path migration function

* feat(collection-tree): remove unused sync exist tree collection table function

* feat(collection-tree): use get method to replace use dataValues function

* feat(collection-tree): use get method to replace use dataValues function

* feat(collection-tree): fix migration rootPk variable bug

* feat(collection-tree): use get method to replace use dataValues function

* feat(collection-tree): improve get tree path logic

* feat(collection-tree): remove unused test case code

* feat(collection-tree): add sync collection tree test case

* feat(collection-tree): add tree path test case and fix migration bug

* feat(collection-tree): add migration db variable

* feat(collection-tree): change logger function

* feat(collection-tree): remove unused library

* feat(collection-tree): add plugin information

* feat(collection-tree): remove await for get collection and use this.db instead of this.app.db

* feat(collection-tree): improve the performance of exist data migration to  path table

* feat(collection-tree): modify get tree path implement to avoid infinite loop bug

* feat(collection-tree): fix path create bug

* feat(collection-tree): add index for path table

* feat(collection-tree): fix related node path bug when some node parent changed

* feat(collection-tree): add transaction for get tree path function

* feat(collection-tree): add tree path test case

* feat(collection-tree): change parent field id name to dynamic

* feat(collection-tree): migrate some test case to path.test.ts file

* feat(collection-tree): add some test case for path table

* feat(collection-tree): fix sqlite and mysql json type search path data bug

* feat(collection-tree): fix sqlite query related data sql bug

* feat(collection-tree): fix list action test case query data with related data bug

* feat(collection-tree): try to fix mysql test case bug to remove index

* feat(collection-tree): remove unnecessary code

* chore: string field with length option

* feat(collection-tree): change path type to string and set max length to 1024

* fix: merge conflicts

* feat(collection-tree): modify query path filter to adapt the path change to string

* feat(collection-tree): remove append parent condition

* feat(collection-tree): split the path test case

* feat(collection-tree): remove unused code and fix test case plugin bug

* feat(collection-tree): improve get tree path implementation

* feat(collection-tree): disable one failed test case for full test

* feat(collection-tree): add transaction for collection tree migration

* feat(collection-tree): fix collection tree migration bug

* feat(collection-tree): remove sqlite handle condition code

* feat(collection-tree): add tree test case

* feat(collection-tree): add tree test case

* feat(collection-tree): modify test case to match the expection

* feat(collection-tree): modify tree implementation to root parent

* feat(collection-tree): remove unused function

* feat(collection-tree): add count function to solve tree data pagination implementation

* feat(collection-tree): split tree failed test case to legacy file

* feat(collection-tree): correct legacy tree test case to expection

* feat(collection-tree): add new tree test for legacy test case to meet expection

* feat(collection-tree): fix legacy test case to meet expection

* feat(collection-tree): modify legacy test case to meet expection and add new test case

* feat(collection-tree): improve tree test case

* feat(collection-tree): improve tree test case

* feat(collection-tree): improve tree test case

* feat(collection-tree): add tree test case

* feat(collection-tree): add tree count test case

* feat(collection-tree): add tree find and count test case

* feat(collection-tree): add tree find one test case

* feat(collection-tree): improve migration get path performance

* feat(collection-tree): improve get tree path function to avoid undefined parent data situation

* feat(collection-tree): add tree and raw parameter variable

* fix: test

* feat(collection-tree): modify index length

* feat(collection-tree): fix index snake case bug

* feat(collection-tree): improve tree filter search

* feat(collection-tree): fix tree search pagination parameter omit bug

* chore: optimize

* chore: optimize

* fix: build

* fix: type

* fix: bug

* chore: move tree repo to plugin

* fix: build

* fix: test

* chore: remove ts-ignore

* fix: issue of getting filter target key

---------

Co-authored-by: xilesun <2013xile@gmail.com>
Co-authored-by: Chareice <chareice@live.com>

* fix(tree): issue of building tree node map (#5072)

---------

Co-authored-by: jimmy201602 <zhengge2012@gmail.com>
Co-authored-by: xilesun <2013xile@gmail.com>
Co-authored-by: Chareice <chareice@live.com>
This commit is contained in:
chenos 2024-08-21 14:57:01 +08:00 committed by GitHub
parent 74769abb46
commit 1ac6343049
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 3171 additions and 1423 deletions

View File

@ -147,285 +147,3 @@ describe('list action', () => {
expect(response.body.count).toEqual(0); 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');
});
});

View File

@ -17,23 +17,15 @@ function totalPage(total, pageSize): number {
} }
function findArgs(ctx: Context) { function findArgs(ctx: Context) {
const resourceName = ctx.action.resourceName;
const params = ctx.action.params; 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 }; return { tree, filter, fields, appends, except, sort };
} }

View File

@ -22,6 +22,28 @@ describe('string field', () => {
await db.close(); 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 () => { it('define', async () => {
const Test = db.collection({ const Test = db.collection({
name: 'tests', name: 'tests',

View File

@ -498,86 +498,6 @@ describe('find with associations', () => {
expect(filterResult[0].get('user').get('department')).toBeDefined(); 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 () => { it('should find with associations with sort params', async () => {
const User = db.collection({ const User = db.collection({
name: 'users', name: 'users',

View File

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

View File

@ -23,7 +23,6 @@ import { BuiltInGroup } from './collection-group-manager';
import { Database } from './database'; import { Database } from './database';
import { BelongsToField, Field, FieldOptions, HasManyField } from './fields'; import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
import { Model } from './model'; import { Model } from './model';
import { AdjacencyListRepository } from './repositories/tree-repository/adjacency-list-repository';
import { Repository } from './repository'; import { Repository } from './repository';
import { checkIdentifier, md5, snakeCase } from './utils'; import { checkIdentifier, md5, snakeCase } from './utils';
import safeJsonStringify from 'safe-json-stringify'; import safeJsonStringify from 'safe-json-stringify';
@ -268,11 +267,6 @@ export class Collection<
if (typeof repository === 'string') { if (typeof repository === 'string') {
repo = this.context.database.repositories.get(repository) || Repository; 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); this.repository = new repo(this);
} }

View File

@ -469,6 +469,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
options.indexes = options.indexes.map((index) => { options.indexes = options.indexes.map((index) => {
if (index.fields) { if (index.fields) {
index.fields = index.fields.map((field) => { index.fields = index.fields.map((field) => {
if (field.name) {
return { name: snakeCase(field.name), ...field };
}
return snakeCase(field); return snakeCase(field);
}); });
} }

View File

@ -12,7 +12,7 @@ import { Association, HasOne, HasOneOptions, Includeable, Model, ModelStatic, Op
import Database from '../database'; import Database from '../database';
import { appendChildCollectionNameAfterRepositoryFind } from '../listeners/append-child-collection-name-after-repository-find'; import { appendChildCollectionNameAfterRepositoryFind } from '../listeners/append-child-collection-name-after-repository-find';
import { OptionsParser } from '../options-parser'; import { OptionsParser } from '../options-parser';
import { AdjacencyListRepository } from '../repositories/tree-repository/adjacency-list-repository'; import { Collection } from '../collection';
interface EagerLoadingNode { interface EagerLoadingNode {
model: ModelStatic<any>; model: ModelStatic<any>;
@ -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 { export class EagerLoadingTree {
public root: EagerLoadingNode; public root: EagerLoadingNode;
db: Database; db: Database;
@ -356,7 +383,7 @@ export class EagerLoadingTree {
// load parent instances recursively // load parent instances recursively
if (node.includeOption.recursively && instances.length > 0) { if (node.includeOption.recursively && instances.length > 0) {
const targetKey = association.targetKey; const targetKey = association.targetKey;
const sql = AdjacencyListRepository.queryParentSQL({ const sql = queryParentSQL({
db: this.db, db: this.db,
collection, collection,
foreignKey, foreignKey,

View File

@ -12,10 +12,15 @@ import { BaseColumnFieldOptions, Field } from './field';
export class StringField extends Field { export class StringField extends Field {
get dataType() { get dataType() {
if (this.options.length) {
return DataTypes.STRING(this.options.length);
}
return DataTypes.STRING; return DataTypes.STRING;
} }
} }
export interface StringFieldOptions extends BaseColumnFieldOptions { export interface StringFieldOptions extends BaseColumnFieldOptions {
type: 'string'; type: 'string';
length?: number;
} }

View File

@ -25,7 +25,7 @@ import { updateModelByValues } from '../update-associations';
import { UpdateGuard } from '../update-guard'; import { UpdateGuard } from '../update-guard';
import { RelationRepository, transaction } from './relation-repository'; import { RelationRepository, transaction } from './relation-repository';
export type FindAndCountOptions = CommonFindOptions; type FindAndCountOptions = CommonFindOptions;
export interface AssociatedOptions extends Transactionable { export interface AssociatedOptions extends Transactionable {
tk?: TK; tk?: TK;

View File

@ -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<any> {
return super.update({
...(options || {}),
addIndex: false,
});
}
async find(options: FindOptions & { addIndex?: boolean } = {}): Promise<any> {
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
`;
}
}

View File

@ -134,7 +134,7 @@ export interface DestroyOptions extends SequelizeDestroyOptions {
context?: any; context?: any;
} }
type FindAndCountOptions = Omit<SequelizeAndCountOptions, 'where' | 'include' | 'order'> & CommonFindOptions; export type FindAndCountOptions = Omit<SequelizeAndCountOptions, 'where' | 'include' | 'order'> & CommonFindOptions;
export interface CreateOptions extends SequelizeCreateOptions { export interface CreateOptions extends SequelizeCreateOptions {
values?: Values | Values[]; values?: Values | Values[];

View File

@ -358,121 +358,4 @@ describe('list association action with acl', () => {
expect(data['meta']['allowedActions'].view).toContain(1); expect(data['meta']['allowedActions'].view).toContain(1);
expect(data['meta']['allowedActions'].view).toContain(2); 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]);
});
}); });

View File

@ -13,7 +13,16 @@ export async function prepareApp(): Promise<MockServer> {
const app = await createMockServer({ const app = await createMockServer({
registerActions: true, registerActions: true,
acl: 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; return app;

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-collection-tree

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

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

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -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<WebAssembly.Instance>;
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;
}

View File

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

View File

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

View File

@ -7,19 +7,22 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { mockDatabase } from './'; import { prepareApp } from './prepare';
import Database from '../database'; import { Database } from '@nocobase/database';
import { MockServer } from '@nocobase/test';
describe('adjacency list repository', () => { describe('adjacency list repository', () => {
let app: MockServer;
let db: Database; let db: Database;
beforeEach(async () => { beforeEach(async () => {
db = mockDatabase(); app = await prepareApp();
db = app.db;
await db.clean({ drop: true }); await db.clean({ drop: true });
}); });
afterEach(async () => { afterEach(async () => {
await db.close(); await app.destroy();
}); });
it('should append relation parent recursively with belongs to assoc', async () => { it('should append relation parent recursively with belongs to assoc', async () => {

View File

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

View File

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

View File

@ -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<MockServer> {
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;
}

View File

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

View File

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

View File

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

View File

@ -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<any> {
return super.update({
...(options || {}),
addIndex: false,
});
}
buildRootNodeDataMap(nodeData: Model[]) {
const rootPathDataMap: {
[key: string]: Set<string>;
} = {};
for (const node of nodeData) {
const rootPk = node.get('rootPk');
const pathSet = new Set<string>(
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<any> {
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<number> {
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<any> {
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<number> {
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<Model[]> {
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<Model[]> {
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 [];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@
"@nocobase/plugin-calendar": "1.3.0-alpha", "@nocobase/plugin-calendar": "1.3.0-alpha",
"@nocobase/plugin-charts": "1.3.0-alpha", "@nocobase/plugin-charts": "1.3.0-alpha",
"@nocobase/plugin-client": "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-collection-sql": "1.3.0-alpha",
"@nocobase/plugin-data-source-main": "1.3.0-alpha", "@nocobase/plugin-data-source-main": "1.3.0-alpha",
"@nocobase/plugin-data-source-manager": "1.3.0-alpha", "@nocobase/plugin-data-source-manager": "1.3.0-alpha",

View File

@ -53,6 +53,7 @@ export class PresetNocoBase extends Plugin {
'action-duplicate', 'action-duplicate',
'action-print', 'action-print',
'collection-sql', 'collection-sql',
'collection-tree',
]; ];
localPlugins = [ localPlugins = [