mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-30 16:56:07 +08:00
Merge branch 'main' into next
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
|
||||
import Database, { Model, Repository } from '@nocobase/database';
|
||||
import { createMockServer, MockServer } from '@nocobase/test';
|
||||
import CleanOrphanFlowRouteModelsMigration from '../migrations/202605121200-clean-orphan-flow-route-models';
|
||||
|
||||
describe('desktopRoutes:listAccessible', () => {
|
||||
let app: MockServer;
|
||||
@@ -290,3 +291,188 @@ describe('desktopRoutes', async () => {
|
||||
expect(rolesDesktopRoutes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('desktopRoutes flow model cleanup', () => {
|
||||
let app: MockServer, db: Database, desktopRoutesRepo: Repository;
|
||||
let flowRepo: any, usageRepo: any;
|
||||
|
||||
const buildReferenceOptions = (templateUid: string) => ({
|
||||
use: 'ReferenceBlockModel',
|
||||
stepParams: {
|
||||
referenceSettings: {
|
||||
useTemplate: {
|
||||
templateUid,
|
||||
templateName: `name-${templateUid}`,
|
||||
mode: 'reference',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createTemplate = async (uid: string) => {
|
||||
await app
|
||||
.agent()
|
||||
.resource('flowModelTemplates')
|
||||
.create({
|
||||
values: {
|
||||
uid,
|
||||
name: uid,
|
||||
targetUid: 'target-block',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveRouteTreeWithReferenceBlock = async (routeUid: string, referenceUid: string, templateUid: string) => {
|
||||
await app
|
||||
.agent()
|
||||
.resource('flowModels')
|
||||
.save({
|
||||
values: {
|
||||
uid: routeUid,
|
||||
use: 'RouteModel',
|
||||
subModels: {
|
||||
page: {
|
||||
uid: `${routeUid}-page`,
|
||||
use: 'RootPageModel',
|
||||
subModels: {
|
||||
grid: {
|
||||
uid: referenceUid,
|
||||
...buildReferenceOptions(templateUid),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createSchemaRouteRoot = async (routeUid: string) => {
|
||||
await flowRepo.create({
|
||||
values: {
|
||||
uid: routeUid,
|
||||
name: routeUid,
|
||||
schema: {
|
||||
use: 'RouteModel',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const countUsage = (filter: Record<string, any>) => {
|
||||
return usageRepo.count({ filter });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
registerActions: true,
|
||||
plugins: [
|
||||
'error-handler',
|
||||
'client',
|
||||
'field-sort',
|
||||
'acl',
|
||||
'ui-schema-storage',
|
||||
'system-settings',
|
||||
'data-source-main',
|
||||
'data-source-manager',
|
||||
'flow-engine',
|
||||
'ui-templates',
|
||||
],
|
||||
});
|
||||
db = app.db;
|
||||
desktopRoutesRepo = db.getRepository('desktopRoutes');
|
||||
flowRepo = db.getRepository('flowModels');
|
||||
usageRepo = db.getRepository('flowModelTemplateUsages');
|
||||
|
||||
await flowRepo.create({
|
||||
values: {
|
||||
uid: 'target-block',
|
||||
options: {
|
||||
use: 'TargetBlock',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should remove child route flow model tree and template usages when deleting a parent desktop route', async () => {
|
||||
await createTemplate('tpl-route-cleanup');
|
||||
const group = await desktopRoutesRepo.create({
|
||||
values: {
|
||||
title: 'group',
|
||||
type: 'group',
|
||||
},
|
||||
});
|
||||
await desktopRoutesRepo.create({
|
||||
values: {
|
||||
title: 'child page',
|
||||
type: 'flowPage',
|
||||
parentId: group.get('id'),
|
||||
schemaUid: 'route-cleanup',
|
||||
},
|
||||
});
|
||||
await saveRouteTreeWithReferenceBlock('route-cleanup', 'ref-route-cleanup', 'tpl-route-cleanup');
|
||||
|
||||
expect(await countUsage({ templateUid: 'tpl-route-cleanup', modelUid: 'ref-route-cleanup' })).toBe(1);
|
||||
|
||||
await desktopRoutesRepo.destroy({
|
||||
filterByTk: group.get('id'),
|
||||
});
|
||||
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'route-cleanup' } })).toBeFalsy();
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'route-cleanup-page' } })).toBeFalsy();
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'ref-route-cleanup' } })).toBeFalsy();
|
||||
expect(await countUsage({ templateUid: 'tpl-route-cleanup', modelUid: 'ref-route-cleanup' })).toBe(0);
|
||||
|
||||
const destroyTemplateResp = await app.agent().resource('flowModelTemplates').destroy({
|
||||
filterByTk: 'tpl-route-cleanup',
|
||||
});
|
||||
expect(destroyTemplateResp.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should clean orphan RouteModel trees and stale usages in migration', async () => {
|
||||
await createTemplate('tpl-orphan-route');
|
||||
await desktopRoutesRepo.create({
|
||||
values: {
|
||||
title: 'active page',
|
||||
type: 'flowPage',
|
||||
schemaUid: 'active-route',
|
||||
},
|
||||
});
|
||||
await desktopRoutesRepo.create({
|
||||
values: {
|
||||
title: 'active schema page',
|
||||
type: 'flowPage',
|
||||
schemaUid: 'active-schema-route',
|
||||
},
|
||||
});
|
||||
|
||||
await saveRouteTreeWithReferenceBlock('active-route', 'ref-active-route', 'tpl-orphan-route');
|
||||
await saveRouteTreeWithReferenceBlock('orphan-route', 'ref-orphan-route', 'tpl-orphan-route');
|
||||
await createSchemaRouteRoot('orphan-schema-route');
|
||||
await usageRepo.create({
|
||||
values: {
|
||||
uid: 'usage-missing-route-model',
|
||||
templateUid: 'tpl-orphan-route',
|
||||
modelUid: 'missing-route-model',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await countUsage({ templateUid: 'tpl-orphan-route' })).toBe(3);
|
||||
|
||||
const migration = new CleanOrphanFlowRouteModelsMigration({ db, app } as any);
|
||||
await migration.up();
|
||||
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'active-route' } })).toBeTruthy();
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'active-schema-route' } })).toBeTruthy();
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'ref-active-route' } })).toBeTruthy();
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'orphan-route' } })).toBeFalsy();
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'orphan-schema-route' } })).toBeFalsy();
|
||||
expect(await flowRepo.findOne({ filter: { uid: 'ref-orphan-route' } })).toBeFalsy();
|
||||
expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'ref-orphan-route' })).toBe(0);
|
||||
expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'missing-route-model' })).toBe(0);
|
||||
expect(await countUsage({ templateUid: 'tpl-orphan-route', modelUid: 'ref-active-route' })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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 * as _ from 'lodash';
|
||||
|
||||
const parseOptions = (value: any) => {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const isRouteModelOptions = (value: any) => {
|
||||
const options = parseOptions(value);
|
||||
return options?.use === 'RouteModel' || options?.schema?.use === 'RouteModel';
|
||||
};
|
||||
|
||||
export default class extends Migration {
|
||||
on = 'afterLoad';
|
||||
|
||||
async up() {
|
||||
if (
|
||||
!this.db.hasCollection('desktopRoutes') ||
|
||||
!this.db.hasCollection('flowModels') ||
|
||||
!this.db.hasCollection('flowModelTreePath')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.sequelize.transaction(async (transaction) => {
|
||||
const desktopRoutesRepo = this.db.getRepository('desktopRoutes');
|
||||
const flowRepo = this.db.getRepository('flowModels') as any;
|
||||
const treePathRepo = this.db.getRepository('flowModelTreePath');
|
||||
|
||||
const desktopRoutes = await desktopRoutesRepo.find({ fields: ['schemaUid'], transaction });
|
||||
const routeSchemaUids = new Set(desktopRoutes.map((route) => route.get('schemaUid')).filter(Boolean));
|
||||
|
||||
const flowModels = await flowRepo.find({ fields: ['uid', 'options'], transaction });
|
||||
const routeModelUids = flowModels
|
||||
.filter((model) => isRouteModelOptions(model.get('options')))
|
||||
.map((model) => model.get('uid'))
|
||||
.filter(Boolean);
|
||||
|
||||
if (routeModelUids.length) {
|
||||
const childRouteModels = new Set<string>();
|
||||
for (const chunk of _.chunk(routeModelUids, 500)) {
|
||||
const parentPaths = await treePathRepo.find({
|
||||
filter: {
|
||||
descendant: {
|
||||
$in: chunk,
|
||||
},
|
||||
depth: 1,
|
||||
},
|
||||
fields: ['descendant'],
|
||||
transaction,
|
||||
});
|
||||
for (const path of parentPaths) {
|
||||
const descendant = path.get('descendant');
|
||||
if (descendant) {
|
||||
childRouteModels.add(descendant);
|
||||
}
|
||||
}
|
||||
}
|
||||
const orphanRouteModelUids = routeModelUids.filter(
|
||||
(uid) => !routeSchemaUids.has(uid) && !childRouteModels.has(uid),
|
||||
);
|
||||
|
||||
for (const uid of orphanRouteModelUids) {
|
||||
await flowRepo.remove(uid, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.db.hasCollection('flowModelTemplateUsages')) {
|
||||
const usageRepo = this.db.getRepository('flowModelTemplateUsages');
|
||||
const usages = await usageRepo.find({ fields: ['modelUid'], transaction });
|
||||
const usageModelUids = _.uniq(usages.map((usage) => usage.get('modelUid')).filter(Boolean));
|
||||
|
||||
for (const chunk of _.chunk(usageModelUids, 500)) {
|
||||
const existingModels = await flowRepo.find({
|
||||
fields: ['uid'],
|
||||
filter: {
|
||||
uid: {
|
||||
$in: chunk,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
const existingUids = new Set(existingModels.map((model) => model.get('uid')).filter(Boolean));
|
||||
const missingUids = chunk.filter((uid) => !existingUids.has(uid));
|
||||
|
||||
if (missingUids.length) {
|
||||
await usageRepo.destroy({
|
||||
filter: {
|
||||
modelUid: {
|
||||
$in: missingUids,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -188,15 +188,53 @@ export class PluginClientServer extends Plugin {
|
||||
instance.allowNewMenu === undefined ? ['admin', 'member'].includes(instance.name) : !!instance.allowNewMenu,
|
||||
);
|
||||
});
|
||||
this.db.on('desktopRoutes.afterDestroy', async (instance: Model, { transaction }) => {
|
||||
const r = this.db.getRepository('flowModels');
|
||||
if (r) {
|
||||
await r.destroy({
|
||||
|
||||
const collectDesktopRouteSchemaUids = async (instance: Model, transaction?: Transaction) => {
|
||||
const routeRepo = this.db.getRepository('desktopRoutes');
|
||||
const schemaUids = new Set<string>();
|
||||
const pendingIds: Array<string | number> = [];
|
||||
const rootId = instance.get('id');
|
||||
const rootSchemaUid = instance.get('schemaUid');
|
||||
|
||||
if (rootSchemaUid) {
|
||||
schemaUids.add(rootSchemaUid);
|
||||
}
|
||||
if (rootId) {
|
||||
pendingIds.push(rootId);
|
||||
}
|
||||
|
||||
while (pendingIds.length) {
|
||||
const routes = await routeRepo.find({
|
||||
fields: ['id', 'schemaUid'],
|
||||
filter: {
|
||||
uid: instance.get('schemaUid'),
|
||||
parentId: {
|
||||
$in: pendingIds.splice(0, pendingIds.length),
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
for (const route of routes) {
|
||||
const schemaUid = route.get('schemaUid');
|
||||
const id = route.get('id');
|
||||
if (schemaUid) {
|
||||
schemaUids.add(schemaUid);
|
||||
}
|
||||
if (id) {
|
||||
pendingIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(schemaUids);
|
||||
};
|
||||
|
||||
this.db.on('desktopRoutes.beforeDestroy', async (instance: Model, { transaction }) => {
|
||||
const r = this.db.getRepository('flowModels') as any;
|
||||
if (r?.remove) {
|
||||
const schemaUids = await collectDesktopRouteSchemaUids(instance, transaction);
|
||||
for (const schemaUid of schemaUids) {
|
||||
await r.remove(schemaUid, { transaction });
|
||||
}
|
||||
}
|
||||
});
|
||||
this.db.on('desktopRoutes.afterCreate', async (instance: Model, { transaction }) => {
|
||||
|
||||
@@ -456,6 +456,29 @@ export class FlowModelRepository extends Repository {
|
||||
return;
|
||||
}
|
||||
|
||||
const descendants = (await this.database.sequelize.query(
|
||||
this.sqlAdapter(`SELECT descendant FROM ${this.flowModelTreePathTableName} WHERE ancestor = :uid`),
|
||||
{
|
||||
type: 'SELECT',
|
||||
replacements: {
|
||||
uid,
|
||||
},
|
||||
transaction,
|
||||
},
|
||||
)) as Array<{ descendant?: string }>;
|
||||
const uids = descendants.map((row) => row.descendant).filter(Boolean) as string[];
|
||||
|
||||
if (uids.length) {
|
||||
await this.database.emitAsync(
|
||||
`${this.collection.name}.beforeRemoveTree`,
|
||||
{
|
||||
rootUid: uid,
|
||||
uids,
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
await this.database.sequelize.query(
|
||||
this.sqlAdapter(`DELETE FROM ${this.flowModelsTableName} WHERE "uid" IN (
|
||||
SELECT descendant FROM ${this.flowModelTreePathTableName} WHERE ancestor = :uid
|
||||
|
||||
+59
@@ -150,6 +150,65 @@ describe('ui templates and usages', () => {
|
||||
expect(destroyResp2.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should remove target tree usages when template is destroyed', async () => {
|
||||
const agent = app.agent();
|
||||
await agent.resource('flowModelTemplates').create({
|
||||
values: {
|
||||
uid: 'tpl-block',
|
||||
name: 'Block Template',
|
||||
targetUid: 'target-block',
|
||||
},
|
||||
});
|
||||
|
||||
const savePopupTargetResp = await agent.resource('flowModels').save({
|
||||
values: {
|
||||
uid: 'popup-template-root',
|
||||
use: 'PopupActionModel',
|
||||
subModels: {
|
||||
content: [
|
||||
{
|
||||
uid: 'popup-ref-block',
|
||||
...buildOptions('tpl-block'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(savePopupTargetResp.status).toBe(200);
|
||||
|
||||
expect(await countUsage({ templateUid: 'tpl-block', modelUid: 'popup-ref-block' })).toBe(1);
|
||||
const blockTplResp = await agent.resource('flowModelTemplates').get({
|
||||
filterByTk: 'tpl-block',
|
||||
});
|
||||
expect(blockTplResp.status).toBe(200);
|
||||
const blockTpl = blockTplResp.body?.data || blockTplResp.body;
|
||||
expect(blockTpl?.usageCount).toBe(1);
|
||||
|
||||
const popupTplResp = await agent.resource('flowModelTemplates').create({
|
||||
values: {
|
||||
uid: 'tpl-popup',
|
||||
name: 'Popup Template',
|
||||
targetUid: 'popup-template-root',
|
||||
type: 'popup',
|
||||
},
|
||||
});
|
||||
expect(popupTplResp.status).toBe(200);
|
||||
|
||||
const destroyResp = await agent.resource('flowModelTemplates').destroy({
|
||||
filterByTk: 'tpl-popup',
|
||||
});
|
||||
expect(destroyResp.status).toBe(200);
|
||||
|
||||
expect(await countUsage({ templateUid: 'tpl-block', modelUid: 'popup-ref-block' })).toBe(0);
|
||||
expect(await flowRepo.findNodesById('popup-template-root', { includeAsyncNode: true })).toHaveLength(0);
|
||||
const blockTplRespAfterDestroy = await agent.resource('flowModelTemplates').get({
|
||||
filterByTk: 'tpl-block',
|
||||
});
|
||||
expect(blockTplRespAfterDestroy.status).toBe(200);
|
||||
const blockTplAfterDestroy = blockTplRespAfterDestroy.body?.data || blockTplRespAfterDestroy.body;
|
||||
expect(blockTplAfterDestroy?.usageCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should sync template name/description to reference blocks on template update', async () => {
|
||||
const agent = app.agent();
|
||||
await agent.resource('flowModelTemplates').create({
|
||||
|
||||
@@ -121,23 +121,36 @@ export class PluginBlockReferenceServer extends Plugin {
|
||||
}
|
||||
};
|
||||
|
||||
const removeUsagesByInstance = async (
|
||||
instanceUid: string,
|
||||
_options: any,
|
||||
{ transaction }: { transaction?: any } = {},
|
||||
) => {
|
||||
const removeUsagesByInstances = async (instanceUids: string[], { transaction }: { transaction?: any } = {}) => {
|
||||
const usageRepo = this.db.getRepository('flowModelTemplateUsages');
|
||||
await usageRepo.destroy({ filter: { modelUid: instanceUid }, transaction });
|
||||
const uids = _.uniq(instanceUids.map(String).filter(Boolean));
|
||||
for (const chunk of _.chunk(uids, 500)) {
|
||||
await usageRepo.destroy({
|
||||
filter: {
|
||||
modelUid: {
|
||||
$in: chunk,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 区块删除时自动清理 usage 记录,避免垃圾数据
|
||||
this.db.on('flowModels.afterDestroy', async (instance: any, { transaction }: any = {}) => {
|
||||
const instanceUid = instance?.get?.('uid') || instance?.uid;
|
||||
if (!instanceUid) return;
|
||||
const options = parseOptions(instance?.get?.('options') || instance?.options);
|
||||
await removeUsagesByInstance(instanceUid, options, { transaction });
|
||||
await removeUsagesByInstances([instanceUid], { transaction });
|
||||
});
|
||||
|
||||
this.db.on(
|
||||
'flowModels.beforeRemoveTree',
|
||||
async ({ uids }: { rootUid?: string; uids?: string[] } = {}, options: { transaction?: any } = {}) => {
|
||||
if (!uids?.length) return;
|
||||
await removeUsagesByInstances(uids, { transaction: options?.transaction });
|
||||
},
|
||||
);
|
||||
|
||||
// 区块保存时维护 usage:
|
||||
// - referenceSettings.useTemplate.templateUid(且 mode!==copy)
|
||||
// - popupSettings.openView.popupTemplateUid + popupTemplateMode!==copy
|
||||
@@ -201,11 +214,7 @@ export class PluginBlockReferenceServer extends Plugin {
|
||||
for (const node of nodes) {
|
||||
const instanceUid = node?.uid;
|
||||
if (!instanceUid) continue;
|
||||
const options = parseOptions(node?.options || {});
|
||||
if (node?.parent && !options?.parentId) {
|
||||
options.parentId = node.parent;
|
||||
}
|
||||
await removeUsagesByInstance(instanceUid, options, { transaction: ctx.transaction });
|
||||
await removeUsagesByInstances([instanceUid], { transaction: ctx.transaction });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
+11
@@ -238,6 +238,13 @@ export default {
|
||||
data: { usageCount },
|
||||
});
|
||||
}
|
||||
const templateRepo = ctx.db.getRepository('flowModelTemplates');
|
||||
const template = await templateRepo.findOne({
|
||||
filter: { uid: templateUid },
|
||||
transaction: ctx.transaction,
|
||||
context: ctx,
|
||||
});
|
||||
const targetUid = template?.get?.('targetUid') || template?.targetUid;
|
||||
await actions.destroy(ctx, next);
|
||||
// 兜底清理孤立 usage(正常情况下 usageCount 已为 0)
|
||||
await usageRepo.destroy({
|
||||
@@ -246,6 +253,10 @@ export default {
|
||||
},
|
||||
context: ctx,
|
||||
});
|
||||
if (targetUid) {
|
||||
const flowRepo = ctx.db.getRepository('flowModels') as FlowModelRepository;
|
||||
await flowRepo.remove(targetUid, { transaction: ctx.transaction });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user