jimmy201602 6108e0818f
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>
2024-08-13 09:42:31 +08:00

631 lines
20 KiB
TypeScript

/**
* 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, { flatten } from 'lodash';
import { Association, HasOne, HasOneOptions, Includeable, Model, ModelStatic, Op, Transaction } from 'sequelize';
import Database from '../database';
import { appendChildCollectionNameAfterRepositoryFind } from '../listeners/append-child-collection-name-after-repository-find';
import { OptionsParser } from '../options-parser';
import { Collection } from '../collection';
interface EagerLoadingNode {
model: ModelStatic<any>;
association: Association;
attributes: Array<string>;
rawAttributes: Array<string>;
children: Array<EagerLoadingNode>;
parent?: EagerLoadingNode;
instances?: Array<Model>;
order?: any;
where?: any;
inspectInheritAttribute?: boolean;
includeOptions?: any;
}
const pushAttribute = (node, attribute) => {
if (lodash.isArray(node.attributes) && !node.attributes.includes(attribute)) {
node.attributes.push(attribute);
}
};
const EagerLoadingNodeProto = {
afterBuild(db: Database) {
const collection = db.modelCollection.get(this.model);
if (collection && collection.isParent()) {
if (!this.attributes) {
this.attributes = {
include: [],
};
}
OptionsParser.appendInheritInspectAttribute(
lodash.isArray(this.attributes) ? this.attributes : this.attributes.include,
collection,
);
this.inspectInheritAttribute = true;
}
},
};
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;
private rootQueryOptions: any = {};
constructor(root: EagerLoadingNode) {
this.root = root;
}
static buildFromSequelizeOptions(options: {
model: ModelStatic<any>;
rootAttributes: Array<string>;
rootOrder?: any;
rootQueryOptions?: any;
includeOption: Includeable | Includeable[];
db: Database;
}): EagerLoadingTree {
const { model, rootAttributes, includeOption, db, rootQueryOptions } = options;
const buildNode = (node) => {
Object.setPrototypeOf(node, EagerLoadingNodeProto);
node.afterBuild(db);
return node;
};
const root = buildNode({
model,
association: null,
rawAttributes: lodash.cloneDeep(rootAttributes),
attributes: lodash.cloneDeep(rootAttributes),
order: options.rootOrder,
children: [],
});
const traverseIncludeOption = (includeOption, eagerLoadingTreeParent) => {
const includeOptions = lodash.castArray(includeOption);
if (includeOption.length > 0) {
const modelPrimaryKey = eagerLoadingTreeParent.model.primaryKeyAttribute;
pushAttribute(eagerLoadingTreeParent, modelPrimaryKey);
}
for (const include of includeOptions) {
// skip fromFilter include option
if (include.fromFilter) {
continue;
}
const association = lodash.isString(include.association)
? eagerLoadingTreeParent.model.associations[include.association]
: include.association;
if (!association) {
throw new Error(
`Association "${include.association}" not found in model "${eagerLoadingTreeParent.model.name}"`,
);
}
const associationType = association.associationType;
const child = buildNode({
model: association.target,
association,
rawAttributes: lodash.cloneDeep(include.attributes),
attributes: lodash.cloneDeep(include.attributes),
parent: eagerLoadingTreeParent,
where: include.where,
children: [],
includeOption: include.options || {},
});
if (associationType == 'HasOne' || associationType == 'HasMany') {
const { sourceKey, foreignKey } = association;
pushAttribute(eagerLoadingTreeParent, sourceKey);
pushAttribute(child, foreignKey);
}
if (associationType == 'BelongsTo') {
const { targetKey, foreignKey } = association;
pushAttribute(eagerLoadingTreeParent, foreignKey);
pushAttribute(child, targetKey);
}
if (associationType == 'BelongsToMany') {
const { sourceKey } = association;
pushAttribute(eagerLoadingTreeParent, sourceKey);
}
eagerLoadingTreeParent.children.push(child);
if (include.include) {
traverseIncludeOption(include.include, child);
}
}
};
traverseIncludeOption(includeOption, root);
const tree = new EagerLoadingTree(root);
tree.db = db;
tree.rootQueryOptions = rootQueryOptions;
return tree;
}
async load(transaction?: Transaction) {
const result = {};
const orderOption = (association) => {
const targetModel = association.target;
const order = [];
if (targetModel.primaryKeyAttribute && targetModel.rawAttributes[targetModel.primaryKeyAttribute].autoIncrement) {
order.push([targetModel.primaryKeyAttribute, 'ASC']);
}
return order;
};
const loadRecursive = async (node, ids = []) => {
let instances = [];
if (!node.parent) {
// load root instances
const rootInclude = this.rootQueryOptions?.include || node.includeOption;
const includeForFilter = rootInclude.filter((include) => {
return (
Object.keys(include.where || {}).length > 0 ||
JSON.stringify(this.rootQueryOptions?.filter)?.includes(include.association)
);
});
const isBelongsToAssociationOnly = (includes, model) => {
for (const include of includes) {
const association = model.associations[include.association];
if (!association) {
return false;
}
if (association.associationType != 'BelongsTo') {
return false;
}
if (!isBelongsToAssociationOnly(include.include || [], association.target)) {
return false;
}
}
return true;
};
const belongsToAssociationsOnly = isBelongsToAssociationOnly(includeForFilter, node.model);
if (belongsToAssociationsOnly) {
instances = await node.model.findAll({
...this.rootQueryOptions,
attributes: node.attributes,
distinct: true,
include: includeForFilter,
transaction,
});
} else {
const primaryKeyField = node.model.primaryKeyField || node.model.primaryKeyAttribute;
if (!primaryKeyField) {
throw new Error(`Model ${node.model.name} does not have primary key`);
}
includeForFilter.forEach((include: { association: string }, index: number) => {
const association = node.model.associations[include.association];
if (association?.associationType == 'BelongsToArray') {
includeForFilter[index] = {
...include,
...association.generateInclude(),
};
}
});
// find all ids
const ids = (
await node.model.findAll({
...this.rootQueryOptions,
includeIgnoreAttributes: false,
attributes: [primaryKeyField],
group: `${node.model.name}.${primaryKeyField}`,
transaction,
include: includeForFilter,
} as any)
).map((row) => {
return { row, pk: row[primaryKeyField] };
});
const findOptions = {
where: { [primaryKeyField]: ids.map((i) => i.pk) },
attributes: node.attributes,
};
if (node.order) {
findOptions['order'] = node.order;
}
instances = await node.model.findAll({
...findOptions,
transaction,
});
}
// clear filter association value
const associations = node.model.associations;
for (const [name, association] of Object.entries(associations)) {
// if ((association as any).associationType === 'belongsToArray') {
// continue;
// }
for (const instance of instances) {
delete instance[name];
delete instance.dataValues[name];
}
}
} else if (ids.length > 0) {
const association = node.association;
const associationType = association.associationType;
let params: any = {};
const otherFindOptions = lodash.pick(node.includeOption, ['sort']) || {};
const collection = this.db.modelCollection.get(node.model);
if (collection && !lodash.isEmpty(otherFindOptions)) {
const parser = new OptionsParser(otherFindOptions, {
collection,
});
params = parser.toSequelizeParams();
}
if (associationType == 'HasOne' || associationType == 'HasMany') {
const foreignKey = association.foreignKey;
const foreignKeyValues = node.parent.instances.map((instance) => instance.get(association.sourceKey));
let where: any = { [foreignKey]: foreignKeyValues };
if (node.where) {
where = {
[Op.and]: [where, node.where],
};
}
const findOptions = {
where,
attributes: node.attributes,
order: params.order || orderOption(association),
transaction,
};
instances = await node.model.findAll(findOptions);
}
if (associationType === 'BelongsToArray') {
const targetKey = association.targetKey;
const targetKeyValues = node.parent.instances.map((instance) => {
return instance.get(association.foreignKey);
});
let where: any = { [targetKey]: Array.from(new Set(flatten(targetKeyValues))) };
if (node.where) {
where = {
[Op.and]: [where, node.where],
};
}
const findOptions = {
where,
attributes: node.attributes,
order: params.order || orderOption(association),
transaction,
};
instances = await node.model.findAll(findOptions);
}
if (associationType == 'BelongsTo') {
const foreignKey = association.foreignKey;
const parentInstancesForeignKeyValues = node.parent.instances.map((instance) => instance.get(foreignKey));
const collection = this.db.modelCollection.get(node.model);
instances = await node.model.findAll({
transaction,
where: {
[association.targetKey]: parentInstancesForeignKeyValues,
},
attributes: node.attributes,
});
// load parent instances recursively
if (node.includeOption.recursively && instances.length > 0) {
const targetKey = association.targetKey;
const sql = queryParentSQL({
db: this.db,
collection,
foreignKey,
targetKey,
nodeIds: instances.map((instance) => instance.get(targetKey)),
});
const results = await this.db.sequelize.query(sql, {
type: 'SELECT',
transaction,
});
const parentInstances = await node.model.findAll({
transaction,
where: {
[association.targetKey]: results.map((result) => result[targetKey]),
},
attributes: node.attributes,
});
const setInstanceParent = (instance) => {
const parentInstance = parentInstances.find(
(parentInstance) => parentInstance.get(targetKey) == instance.get(foreignKey),
);
if (!parentInstance) {
return;
}
setInstanceParent(parentInstance);
instance[association.as] = instance.dataValues[association.as] = parentInstance;
};
for (const instance of instances) {
setInstanceParent(instance);
}
}
}
if (associationType == 'BelongsToMany') {
const foreignKeyValues = node.parent.instances.map((instance) => instance.get(association.sourceKey));
const hasOneOptions: HasOneOptions = {
as: '_pivot_',
foreignKey: association.otherKey,
sourceKey: association.targetKey,
};
if (association.through.scope) {
hasOneOptions.scope = association.through.scope;
}
const pivotAssoc = new HasOne(association.target, association.through.model, hasOneOptions);
instances = await node.model.findAll({
transaction,
attributes: node.attributes,
include: [
{
association: pivotAssoc,
where: {
[association.foreignKey]: foreignKeyValues,
},
},
],
order: params.order || orderOption(association),
});
}
}
node.instances = instances;
for (const child of node.children) {
const modelPrimaryKey = node.model.primaryKeyField || node.model.primaryKeyAttribute;
const nodeIds = instances.map((instance) => instance.get(modelPrimaryKey));
await loadRecursive(child, nodeIds);
}
// merge instances to parent
if (!node.parent) {
return;
} else {
const association = node.association;
const associationType = association.associationType;
const setParentAccessor = (parentInstance) => {
const key = association.as;
if (!key) {
return;
}
const children = parentInstance.getDataValue(association.as);
if (association.isSingleAssociation) {
const isEmpty = !children;
parentInstance[key] = parentInstance.dataValues[key] = isEmpty ? null : children;
} else {
const isEmpty = !children || children.length == 0;
parentInstance[key] = parentInstance.dataValues[key] = isEmpty ? [] : children;
}
};
if (associationType == 'HasMany' || associationType == 'HasOne') {
const foreignKey = association.foreignKey;
const sourceKey = association.sourceKey;
for (const instance of node.instances) {
const parentInstance = node.parent.instances.find(
(parentInstance) => parentInstance.get(sourceKey) == instance.get(foreignKey),
);
if (parentInstance) {
if (associationType == 'HasMany') {
const children = parentInstance.getDataValue(association.as);
if (!children) {
parentInstance.setDataValue(association.as, [instance]);
} else {
children.push(instance);
}
}
if (associationType == 'HasOne') {
const key = association.options.realAs || association.as;
parentInstance[key] = parentInstance.dataValues[key] = instance;
}
}
}
}
if (associationType === 'BelongsToArray') {
const { foreignKey, targetKey } = association;
const instanceMap = node.instances.reduce((mp: { [targetKey: string]: Model }, instance: Model) => {
mp[instance.get(targetKey)] = instance;
return mp;
}, {});
node.parent.instances.forEach((parentInstance: Model) => {
const targetKeys = parentInstance.getDataValue(foreignKey);
parentInstance.setDataValue(
association.as,
targetKeys?.map((targetKey: any) => instanceMap[targetKey]).filter(Boolean),
);
});
}
if (associationType == 'BelongsTo') {
const foreignKey = association.foreignKey;
const targetKey = association.targetKey;
for (const instance of node.instances) {
const parentInstances = node.parent.instances.filter(
(parentInstance) => parentInstance.get(foreignKey) == instance.get(targetKey),
);
for (const parentInstance of parentInstances) {
parentInstance.setDataValue(association.as, instance);
}
}
}
if (associationType == 'BelongsToMany') {
const sourceKey = association.sourceKey;
const foreignKey = association.foreignKey;
const as = association.oneFromTarget.as;
for (const instance of node.instances) {
// set instance accessor
instance[as] = instance.dataValues[as] = instance['_pivot_'];
delete instance.dataValues['_pivot_'];
delete instance['_pivot_'];
const parentInstance = node.parent.instances.find(
(parentInstance) => parentInstance.get(sourceKey) == instance.dataValues[as].get(foreignKey),
);
if (parentInstance) {
const children = parentInstance.getDataValue(association.as);
if (!children) {
parentInstance.setDataValue(association.as, [instance]);
} else {
children.push(instance);
}
}
}
}
for (const parent of node.parent.instances) {
setParentAccessor(parent);
}
}
};
await loadRecursive(this.root);
const appendChildCollectionName = appendChildCollectionNameAfterRepositoryFind(this.db);
const setInstanceAttributes = (node) => {
if (node.inspectInheritAttribute) {
appendChildCollectionName({
findOptions: {},
data: node.instances,
dataCollection: this.db.modelCollection.get(node.model),
});
}
// skip pivot attributes
if (node.association?.as == '_pivot_') {
return;
}
// if no attributes are specified, return empty fields
const nodeRawAttributes = node.rawAttributes || [];
if (!lodash.isArray(nodeRawAttributes)) {
return;
}
const nodeChildrenAs = node.children.map((child) => child.association.as);
const includeAttributes = [...nodeRawAttributes, ...nodeChildrenAs];
if (node.inspectInheritAttribute) {
includeAttributes.push('__schemaName', '__tableName', '__collection');
}
for (const instance of node.instances) {
instance.dataValues = lodash.pick(instance.dataValues, includeAttributes);
}
};
// traverse tree and set instance attributes
const traverse = (node) => {
setInstanceAttributes(node);
for (const child of node.children) {
traverse(child);
}
};
traverse(this.root);
return result;
}
}