mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 03:46:08 +00:00
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
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:
parent
74769abb46
commit
1ac6343049
@ -147,285 +147,3 @@ describe('list action', () => {
|
||||
expect(response.body.count).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list-tree', () => {
|
||||
let app;
|
||||
beforeEach(async () => {
|
||||
app = actionMockServer();
|
||||
registerActions(app);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should be tree', async () => {
|
||||
const values = [
|
||||
{
|
||||
name: '1',
|
||||
__index: '0',
|
||||
children: [
|
||||
{
|
||||
name: '1-1',
|
||||
__index: '0.children.0',
|
||||
children: [
|
||||
{
|
||||
name: '1-1-1',
|
||||
__index: '0.children.0.children.0',
|
||||
children: [
|
||||
{
|
||||
name: '1-1-1-1',
|
||||
__index: '0.children.0.children.0.children.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '2',
|
||||
__index: '1',
|
||||
children: [
|
||||
{
|
||||
name: '2-1',
|
||||
__index: '1.children.0',
|
||||
children: [
|
||||
{
|
||||
name: '2-1-1',
|
||||
__index: '1.children.0.children.0',
|
||||
children: [
|
||||
{
|
||||
name: '2-1-1-1',
|
||||
__index: '1.children.0.children.0.children.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const db = app.db;
|
||||
const collection = db.collection({
|
||||
name: 'categories',
|
||||
tree: 'adjacency-list',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'parent',
|
||||
treeParent: true,
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'children',
|
||||
treeChildren: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.sync();
|
||||
|
||||
await db.getRepository('categories').create({
|
||||
values,
|
||||
});
|
||||
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('categories')
|
||||
.list({
|
||||
tree: true,
|
||||
fields: ['id', 'name'],
|
||||
sort: ['id'],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body.rows).toMatchObject(values);
|
||||
});
|
||||
|
||||
it('should be tree', async () => {
|
||||
const values = [
|
||||
{
|
||||
name: '1',
|
||||
__index: '0',
|
||||
children2: [
|
||||
{
|
||||
name: '1-1',
|
||||
__index: '0.children2.0',
|
||||
children2: [
|
||||
{
|
||||
name: '1-1-1',
|
||||
__index: '0.children2.0.children2.0',
|
||||
children2: [
|
||||
{
|
||||
name: '1-1-1-1',
|
||||
__index: '0.children2.0.children2.0.children2.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '2',
|
||||
__index: '1',
|
||||
children2: [
|
||||
{
|
||||
name: '2-1',
|
||||
__index: '1.children2.0',
|
||||
children2: [
|
||||
{
|
||||
name: '2-1-1',
|
||||
__index: '1.children2.0.children2.0',
|
||||
children2: [
|
||||
{
|
||||
name: '2-1-1-1',
|
||||
__index: '1.children2.0.children2.0.children2.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const db = app.db;
|
||||
const collection = db.collection({
|
||||
name: 'categories',
|
||||
tree: 'adjacency-list',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'parent',
|
||||
foreignKey: 'cid',
|
||||
treeParent: true,
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'children2',
|
||||
foreignKey: 'cid',
|
||||
treeChildren: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.sync();
|
||||
|
||||
await db.getRepository('categories').create({
|
||||
values,
|
||||
});
|
||||
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('categories')
|
||||
.list({
|
||||
tree: true,
|
||||
fields: ['id', 'name'],
|
||||
sort: ['id'],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body.rows).toMatchObject(values);
|
||||
});
|
||||
|
||||
it('should filter child nodes for tree', async () => {
|
||||
const values = [
|
||||
{
|
||||
name: 'A1',
|
||||
__index: '0',
|
||||
children3: [
|
||||
{
|
||||
name: 'B',
|
||||
__index: '0.children3.0',
|
||||
children3: [
|
||||
{
|
||||
name: 'C',
|
||||
__index: '0.children3.0.children3.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'A2',
|
||||
__index: '1',
|
||||
children3: [
|
||||
{
|
||||
name: 'B',
|
||||
__index: '1.children3.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const db = app.db;
|
||||
db.collection({
|
||||
name: 'categories',
|
||||
tree: 'adjacency-list',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'parent',
|
||||
foreignKey: 'cid',
|
||||
treeParent: true,
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'children3',
|
||||
foreignKey: 'cid',
|
||||
treeChildren: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.sync();
|
||||
|
||||
await db.getRepository('categories').create({
|
||||
values,
|
||||
});
|
||||
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('categories')
|
||||
.list({
|
||||
tree: true,
|
||||
fields: ['id', 'name'],
|
||||
appends: ['parent'],
|
||||
filter: {
|
||||
name: 'B',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
const rows = response.body.rows;
|
||||
expect(rows.length).toEqual(2);
|
||||
expect(rows[0].name).toEqual('B');
|
||||
expect(rows[1].name).toEqual('B');
|
||||
expect(rows[0].parent.name).toEqual('A1');
|
||||
expect(rows[1].parent.name).toEqual('A2');
|
||||
});
|
||||
});
|
||||
|
@ -17,23 +17,15 @@ function totalPage(total, pageSize): number {
|
||||
}
|
||||
|
||||
function findArgs(ctx: Context) {
|
||||
const resourceName = ctx.action.resourceName;
|
||||
const params = ctx.action.params;
|
||||
if (params.tree) {
|
||||
if (isValidFilter(params.filter)) {
|
||||
params.tree = false;
|
||||
} else {
|
||||
const [collectionName, associationName] = resourceName.split('.');
|
||||
const collection = ctx.db.getCollection(resourceName);
|
||||
// tree collection 或者关系表是 tree collection
|
||||
if (collection.options.tree && !(associationName && collectionName === collection.name)) {
|
||||
const foreignKey = collection.treeParentField?.foreignKey || 'parentId';
|
||||
assign(params, { filter: { [foreignKey]: null } }, { filter: 'andMerge' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const { tree, fields, filter, appends, except, sort } = params;
|
||||
|
||||
const { fields, filter, appends, except, sort } = params;
|
||||
let { tree } = params;
|
||||
if (tree === true || tree === 'true') {
|
||||
tree = true;
|
||||
} else {
|
||||
tree = false;
|
||||
}
|
||||
return { tree, filter, fields, appends, except, sort };
|
||||
}
|
||||
|
||||
|
@ -22,6 +22,28 @@ describe('string field', () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it.skipIf(process.env['DB_DIALECT'] === 'sqlite')('should define string with length options', async () => {
|
||||
const Test = db.collection({
|
||||
name: 'tests',
|
||||
fields: [{ type: 'string', name: 'name', length: 10 }],
|
||||
});
|
||||
await db.sync();
|
||||
|
||||
let err;
|
||||
|
||||
try {
|
||||
await Test.repository.create({
|
||||
values: {
|
||||
name: '123456789011',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
||||
expect(err).toBeDefined();
|
||||
});
|
||||
|
||||
it('define', async () => {
|
||||
const Test = db.collection({
|
||||
name: 'tests',
|
||||
|
@ -498,86 +498,6 @@ describe('find with associations', () => {
|
||||
expect(filterResult[0].get('user').get('department')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter by association field', async () => {
|
||||
const User = db.collection({
|
||||
name: 'users',
|
||||
tree: 'adjacency-list',
|
||||
fields: [
|
||||
{ type: 'string', name: 'name' },
|
||||
{ type: 'hasMany', name: 'posts', target: 'posts', foreignKey: 'user_id' },
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'parent',
|
||||
foreignKey: 'parent_id',
|
||||
treeParent: true,
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'children',
|
||||
foreignKey: 'parent_id',
|
||||
treeChildren: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const Post = db.collection({
|
||||
name: 'posts',
|
||||
fields: [
|
||||
{ type: 'string', name: 'title' },
|
||||
{ type: 'belongsTo', name: 'user', target: 'users', foreignKey: 'user_id' },
|
||||
],
|
||||
});
|
||||
|
||||
await db.sync();
|
||||
|
||||
expect(User.options.tree).toBeTruthy();
|
||||
|
||||
await User.repository.create({
|
||||
values: [
|
||||
{
|
||||
name: 'u1',
|
||||
posts: [
|
||||
{
|
||||
title: 'u1p1',
|
||||
},
|
||||
],
|
||||
children: [
|
||||
{
|
||||
name: 'u2',
|
||||
posts: [
|
||||
{
|
||||
title: '标题2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const filter = {
|
||||
$and: [
|
||||
{
|
||||
children: {
|
||||
posts: {
|
||||
title: {
|
||||
$eq: '标题2',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [findResult, count] = await User.repository.findAndCount({
|
||||
filter,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(findResult[0].get('name')).toEqual('u1');
|
||||
});
|
||||
|
||||
it('should find with associations with sort params', async () => {
|
||||
const User = db.collection({
|
||||
name: 'users',
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -23,7 +23,6 @@ import { BuiltInGroup } from './collection-group-manager';
|
||||
import { Database } from './database';
|
||||
import { BelongsToField, Field, FieldOptions, HasManyField } from './fields';
|
||||
import { Model } from './model';
|
||||
import { AdjacencyListRepository } from './repositories/tree-repository/adjacency-list-repository';
|
||||
import { Repository } from './repository';
|
||||
import { checkIdentifier, md5, snakeCase } from './utils';
|
||||
import safeJsonStringify from 'safe-json-stringify';
|
||||
@ -268,11 +267,6 @@ export class Collection<
|
||||
if (typeof repository === 'string') {
|
||||
repo = this.context.database.repositories.get(repository) || Repository;
|
||||
}
|
||||
|
||||
if (this.options.tree == 'adjacency-list' || this.options.tree == 'adjacencyList') {
|
||||
repo = AdjacencyListRepository;
|
||||
}
|
||||
|
||||
this.repository = new repo(this);
|
||||
}
|
||||
|
||||
|
@ -469,6 +469,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
options.indexes = options.indexes.map((index) => {
|
||||
if (index.fields) {
|
||||
index.fields = index.fields.map((field) => {
|
||||
if (field.name) {
|
||||
return { name: snakeCase(field.name), ...field };
|
||||
}
|
||||
return snakeCase(field);
|
||||
});
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import { Association, HasOne, HasOneOptions, Includeable, Model, ModelStatic, Op
|
||||
import Database from '../database';
|
||||
import { appendChildCollectionNameAfterRepositoryFind } from '../listeners/append-child-collection-name-after-repository-find';
|
||||
import { OptionsParser } from '../options-parser';
|
||||
import { AdjacencyListRepository } from '../repositories/tree-repository/adjacency-list-repository';
|
||||
import { Collection } from '../collection';
|
||||
|
||||
interface EagerLoadingNode {
|
||||
model: ModelStatic<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 {
|
||||
public root: EagerLoadingNode;
|
||||
db: Database;
|
||||
@ -356,7 +383,7 @@ export class EagerLoadingTree {
|
||||
// load parent instances recursively
|
||||
if (node.includeOption.recursively && instances.length > 0) {
|
||||
const targetKey = association.targetKey;
|
||||
const sql = AdjacencyListRepository.queryParentSQL({
|
||||
const sql = queryParentSQL({
|
||||
db: this.db,
|
||||
collection,
|
||||
foreignKey,
|
||||
|
@ -12,10 +12,15 @@ import { BaseColumnFieldOptions, Field } from './field';
|
||||
|
||||
export class StringField extends Field {
|
||||
get dataType() {
|
||||
if (this.options.length) {
|
||||
return DataTypes.STRING(this.options.length);
|
||||
}
|
||||
|
||||
return DataTypes.STRING;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StringFieldOptions extends BaseColumnFieldOptions {
|
||||
type: 'string';
|
||||
length?: number;
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ import { updateModelByValues } from '../update-associations';
|
||||
import { UpdateGuard } from '../update-guard';
|
||||
import { RelationRepository, transaction } from './relation-repository';
|
||||
|
||||
export type FindAndCountOptions = CommonFindOptions;
|
||||
type FindAndCountOptions = CommonFindOptions;
|
||||
|
||||
export interface AssociatedOptions extends Transactionable {
|
||||
tk?: TK;
|
||||
|
@ -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
|
||||
`;
|
||||
}
|
||||
}
|
@ -134,7 +134,7 @@ export interface DestroyOptions extends SequelizeDestroyOptions {
|
||||
context?: any;
|
||||
}
|
||||
|
||||
type FindAndCountOptions = Omit<SequelizeAndCountOptions, 'where' | 'include' | 'order'> & CommonFindOptions;
|
||||
export type FindAndCountOptions = Omit<SequelizeAndCountOptions, 'where' | 'include' | 'order'> & CommonFindOptions;
|
||||
|
||||
export interface CreateOptions extends SequelizeCreateOptions {
|
||||
values?: Values | Values[];
|
||||
|
@ -358,121 +358,4 @@ describe('list association action with acl', () => {
|
||||
expect(data['meta']['allowedActions'].view).toContain(1);
|
||||
expect(data['meta']['allowedActions'].view).toContain(2);
|
||||
});
|
||||
|
||||
it('tree list action allowActions', async () => {
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'newRole',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await db.getRepository('users').create({
|
||||
values: {
|
||||
roles: ['newRole'],
|
||||
},
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users');
|
||||
const agent = app.agent().login(user).set('X-With-ACL-Meta', true);
|
||||
app.acl.allow('table_a', ['*']);
|
||||
app.acl.allow('collections', ['*']);
|
||||
|
||||
await agent.resource('collections').create({
|
||||
values: {
|
||||
autoGenId: true,
|
||||
createdBy: false,
|
||||
updatedBy: false,
|
||||
createdAt: false,
|
||||
updatedAt: false,
|
||||
sortable: false,
|
||||
name: 'table_a',
|
||||
template: 'tree',
|
||||
tree: 'adjacency-list',
|
||||
fields: [
|
||||
{
|
||||
interface: 'integer',
|
||||
name: 'parentId',
|
||||
type: 'bigInt',
|
||||
isForeignKey: true,
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
title: '{{t("Parent ID")}}',
|
||||
'x-component': 'InputNumber',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
target: 'table_a',
|
||||
},
|
||||
{
|
||||
interface: 'm2o',
|
||||
type: 'belongsTo',
|
||||
name: 'parent',
|
||||
treeParent: true,
|
||||
foreignKey: 'parentId',
|
||||
uiSchema: {
|
||||
title: '{{t("Parent")}}',
|
||||
'x-component': 'AssociationField',
|
||||
'x-component-props': { multiple: false, fieldNames: { label: 'id', value: 'id' } },
|
||||
},
|
||||
target: 'table_a',
|
||||
},
|
||||
{
|
||||
interface: 'o2m',
|
||||
type: 'hasMany',
|
||||
name: 'children',
|
||||
foreignKey: 'parentId',
|
||||
uiSchema: {
|
||||
title: '{{t("Children")}}',
|
||||
'x-component': 'RecordPicker',
|
||||
'x-component-props': { multiple: true, fieldNames: { label: 'id', value: 'id' } },
|
||||
},
|
||||
treeChildren: true,
|
||||
target: 'table_a',
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
|
||||
interface: 'id',
|
||||
},
|
||||
],
|
||||
title: 'table_a',
|
||||
},
|
||||
});
|
||||
|
||||
await agent.resource('table_a').create({
|
||||
values: {},
|
||||
});
|
||||
|
||||
await agent.resource('table_a').create({
|
||||
values: {
|
||||
parent: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await agent.resource('table_a').create({
|
||||
values: {},
|
||||
});
|
||||
|
||||
await agent.resource('table_a').create({
|
||||
values: {
|
||||
parent: {
|
||||
id: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.resource('table_a').list({
|
||||
filter: JSON.stringify({
|
||||
parentId: null,
|
||||
}),
|
||||
tree: true,
|
||||
});
|
||||
|
||||
expect(res.body.meta.allowedActions.view.sort()).toMatchObject([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
@ -13,7 +13,16 @@ 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', 'auth', 'data-source-manager'],
|
||||
plugins: [
|
||||
'acl',
|
||||
'error-handler',
|
||||
'users',
|
||||
'ui-schema-storage',
|
||||
'data-source-main',
|
||||
'auth',
|
||||
'data-source-manager',
|
||||
'collection-tree',
|
||||
],
|
||||
});
|
||||
|
||||
return app;
|
||||
|
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/src
|
@ -0,0 +1 @@
|
||||
# @nocobase/plugin-collection-tree
|
2
packages/plugins/@nocobase/plugin-collection-tree/client.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-collection-tree/client.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/client';
|
||||
export { default } from './dist/client';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client/index.js');
|
@ -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"
|
||||
}
|
||||
}
|
2
packages/plugins/@nocobase/plugin-collection-tree/server.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-collection-tree/server.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/server';
|
||||
export { default } from './dist/server';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/server/index.js');
|
249
packages/plugins/@nocobase/plugin-collection-tree/src/client/client.d.ts
vendored
Normal file
249
packages/plugins/@nocobase/plugin-collection-tree/src/client/client.d.ts
vendored
Normal 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;
|
||||
}
|
@ -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;
|
@ -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';
|
@ -7,19 +7,22 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { mockDatabase } from './';
|
||||
import Database from '../database';
|
||||
import { prepareApp } from './prepare';
|
||||
import { Database } from '@nocobase/database';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
|
||||
describe('adjacency list repository', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = mockDatabase();
|
||||
app = await prepareApp();
|
||||
db = app.db;
|
||||
await db.clean({ drop: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should append relation parent recursively with belongs to assoc', async () => {
|
@ -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');
|
||||
});
|
||||
});
|
@ -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();
|
||||
// })
|
||||
});
|
@ -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;
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
// });
|
||||
});
|
@ -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 [];
|
||||
}
|
||||
}
|
@ -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';
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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';
|
||||
}
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -24,6 +24,7 @@
|
||||
"@nocobase/plugin-calendar": "1.3.0-alpha",
|
||||
"@nocobase/plugin-charts": "1.3.0-alpha",
|
||||
"@nocobase/plugin-client": "1.3.0-alpha",
|
||||
"@nocobase/plugin-collection-tree": "1.3.0-alpha",
|
||||
"@nocobase/plugin-collection-sql": "1.3.0-alpha",
|
||||
"@nocobase/plugin-data-source-main": "1.3.0-alpha",
|
||||
"@nocobase/plugin-data-source-manager": "1.3.0-alpha",
|
||||
|
@ -53,6 +53,7 @@ export class PresetNocoBase extends Plugin {
|
||||
'action-duplicate',
|
||||
'action-print',
|
||||
'collection-sql',
|
||||
'collection-tree',
|
||||
];
|
||||
|
||||
localPlugins = [
|
||||
|
Loading…
Reference in New Issue
Block a user