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:
Junyi 2024-02-22 11:27:10 +08:00 committed by GitHub
parent 85ab125bb0
commit c6615441bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 241 additions and 100 deletions

View File

@ -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>",

View File

@ -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,
};

View File

@ -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,
};

View 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,
};

View File

@ -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(

View File

@ -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');
});
});
});

View File

@ -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;

View File

@ -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;
}
}, {});

View File

@ -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);

View File

@ -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 },
);

View File

@ -0,0 +1,8 @@
import { evaluate } from '.';
export default evaluate.bind(
function (expression: string, scope = {}) {
return expression;
},
{ replaceValue: true },
);

View File

@ -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": "系统时间",

View File

@ -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 () => {