fix: flow model repository

This commit is contained in:
chenos 2025-06-23 23:18:03 +08:00
parent f4a92e4959
commit 724b5e9e62
6 changed files with 637 additions and 11 deletions

View File

@ -9,6 +9,7 @@
import { FlowModel, IFlowModelRepository } from '@nocobase/flow-engine';
import _ from 'lodash';
import { Application } from '../application';
export class MockFlowModelRepository implements IFlowModelRepository<FlowModel> {
get models() {
@ -99,3 +100,32 @@ export class MockFlowModelRepository implements IFlowModelRepository<FlowModel>
return true;
}
}
export class FlowModelRepository implements IFlowModelRepository<FlowModel> {
constructor(private app: Application) {}
async findOne(query) {
const response = await this.app.apiClient.request({
url: 'flowModels:findOne',
params: _.pick(query, ['uid', 'parentId']),
});
return response.data?.data;
}
async save(model: FlowModel) {
const response = await this.app.apiClient.request({
method: 'POST',
url: 'flowModels:save',
data: model.serialize(),
});
return response.data?.data;
}
async destroy(uid: string) {
await this.app.apiClient.request({
method: 'POST',
url: 'flowModels:destroy',
params: { filterByTk: uid },
});
return true;
}
}

View File

@ -27,7 +27,7 @@ function InternalFlowPage({ uid, sharedContext }) {
export const FlowRoute = () => {
const params = useParams();
return <FlowPage uid={params.name} />;
return <FlowPage uid={`r_${params.name}`} />;
};
export const FlowPage = (props) => {

View File

@ -12,7 +12,7 @@ import _ from 'lodash';
import { Plugin } from '../application/Plugin';
import * as actions from './actions';
import { FlowEngineRunner } from './FlowEngineRunner';
import { MockFlowModelRepository } from './FlowModelRepository';
import { FlowModelRepository, MockFlowModelRepository } from './FlowModelRepository';
import { FlowRoute } from './FlowPage';
import { DateTimeFormat } from './flowSetting/DateTimeFormat';
import * as models from './models';
@ -20,7 +20,8 @@ import * as models from './models';
export class PluginFlowEngine extends Plugin {
async load() {
this.app.addComponents({ FlowRoute });
this.app.flowEngine.setModelRepository(new MockFlowModelRepository());
// this.app.flowEngine.setModelRepository(new MockFlowModelRepository());
this.app.flowEngine.setModelRepository(new FlowModelRepository(this.app));
const filteredModels = Object.fromEntries(
Object.entries(models).filter(
([, ModelClass]) => typeof ModelClass === 'function' && ModelClass.prototype instanceof FlowModel,

View File

@ -0,0 +1,386 @@
/**
* 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, Database } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
import subModel from 'packages/core/client/docs/zh-CN/core/flow-models/demos/sub-model';
import { SchemaNode } from '../dao/ui_schema_node_dao';
import UiSchemaRepository from '../repository';
describe('ui_schema repository', () => {
let app: MockServer;
let db: Database;
let repository: UiSchemaRepository;
let treePathCollection: Collection;
afterEach(async () => {
await app.destroy();
});
beforeEach(async () => {
app = await createMockServer({
registerActions: true,
plugins: ['ui-schema-storage'],
});
db = app.db;
repository = db.getCollection('uiSchemas').repository as UiSchemaRepository;
treePathCollection = db.getCollection('uiSchemaTreePath');
});
it('should insert model', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
};
const model2 = await repository.insertModel(model1);
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
});
it('should insert model', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
subModels: {
sub1: {
uid: 'sub1',
use: 'TestSubModel',
},
sub2: [
{
uid: 'sub2-1',
use: 'TestSubModel2',
},
{
uid: 'sub2-2',
use: 'TestSubModel3',
},
],
},
};
const model2 = await repository.insertModel(model1);
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
expect(model2.subModels).toBeDefined();
expect(model2.subModels.sub1).toBeDefined();
expect(model2.subModels.sub1.use).toBe('TestSubModel');
expect(model2.subModels.sub2).toBeDefined();
expect(model2.subModels.sub2.length).toBe(2);
expect(model2.subModels.sub2[0].use).toBe('TestSubModel2');
expect(model2.subModels.sub2[1].use).toBe('TestSubModel3');
expect(model2.subModels.sub2[0].uid).toBe('sub2-1');
expect(model2.subModels.sub2[1].uid).toBe('sub2-2');
});
it('should insert model', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
subModels: {
sub1: {
uid: 'sub1',
use: 'TestSubModel',
subModels: {
sub2: [
{
uid: 'sub2-1',
use: 'TestSubModel2',
},
{
uid: 'sub2-2',
use: 'TestSubModel3',
},
],
sub3: {
uid: 'sub3',
use: 'TestSubModel4',
},
},
},
},
};
const model2 = await repository.insertModel(model1);
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
expect(model2.subModels).toBeDefined();
expect(model2.subModels.sub1).toBeDefined();
expect(model2.subModels.sub1.uid).toBe('sub1');
expect(model2.subModels.sub1.use).toBe('TestSubModel');
expect(model2.subModels.sub1.subModels).toBeDefined();
expect(model2.subModels.sub1.subModels.sub2).toBeDefined();
expect(model2.subModels.sub1.subModels.sub2.length).toBe(2);
expect(model2.subModels.sub1.subModels.sub2[0].uid).toBe('sub2-1');
expect(model2.subModels.sub1.subModels.sub2[0].use).toBe('TestSubModel2');
expect(model2.subModels.sub1.subModels.sub2[1].uid).toBe('sub2-2');
expect(model2.subModels.sub1.subModels.sub2[1].use).toBe('TestSubModel3');
expect(model2.subModels.sub1.subModels.sub3).toBeDefined();
expect(model2.subModels.sub1.subModels.sub3.uid).toBe('sub3');
expect(model2.subModels.sub1.subModels.sub3.use).toBe('TestSubModel4');
});
it('should insert model', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
subModels: {
sub1: {
use: 'TestSubModel',
},
sub2: [
{
use: 'TestSubModel2',
},
{
use: 'TestSubModel3',
},
],
},
};
const model2 = await repository.insertModel(model1);
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
expect(model2.subModels).toBeDefined();
expect(model2.subModels.sub1).toBeDefined();
expect(model2.subModels.sub1.use).toBe('TestSubModel');
expect(model2.subModels.sub2).toBeDefined();
expect(model2.subModels.sub2.length).toBe(2);
expect(model2.subModels.sub2[0].use).toBe('TestSubModel2');
expect(model2.subModels.sub2[1].use).toBe('TestSubModel3');
expect(model2.subModels.sub2[0].uid).toBeDefined();
expect(model2.subModels.sub2[1].uid).toBeDefined();
});
it('should insert model', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
subModels: {
sub1: {
async: true, // 模拟异步加载
use: 'TestSubModel',
},
sub2: [
{
async: true, // 模拟异步加载
use: 'TestSubModel2',
},
{
use: 'TestSubModel3',
},
],
},
};
const model2 = await repository.insertModel(model1);
console.log(JSON.stringify(model2, null, 2));
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
expect(model2.subModels).toBeDefined();
expect(model2.subModels.sub1).not.toBeDefined();
expect(model2.subModels.sub2).toBeDefined();
expect(model2.subModels.sub2.length).toBe(1);
expect(model2.subModels.sub2[0].use).toBe('TestSubModel3');
expect(model2.subModels.sub2[0].uid).toBeDefined();
});
it('findModelById includeAsyncNode', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
subModels: {
sub1: {
async: true, // 模拟异步加载
use: 'TestSubModel',
},
sub2: [
{
async: true, // 模拟异步加载
use: 'TestSubModel2',
},
{
use: 'TestSubModel3',
},
],
},
};
await repository.insertModel(model1);
const model2 = await repository.findModelById('uid1', { includeAsyncNode: true });
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
expect(model2.subModels).toBeDefined();
expect(model2.subModels.sub1).toBeDefined();
expect(model2.subModels.sub1.use).toBe('TestSubModel');
expect(model2.subModels.sub2).toBeDefined();
expect(model2.subModels.sub2.length).toBe(2);
expect(model2.subModels.sub2[0].use).toBe('TestSubModel2');
expect(model2.subModels.sub2[1].use).toBe('TestSubModel3');
expect(model2.subModels.sub2[0].uid).toBeDefined();
expect(model2.subModels.sub2[1].uid).toBeDefined();
});
it('should upsert model', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
subModels: {
sub1: {
uid: 'sub1',
use: 'TestSubModel',
},
sub2: [
{
uid: 'sub2-1',
use: 'TestSubModel2',
},
{
uid: 'sub2-2',
use: 'TestSubModel3',
},
],
},
};
const uid = await repository.upsertModel(model1);
expect(uid).toBe('uid1');
const model2 = await repository.findModelById('uid1');
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
expect(model2.subModels).toBeDefined();
expect(model2.subModels.sub1).toBeDefined();
expect(model2.subModels.sub1.use).toBe('TestSubModel');
expect(model2.subModels.sub2).toBeDefined();
expect(model2.subModels.sub2.length).toBe(2);
expect(model2.subModels.sub2[0].use).toBe('TestSubModel2');
expect(model2.subModels.sub2[1].use).toBe('TestSubModel3');
expect(model2.subModels.sub2[0].uid).toBe('sub2-1');
expect(model2.subModels.sub2[1].uid).toBe('sub2-2');
const model3 = {
uid: 'uid1',
use: 'TestModel_1',
subModels: {
sub1: {
uid: 'sub1',
use: 'TestSubModel_1',
},
sub2: [
{
uid: 'sub2-1',
use: 'TestSubModel2_1',
},
{
uid: 'sub2-2',
use: 'TestSubModel3_1',
},
],
},
};
await repository.upsertModel(model3);
const model4 = await repository.findModelById('uid1');
expect(model4).toBeDefined();
expect(model4.uid).toBe('uid1');
expect(model4.use).toBe('TestModel_1');
expect(model4.subModels).toBeDefined();
expect(model4.subModels.sub1).toBeDefined();
expect(model4.subModels.sub1.use).toBe('TestSubModel_1');
expect(model4.subModels.sub2).toBeDefined();
expect(model4.subModels.sub2.length).toBe(2);
expect(model4.subModels.sub2[0].use).toBe('TestSubModel2_1');
expect(model4.subModels.sub2[1].use).toBe('TestSubModel3_1');
expect(model4.subModels.sub2[0].uid).toBe('sub2-1');
expect(model4.subModels.sub2[1].uid).toBe('sub2-2');
await repository.upsertModel({
uid: 'sub2-2',
use: 'TestSubModel3_2',
});
const model5 = await repository.findModelById('uid1');
expect(model5.subModels.sub2[1].use).toBe('TestSubModel3_2');
});
it('should upsert model', async () => {
const model1 = {
uid: 'uid1',
use: 'TestModel',
subModels: {
sub1: {
uid: 'sub1',
use: 'TestSubModel',
},
sub2: [
{
uid: 'sub2-1',
use: 'TestSubModel2',
},
{
uid: 'sub2-2',
use: 'TestSubModel3',
},
],
},
};
const uid = await repository.upsertModel(model1);
expect(uid).toBe('uid1');
const model2 = await repository.findModelById('uid1');
expect(model2).toBeDefined();
expect(model2.uid).toBe('uid1');
expect(model2.use).toBe('TestModel');
expect(model2.subModels).toBeDefined();
expect(model2.subModels.sub1).toBeDefined();
expect(model2.subModels.sub1.use).toBe('TestSubModel');
expect(model2.subModels.sub2).toBeDefined();
expect(model2.subModels.sub2.length).toBe(2);
expect(model2.subModels.sub2[0].use).toBe('TestSubModel2');
expect(model2.subModels.sub2[1].use).toBe('TestSubModel3');
expect(model2.subModels.sub2[0].uid).toBe('sub2-1');
expect(model2.subModels.sub2[1].uid).toBe('sub2-2');
const model3 = {
uid: 'uid1',
use: 'TestModel_1',
subModels: {
sub1: {
uid: 'sub1',
use: 'TestSubModel_1',
},
sub2: [
{
uid: 'sub2-1',
use: 'TestSubModel2_1',
},
],
},
};
await repository.upsertModel(model3);
const model4 = await repository.findModelById('uid1');
expect(model4).toBeDefined();
expect(model4.uid).toBe('uid1');
expect(model4.use).toBe('TestModel_1');
expect(model4.subModels).toBeDefined();
expect(model4.subModels.sub1).toBeDefined();
expect(model4.subModels.sub1.use).toBe('TestSubModel_1');
expect(model4.subModels.sub2).toBeDefined();
expect(model4.subModels.sub2.length).toBe(2);
expect(model4.subModels.sub2[0].use).toBe('TestSubModel2_1');
expect(model4.subModels.sub2[0].uid).toBe('sub2-1');
await repository.upsertModel({
uid: 'sub2-2',
parentId: 'uid1',
subType: 'array',
subKey: 'sub2',
use: 'TestSubModel3_1',
});
const model5 = await repository.findModelById('uid1');
expect(model5.subModels.sub2[1].use).toBe('TestSubModel3_1');
});
});

View File

@ -39,7 +39,7 @@ interface InsertAdjacentOptions extends removeParentOptions {
const nodeKeys = ['properties', 'definitions', 'patternProperties', 'additionalProperties', 'items'];
function transaction(transactionAbleArgPosition?: number) {
export function transaction(transactionAbleArgPosition?: number) {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
@ -1112,7 +1112,7 @@ WHERE TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor and TreeTable.sort
return lodash.pick(schema, ['type', 'properties']);
}
private async doGetJsonSchema(uid: string, options?: GetJsonSchemaOptions) {
async findNodesById(uid: string, options?: GetJsonSchemaOptions) {
const db = this.database;
const treeTable = this.uiSchemaTreePathTableName;
@ -1138,10 +1138,15 @@ WHERE TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor and TreeTable.sort
});
if (nodes[0].length == 0) {
return {};
return [];
}
return this.nodesToSchema(nodes[0], uid);
return nodes[0];
}
private async doGetJsonSchema(uid: string, options?: GetJsonSchemaOptions) {
const nodes = await this.findNodesById(uid, options);
return this.nodesToSchema(nodes, uid);
}
private ignoreSchemaProperties(schemaProperties) {
@ -1223,6 +1228,173 @@ WHERE TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor and TreeTable.sort
return { uid, name, async, childOptions };
}
static modelToSingleNodes(model, parentChildOptions = null): SchemaNode[] {
const { uid: oldUid, async, subModels, ...rest } = _.cloneDeep(model);
const currentUid = oldUid || uid();
const node = {
'x-uid': currentUid,
'x-async': async || false,
name: currentUid,
...rest,
};
if (parentChildOptions) {
node.childOptions = parentChildOptions;
}
const nodes = [node];
if (Object.keys(subModels || {}).length > 0) {
for (const [subKey, subItems] of Object.entries(subModels)) {
const items = _.castArray<any>(subItems);
let sort = 0;
for (const item of items) {
item.subKey = subKey;
item.subType = Array.isArray(subItems) ? 'array' : 'object';
const childOptions = {
parentUid: currentUid,
parentPath: [currentUid, ...(parentChildOptions?.parentPath || [])].filter(Boolean),
type: 'properties',
sort: ++sort,
};
const children = this.modelToSingleNodes(item, childOptions);
nodes.push(...children);
}
}
}
return nodes;
}
static nodeToModel(node) {
const { 'x-uid': uid, name, schema } = node;
const model = {
uid,
...schema,
};
return model;
}
static nodesToModel(nodes: any[], rootUid: string) {
// 1. 建立 uid 到 node 的映射
const nodeMap = new Map<string, any>();
for (const node of nodes) {
nodeMap.set(node['x-uid'], node);
}
// 2. 找到 root 节点
const rootNode = nodeMap.get(rootUid);
if (!rootNode) return null;
// 3. 找到所有子节点
const children = nodes.filter((n) => n.parent === rootUid);
// 4. 按 subKey 分组并递归
const subModels: Record<string, any> = {};
for (const child of children) {
const { subKey, subType } = child.schema;
if (!subKey) continue;
// 递归处理子节点
const model = UiSchemaRepository.nodesToModel(nodes, child['x-uid']) || {
uid: child['x-uid'],
...child.schema,
sortIndex: child.sort,
};
// 保证 sortIndex
model.sortIndex = child.sort;
if (subType === 'array') {
if (!subModels[subKey]) subModels[subKey] = [];
subModels[subKey].push(model);
} else {
subModels[subKey] = model;
}
}
// 5. 对数组类型的 subModels 排序
for (const key in subModels) {
if (Array.isArray(subModels[key])) {
subModels[key].sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0));
}
}
// 6. 过滤掉空对象 subModels
const filteredSubModels: Record<string, any> = {};
for (const key in subModels) {
const value = subModels[key];
if (Array.isArray(value) && value.length === 0) continue;
if (!Array.isArray(value) && typeof value === 'object' && value !== null && Object.keys(value).length === 0)
continue;
filteredSubModels[key] = value;
}
// 7. 返回最终 model
return {
uid: rootNode['x-uid'],
...rootNode.schema,
...(Object.keys(filteredSubModels).length > 0 ? { subModels: filteredSubModels } : {}),
};
}
@transaction()
async insertModel(model: any, options?: Transactionable) {
const nodes = UiSchemaRepository.modelToSingleNodes(model);
const rootUid = nodes[0]['x-uid'];
await this.insertNodes(nodes, options);
return await this.findModelById(rootUid, options);
}
@transaction()
async updateSingleNode(node: SchemaNode, options?: Transactionable) {
const instance = await this.model.findByPk(node['x-uid'], {
transaction: options?.transaction,
});
if (instance) {
// @ts-ignore
await instance.update(
{
schema: {
...(instance.get('schema') as any),
...lodash.omit(node, ['x-async', 'name', 'x-uid', 'childOptions']),
},
},
{
hooks: false,
transaction: options?.transaction,
},
);
return true;
}
return false;
}
@transaction()
async upsertModel(model: any, options?: Transactionable) {
let childOptions: ChildOptions = null;
if (model.parentId) {
childOptions = {
parentUid: model.parentId,
type: 'properties',
position: 'last',
};
}
const nodes = UiSchemaRepository.modelToSingleNodes(model, childOptions);
const rootUid = nodes[0]['x-uid'];
for (const node of nodes) {
const exists = await this.updateSingleNode(node, options);
if (!exists) {
await this.insertSingleNode(node, options);
}
}
return rootUid;
}
async findModelById(uid: string, options?: GetJsonSchemaOptions) {
const nodes = await this.findNodesById(uid, options);
return UiSchemaRepository.nodesToModel(nodes, uid);
}
async findModelByParentId(parentUid: string, options?: GetJsonSchemaOptions) {
const model = await this.findModelById(parentUid, options);
return Object.values(model.subModels || {}).shift();
}
}
export default UiSchemaRepository;

View File

@ -8,10 +8,9 @@
*/
import { MagicAttributeModel } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import PluginLocalizationServer from '@nocobase/plugin-localization';
import { tval } from '@nocobase/utils';
import { uid } from '@nocobase/utils';
import { Plugin } from '@nocobase/server';
import { tval, uid } from '@nocobase/utils';
import path, { resolve } from 'path';
import { uiSchemaActions } from './actions/ui-schema-action';
import { UiSchemaModel } from './model';
@ -71,6 +70,11 @@ export class PluginUISchemaStorageServer extends Plugin {
actions: ['uiSchemas:*', 'uiSchemas.roles:list', 'uiSchemas.roles:set'],
});
this.app.acl.registerSnippet({
name: 'ui.flowModels',
actions: ['flowModels:*'],
});
db.on('uiSchemas.beforeCreate', function setUid(model) {
if (!model.get('name')) {
model.set('name', uid());
@ -114,11 +118,44 @@ export class PluginUISchemaStorageServer extends Plugin {
});
});
this.app.resourcer.define({
this.app.resourceManager.define({
name: 'uiSchemas',
actions: uiSchemaActions,
});
this.app.resourceManager.define({
name: 'flowModels',
actions: {
findOne: async (ctx, next) => {
const { uid, parentId } = ctx.action.params;
const repository = ctx.db.getRepository('uiSchemas') as UiSchemaRepository;
if (uid) {
ctx.body = await repository.findModelById(uid);
} else if (parentId) {
ctx.body = await repository.findModelByParentId(parentId);
}
await next();
},
save: async (ctx, next) => {
const { values } = ctx.action.params;
const repository = ctx.db.getRepository('uiSchemas') as UiSchemaRepository;
const uid = await repository.upsertModel(values);
ctx.body = uid;
// ctx.body = await repository.findModelById(uid);
await next();
},
destroy: async (ctx, next) => {
const { filterByTk } = ctx.action.params;
const repository = ctx.db.getRepository('uiSchemas') as UiSchemaRepository;
await repository.remove(filterByTk);
ctx.body = 'ok';
await next();
},
},
});
this.app.acl.allow('flowModels', ['findOne'], 'loggedIn');
this.app.acl.allow(
'uiSchemas',
['getProperties', 'getJsonSchema', 'getParentJsonSchema', 'initializeActionContext'],