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 6ff2bb922016a66b5c5f013ea9836755f9da711e.

* 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": "字符串", "String": "字符串",
"Password": "密码", "Password": "密码",
"Advanced type": "高级类型", "Advanced type": "高级类型",
"Formula": "公式",
"Formula description": "基于同一条记录中的其他字段计算出一个值。",
"Syntax references": "语法参考", "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 公式。", "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": "选择类型", "Choices": "选择类型",
"Checkbox": "勾选", "Checkbox": "勾选",
"Display <icon></icon> when unchecked": "未勾选时显示 <icon></icon>", "Display <icon></icon> when unchecked": "未勾选时显示 <icon></icon>",

View File

@ -1,9 +1,8 @@
import { evaluate } from '../../utils';
import formulajs from '../../utils/formulajs'; import formulajs from '../../utils/formulajs';
export default { export default {
label: 'Formula.js', label: 'Formula.js',
tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}', tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}',
link: 'https://formulajs.info/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'; import mathjs from '../../utils/mathjs';
export default { export default {
label: 'Math.js', 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/', 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 mathjs from './engines/mathjs';
import formulajs from './engines/formulajs'; import formulajs from './engines/formulajs';
import string from './engines/string';
export interface Evaluator { export interface Evaluator {
label: string; label: string;
@ -14,6 +15,7 @@ export const evaluators = new Registry<Evaluator>();
evaluators.register('math.js', mathjs); evaluators.register('math.js', mathjs);
evaluators.register('formula.js', formulajs); evaluators.register('formula.js', formulajs);
evaluators.register('string', string);
export function getOptions() { export function getOptions() {
return Array.from((evaluators as Registry<Evaluator>).getEntities()).reduce( return Array.from((evaluators as Registry<Evaluator>).getEntities()).reduce(

View File

@ -1,89 +1,197 @@
import { evaluate } from '../../utils';
import evaluators from '..'; import evaluators from '..';
const mathEval = evaluators.get('math.js');
const formulaEval = evaluators.get('formula.js');
describe('evaluate', () => { describe('evaluate', () => {
it('reference null or undefined', () => { describe('pre-process', () => {
const result = formulaEval('{{a.b}}', { a: null }); const preProcess = evaluate.bind((expression, scope) => ({ expression, scope }), {});
expect(result).toBeNull(); const preProcessReplaceValue = evaluate.bind((expression, scope) => ({ expression, scope }), {
}); replaceValue: true,
it('function result string with quote', () => {
const result = formulaEval('{{a}}', {
a() {
return "I'm done.";
},
}); });
expect(result).toBe("I'm done.");
});
it('function result null', () => { it('only variable as null', () => {
const result = formulaEval('{{a}}', { const { expression, scope } = preProcess('{{a}}', { a: null });
a() { expect(expression).toBe(`$$0`);
return null; expect(scope.$$0).toBeNull();
},
}); });
expect(result).toBe(null);
});
it('function result number', () => { it('string containing variable', () => {
const result = formulaEval('{{a}}', { const { expression, scope } = preProcess('a{{a}}b', { a: 1 });
a() { expect(expression).toBe(`a$$0b`);
return 1; expect(scope.$$0).toBe(1);
},
}); });
expect(result).toBe(1);
});
it('function result Date', () => { it('duplicated variable name in expression should be unique in new scope', () => {
const now = new Date(); const { expression, scope } = preProcess('{{a}} {{a}}', { a: 1 });
const result = formulaEval('{{a}}', { expect(expression).toBe(`$$0 $$0`);
a() { expect(Object.keys(scope).length).toBe(1);
return now; 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);
}); });
expect(result).toBeInstanceOf(Date);
expect(result).toBe(now);
}); });
it('deep array', () => { describe('math.js', () => {
const result = formulaEval('{{a.b}}', { a: [{ b: 1 }, { b: 2 }] }); const mathEval = evaluators.get('math.js');
expect(result).toEqual([1, 2]);
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);
});
}); });
it('key with dash', () => { describe('formula.js', () => {
const result = formulaEval('{{a-b}}', { 'a-b': 1 }); const formulaEval = evaluators.get('formula.js');
expect(result).toBe(1);
it('reference null or undefined', () => {
const result = formulaEval('{{a.b}}', { a: null });
expect(result).toBeNull();
});
it('function result string with quote', () => {
const result = formulaEval('{{a}}', {
a() {
return "I'm done.";
},
});
expect(result).toBe("I'm done.");
});
it('function result null', () => {
const result = formulaEval('{{a}}', {
a() {
return null;
},
});
expect(result).toBe(null);
});
it('function result number', () => {
const result = formulaEval('{{a}}', {
a() {
return 1;
},
});
expect(result).toBe(1);
});
it('function result Date', () => {
const now = new Date();
const result = formulaEval('{{a}}', {
a() {
return now;
},
});
expect(result).toBeInstanceOf(Date);
expect(result).toBe(now);
});
it('deep array', () => {
const result = formulaEval('{{a.b}}', { a: [{ b: 1 }, { b: 2 }] });
expect(result).toEqual([1, 2]);
});
it('key with dash', () => {
const result = formulaEval('{{a-b}}', { 'a-b': 1 });
expect(result).toBe(1);
});
it('deep key with dash', () => {
const result = formulaEval('{{a.b-c}}', { a: { 'b-c': 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);
});
}); });
it('deep key with dash', () => { describe('string', () => {
const result = formulaEval('{{a.b-c}}', { a: { 'b-c': 1 } }); const stringReplace = evaluators.get('string');
expect(result).toBe(1);
});
it('number path to array item 1 (math.js)', () => { it('only variable', () => {
const result = mathEval('{{a}}[1]', { a: [1, 2, 3] }); const result = stringReplace('{{a}}', { a: 1 });
expect(result).toBe(1); expect(result).toBe('1');
}); });
// NOTE: This case is skipped because `a.<number>` is not able to be configured from UI. it('string containing undefined variable', () => {
it.skip('number path to array item 0 (math.js)', () => { const result = stringReplace('a{{a}}b', {});
expect(() => mathEval('{{a.0}}', { a: [1, 2, 3] })).toThrow(); expect(result).toBe('ab');
}); });
it.skip('number path to array item 1 (math.js)', () => { it('string containing null variable', () => {
const result = mathEval('{{a.1}}', { a: [1, 2, 3] }); const result = stringReplace('a{{a}}b', { a: null });
expect(result).toBe(1); expect(result).toBe('ab');
}); });
it('number path to object member 0 (math.js)', () => { it('string containing boolean variable', () => {
const result = mathEval('{{a.1}}', { a: { 1: 1 } }); const result = stringReplace('a{{a}}b', { a: false });
expect(result).toBe(1); expect(result).toBe('afalseb');
}); });
it('number lead string path to object member (formula.js)', () => { it('string containing number variable', () => {
const result = formulaEval('{{a.1a}}', { a: { '1a': 1 } }); const result = stringReplace('a{{a}}b', { a: 1 });
expect(result).toBe(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 { Registry } from '@nocobase/utils';
import { evaluate, Evaluator } from '../utils'; import { Evaluator } from '../utils';
import mathjs from '../utils/mathjs'; import mathjs from '../utils/mathjs';
import formulajs from '../utils/formulajs'; import formulajs from '../utils/formulajs';
import string from '../utils/string';
export { Evaluator, evaluate, appendArrayColumn } from '../utils'; export { Evaluator, evaluate, appendArrayColumn } from '../utils';
export const evaluators = new Registry<Evaluator>(); export const evaluators = new Registry<Evaluator>();
evaluators.register('math.js', evaluate.bind(mathjs)); evaluators.register('math.js', mathjs);
evaluators.register('formula.js', evaluate.bind(formulajs)); evaluators.register('formula.js', formulajs);
evaluators.register('string', string);
export default evaluators; export default evaluators;

View File

@ -1,9 +1,11 @@
import * as functions from '@formulajs/formulajs'; import * as functions from '@formulajs/formulajs';
import { evaluate } from '.';
const fnNames = Object.keys(functions).filter((key) => key !== 'default'); const fnNames = Object.keys(functions).filter((key) => key !== 'default');
const fns = fnNames.map((key) => functions[key]); 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 fn = new Function(...fnNames, ...Object.keys(scope), `return ${expression}`);
const result = fn(...fns, ...Object.values(scope)); const result = fn(...fns, ...Object.values(scope));
if (typeof result === 'number') { if (typeof result === 'number') {
@ -13,4 +15,4 @@ export default function (expression: string, scope = {}) {
return functions.ROUND(result, 9); return functions.ROUND(result, 9);
} }
return result; 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 context = cloneDeep(scope);
const newContext = {}; 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); appendArrayColumn(context, v);
let item = get(context, v); let item = get(context, v) ?? null;
if (typeof item === 'function') { if (typeof item === 'function') {
item = item(); item = item();
} }
const randomKey = `$$${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`; let key = keyMap[v];
if (item == null) { if (!key) {
return 'null'; key = `$$${index++}`;
keyMap[v] = key;
newContext[key] = item;
} }
newContext[randomKey] = item; return options.replaceValue
return ` ${randomKey} `; ? `${item == null || (typeof item === 'number' && (Number.isNaN(item) || !Number.isFinite(item))) ? '' : item}`
: key;
}); });
return this(exp, newContext); return this(exp, newContext);

View File

@ -1,12 +1,17 @@
import * as math from 'mathjs'; import * as math from 'mathjs';
export default function (expression: string, scope = {}) { import { evaluate } from '.';
const result = math.evaluate(expression, scope);
if (typeof result === 'number') { export default evaluate.bind(
if (Number.isNaN(result) || !Number.isFinite(result)) { function (expression: string, scope = {}) {
return null; const result = math.evaluate(expression, scope);
if (typeof result === 'number') {
if (Number.isNaN(result) || !Number.isFinite(result)) {
return null;
}
return math.round(result, 9);
} }
return math.round(result, 9); return result;
} },
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": "局域变量", "Scope variables": "局域变量",
"Operator": "运算符", "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": "字符串", "String operation": "字符串",
"System variables": "系统变量", "System variables": "系统变量",
"System time": "系统时间", "System time": "系统时间",

View File

@ -166,8 +166,7 @@ describe('workflow > instructions > calculation', () => {
const [execution] = await workflow.getExecutions(); const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs(); const [job] = await execution.getJobs();
expect((job.result as string).startsWith('a $$')).toBe(true); expect(job.result).toBe('a$$0');
expect((job.result as string).endsWith(' ')).toBe(true);
}); });
it('text', async () => { it('text', async () => {