mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 10:01:26 +00:00
feat(core): add string template engine to evaluators (#3546)
* feat(core): add string template engine to evaluators
* refactor(plugin-workflow): simplify api
* Revert "refactor(plugin-workflow): simplify api"
This reverts commit 6ff2bb9220
.
* fix(plugin-workflow): fix test case
* refactor(core): adjust variable regular expression
This commit is contained in:
parent
85ab125bb0
commit
c6615441bd
@ -262,11 +262,11 @@
|
||||
"String": "字符串",
|
||||
"Password": "密码",
|
||||
"Advanced type": "高级类型",
|
||||
"Formula": "公式",
|
||||
"Formula description": "基于同一条记录中的其他字段计算出一个值。",
|
||||
"Syntax references": "语法参考",
|
||||
"Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types": "Math.js 包含大量内置函数和常量,并提供了集成的解决方案来处理不同的数据类型。",
|
||||
"Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types.": "Math.js 包含大量内置函数和常量,并提供了集成的解决方案来处理不同的数据类型。",
|
||||
"Formula.js supports most Microsoft Excel formula functions.": "Formula.js 支持大部分 Mircrosoft Excel 公式。",
|
||||
"String template": "字符串模板",
|
||||
"Simple string replacement, can be used to interpolate variables in a string.": "简单的字符串替换,可以用于在字符串中插入变量。",
|
||||
"Choices": "选择类型",
|
||||
"Checkbox": "勾选",
|
||||
"Display <icon></icon> when unchecked": "未勾选时显示 <icon></icon>",
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { evaluate } from '../../utils';
|
||||
import formulajs from '../../utils/formulajs';
|
||||
|
||||
export default {
|
||||
label: 'Formula.js',
|
||||
tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}',
|
||||
link: 'https://formulajs.info/functions/',
|
||||
evaluate: evaluate.bind(formulajs),
|
||||
evaluate: formulajs,
|
||||
};
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { evaluate } from '../../utils';
|
||||
import mathjs from '../../utils/mathjs';
|
||||
|
||||
export default {
|
||||
label: 'Math.js',
|
||||
tooltip: `{{t('Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types')}}`,
|
||||
tooltip: `{{t('Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types.')}}`,
|
||||
link: 'https://mathjs.org/',
|
||||
evaluate: evaluate.bind(mathjs),
|
||||
evaluate: mathjs,
|
||||
};
|
||||
|
7
packages/core/evaluators/src/client/engines/string.ts
Normal file
7
packages/core/evaluators/src/client/engines/string.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import string from '../../utils/string';
|
||||
|
||||
export default {
|
||||
label: `{{t('String template')}}`,
|
||||
tooltip: `{{t('Simple string replacement, can be used to interpolate variables in a string.')}}`,
|
||||
evaluate: string,
|
||||
};
|
@ -2,6 +2,7 @@ import { Registry } from '@nocobase/utils/client';
|
||||
|
||||
import mathjs from './engines/mathjs';
|
||||
import formulajs from './engines/formulajs';
|
||||
import string from './engines/string';
|
||||
|
||||
export interface Evaluator {
|
||||
label: string;
|
||||
@ -14,6 +15,7 @@ export const evaluators = new Registry<Evaluator>();
|
||||
|
||||
evaluators.register('math.js', mathjs);
|
||||
evaluators.register('formula.js', formulajs);
|
||||
evaluators.register('string', string);
|
||||
|
||||
export function getOptions() {
|
||||
return Array.from((evaluators as Registry<Evaluator>).getEntities()).reduce(
|
||||
|
@ -1,9 +1,88 @@
|
||||
import { evaluate } from '../../utils';
|
||||
import evaluators from '..';
|
||||
|
||||
describe('evaluate', () => {
|
||||
describe('pre-process', () => {
|
||||
const preProcess = evaluate.bind((expression, scope) => ({ expression, scope }), {});
|
||||
const preProcessReplaceValue = evaluate.bind((expression, scope) => ({ expression, scope }), {
|
||||
replaceValue: true,
|
||||
});
|
||||
|
||||
it('only variable as null', () => {
|
||||
const { expression, scope } = preProcess('{{a}}', { a: null });
|
||||
expect(expression).toBe(`$$0`);
|
||||
expect(scope.$$0).toBeNull();
|
||||
});
|
||||
|
||||
it('string containing variable', () => {
|
||||
const { expression, scope } = preProcess('a{{a}}b', { a: 1 });
|
||||
expect(expression).toBe(`a$$0b`);
|
||||
expect(scope.$$0).toBe(1);
|
||||
});
|
||||
|
||||
it('duplicated variable name in expression should be unique in new scope', () => {
|
||||
const { expression, scope } = preProcess('{{a}} {{a}}', { a: 1 });
|
||||
expect(expression).toBe(`$$0 $$0`);
|
||||
expect(Object.keys(scope).length).toBe(1);
|
||||
expect(scope.$$0).toBe(1);
|
||||
});
|
||||
|
||||
it('number path to array item 0', () => {
|
||||
const { expression, scope } = preProcess('{{a.0}}', { a: [1, 2, 3] });
|
||||
expect(expression).toBe(`$$0`);
|
||||
expect(scope.$$0).toBe(1);
|
||||
});
|
||||
|
||||
it('number path to array item 1', () => {
|
||||
const { expression, scope } = preProcess('{{a.1}}', { a: [1, 2, 3] });
|
||||
expect(expression).toBe(`$$0`);
|
||||
expect(scope.$$0).toBe(2);
|
||||
});
|
||||
|
||||
it('deep array path', () => {
|
||||
const { expression, scope } = preProcess('{{a.b}}', { a: [{ b: 1 }, { b: 2 }] });
|
||||
expect(expression).toBe(`$$0`);
|
||||
expect(scope.$$0).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('pre-process replace value', () => {
|
||||
const { expression, scope } = preProcessReplaceValue('a{{a}}b', { a: 1 });
|
||||
expect(expression).toBe(`a1b`);
|
||||
expect(scope.$$0).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('math.js', () => {
|
||||
const mathEval = evaluators.get('math.js');
|
||||
|
||||
it('number path to array item 0 (math.js)', () => {
|
||||
expect(() => mathEval('{{a}}[0]', { a: [1, 2, 3] })).toThrow();
|
||||
});
|
||||
|
||||
it('number path to array item 1 (math.js)', () => {
|
||||
const result = mathEval('{{a}}[1]', { a: [1, 2, 3] });
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('number path to array item 0 (math.js)', () => {
|
||||
const result = mathEval('{{a.0}}', { a: [1, 2, 3] });
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('number path to object member 0 (math.js)', () => {
|
||||
const result = mathEval('{{a.1}}', { a: { 1: 1 } });
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('string expression with space', () => {
|
||||
const result = mathEval('{{a}} + 1', { a: 1 });
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formula.js', () => {
|
||||
const formulaEval = evaluators.get('formula.js');
|
||||
|
||||
describe('evaluate', () => {
|
||||
it('reference null or undefined', () => {
|
||||
const result = formulaEval('{{a.b}}', { a: null });
|
||||
expect(result).toBeNull();
|
||||
@ -62,28 +141,57 @@ describe('evaluate', () => {
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('number path to array item 1 (math.js)', () => {
|
||||
const result = mathEval('{{a}}[1]', { a: [1, 2, 3] });
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
// NOTE: This case is skipped because `a.<number>` is not able to be configured from UI.
|
||||
it.skip('number path to array item 0 (math.js)', () => {
|
||||
expect(() => mathEval('{{a.0}}', { a: [1, 2, 3] })).toThrow();
|
||||
});
|
||||
|
||||
it.skip('number path to array item 1 (math.js)', () => {
|
||||
const result = mathEval('{{a.1}}', { a: [1, 2, 3] });
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('number path to object member 0 (math.js)', () => {
|
||||
const result = mathEval('{{a.1}}', { a: { 1: 1 } });
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('number lead string path to object member (formula.js)', () => {
|
||||
const result = formulaEval('{{a.1a}}', { a: { '1a': 1 } });
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string', () => {
|
||||
const stringReplace = evaluators.get('string');
|
||||
|
||||
it('only variable', () => {
|
||||
const result = stringReplace('{{a}}', { a: 1 });
|
||||
expect(result).toBe('1');
|
||||
});
|
||||
|
||||
it('string containing undefined variable', () => {
|
||||
const result = stringReplace('a{{a}}b', {});
|
||||
expect(result).toBe('ab');
|
||||
});
|
||||
|
||||
it('string containing null variable', () => {
|
||||
const result = stringReplace('a{{a}}b', { a: null });
|
||||
expect(result).toBe('ab');
|
||||
});
|
||||
|
||||
it('string containing boolean variable', () => {
|
||||
const result = stringReplace('a{{a}}b', { a: false });
|
||||
expect(result).toBe('afalseb');
|
||||
});
|
||||
|
||||
it('string containing number variable', () => {
|
||||
const result = stringReplace('a{{a}}b', { a: 1 });
|
||||
expect(result).toBe('a1b');
|
||||
});
|
||||
|
||||
it('string containing NaN variable', () => {
|
||||
const result = stringReplace('a{{a}}b', { a: Number.NaN });
|
||||
expect(result).toBe('ab');
|
||||
});
|
||||
|
||||
it('string containing infinite variable', () => {
|
||||
const result = stringReplace('a{{a}}b', { a: Number.POSITIVE_INFINITY });
|
||||
expect(result).toBe('ab');
|
||||
});
|
||||
|
||||
it('string containing function variable', () => {
|
||||
const result = stringReplace('a{{a}}b', {
|
||||
a() {
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
expect(result).toBe('a1b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { Registry } from '@nocobase/utils';
|
||||
|
||||
import { evaluate, Evaluator } from '../utils';
|
||||
import { Evaluator } from '../utils';
|
||||
import mathjs from '../utils/mathjs';
|
||||
import formulajs from '../utils/formulajs';
|
||||
import string from '../utils/string';
|
||||
|
||||
export { Evaluator, evaluate, appendArrayColumn } from '../utils';
|
||||
|
||||
export const evaluators = new Registry<Evaluator>();
|
||||
|
||||
evaluators.register('math.js', evaluate.bind(mathjs));
|
||||
evaluators.register('formula.js', evaluate.bind(formulajs));
|
||||
evaluators.register('math.js', mathjs);
|
||||
evaluators.register('formula.js', formulajs);
|
||||
evaluators.register('string', string);
|
||||
|
||||
export default evaluators;
|
||||
|
@ -1,9 +1,11 @@
|
||||
import * as functions from '@formulajs/formulajs';
|
||||
|
||||
import { evaluate } from '.';
|
||||
|
||||
const fnNames = Object.keys(functions).filter((key) => key !== 'default');
|
||||
const fns = fnNames.map((key) => functions[key]);
|
||||
|
||||
export default function (expression: string, scope = {}) {
|
||||
export default evaluate.bind(function (expression: string, scope = {}) {
|
||||
const fn = new Function(...fnNames, ...Object.keys(scope), `return ${expression}`);
|
||||
const result = fn(...fns, ...Object.values(scope));
|
||||
if (typeof result === 'number') {
|
||||
@ -13,4 +15,4 @@ export default function (expression: string, scope = {}) {
|
||||
return functions.ROUND(result, 9);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}, {});
|
||||
|
@ -17,24 +17,34 @@ export function appendArrayColumn(scope, key) {
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluate(this: Evaluator, expression: string, scope: Scope = {}) {
|
||||
interface EvaluatorOptions {
|
||||
replaceValue?: boolean;
|
||||
}
|
||||
|
||||
export function evaluate(this: Evaluator, options: EvaluatorOptions = {}, expression: string, scope: Scope = {}) {
|
||||
const context = cloneDeep(scope);
|
||||
const newContext = {};
|
||||
const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => {
|
||||
const keyMap = {};
|
||||
let index = 0;
|
||||
const exp = expression.trim().replace(/{{\s*([\w$.-]+)\s*}}/g, (_, v) => {
|
||||
appendArrayColumn(context, v);
|
||||
|
||||
let item = get(context, v);
|
||||
let item = get(context, v) ?? null;
|
||||
|
||||
if (typeof item === 'function') {
|
||||
item = item();
|
||||
}
|
||||
|
||||
const randomKey = `$$${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`;
|
||||
if (item == null) {
|
||||
return 'null';
|
||||
let key = keyMap[v];
|
||||
if (!key) {
|
||||
key = `$$${index++}`;
|
||||
keyMap[v] = key;
|
||||
|
||||
newContext[key] = item;
|
||||
}
|
||||
newContext[randomKey] = item;
|
||||
return ` ${randomKey} `;
|
||||
return options.replaceValue
|
||||
? `${item == null || (typeof item === 'number' && (Number.isNaN(item) || !Number.isFinite(item))) ? '' : item}`
|
||||
: key;
|
||||
});
|
||||
|
||||
return this(exp, newContext);
|
||||
|
@ -1,6 +1,9 @@
|
||||
import * as math from 'mathjs';
|
||||
|
||||
export default function (expression: string, scope = {}) {
|
||||
import { evaluate } from '.';
|
||||
|
||||
export default evaluate.bind(
|
||||
function (expression: string, scope = {}) {
|
||||
const result = math.evaluate(expression, scope);
|
||||
if (typeof result === 'number') {
|
||||
if (Number.isNaN(result) || !Number.isFinite(result)) {
|
||||
@ -9,4 +12,6 @@ export default function (expression: string, scope = {}) {
|
||||
return math.round(result, 9);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
},
|
||||
{ replaceKey: true },
|
||||
);
|
||||
|
8
packages/core/evaluators/src/utils/string.ts
Normal file
8
packages/core/evaluators/src/utils/string.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { evaluate } from '.';
|
||||
|
||||
export default evaluate.bind(
|
||||
function (expression: string, scope = {}) {
|
||||
return expression;
|
||||
},
|
||||
{ replaceValue: true },
|
||||
);
|
@ -94,8 +94,8 @@
|
||||
"Scope variables": "局域变量",
|
||||
|
||||
"Operator": "运算符",
|
||||
"Calculate an expression based on a calculation engine and obtain a value as the result. Variables in the upstream nodes can be used in the expression. The expression can be static or dynamic one from an expression collections.":
|
||||
"基于计算引擎对一个表达式进行计算,并获得一个值作为结果。表达式中可以使用上游节点里的变量。表达式可以是静态的,也可以是表达式表中的动态表达式。",
|
||||
"Calculate an expression based on a calculation engine and obtain a value as the result. Variables in the upstream nodes can be used in the expression.":
|
||||
"基于计算引擎对一个表达式进行计算,并获得一个值作为结果。表达式中可以使用上游节点里的变量。",
|
||||
"String operation": "字符串",
|
||||
"System variables": "系统变量",
|
||||
"System time": "系统时间",
|
||||
|
@ -166,8 +166,7 @@ describe('workflow > instructions > calculation', () => {
|
||||
|
||||
const [execution] = await workflow.getExecutions();
|
||||
const [job] = await execution.getJobs();
|
||||
expect((job.result as string).startsWith('a $$')).toBe(true);
|
||||
expect((job.result as string).endsWith(' ')).toBe(true);
|
||||
expect(job.result).toBe('a$$0');
|
||||
});
|
||||
|
||||
it('text', async () => {
|
||||
|
Loading…
Reference in New Issue
Block a user