diff --git a/packages/shared/README.md b/packages/shared/README.md index 954334977c..d59b49c919 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -37,7 +37,7 @@ import { TemplatedData } from "packages/shared" In addition, to avoid compiler errors thrown by non-browser shared methods, explicit paths should be included to import only the supported files as required ```ts -import { AppStringEvaluator } from "packages/shared/src/models/appStringEvaluator/appStringEvaluator"; +import { AppDataEvaluator } from "packages/shared/src/models/appDataEvaluator/appDataEvaluator"; import { TemplatedData } from "packages/shared/src/models/templatedData/templatedData"; ``` diff --git a/packages/shared/src/models/appDataEvaluator/appDataEvaluator.spec.ts b/packages/shared/src/models/appDataEvaluator/appDataEvaluator.spec.ts new file mode 100644 index 0000000000..4b1751a75c --- /dev/null +++ b/packages/shared/src/models/appDataEvaluator/appDataEvaluator.spec.ts @@ -0,0 +1,42 @@ +import { AppDataEvaluator } from "./appDataEvaluator"; + +describe("App String Evaluator - String Replacement", () => { + const evaluator = new AppDataEvaluator(); + + evaluator.setExecutionContext({ + row: { first_name: "Ada", last_name: "Lovelace", number_1: 1, number_2: 2 }, + }); + + it("Hello @row.first_name @row.last_name)", () => { + expect(evaluator.evaluate("Hello @row.first_name @row.last_name")).toEqual( + "Hello Ada Lovelace" + ); + }); + it("{nested:[@row.first_name]}", () => { + expect(evaluator.evaluate({ nested: ["@row.first_name"] })).toEqual({ nested: ["Ada"] }); + }); +}); + +describe("App String Evaluator - JS Evaluate", () => { + const evaluator = new AppDataEvaluator(); + + evaluator.setExecutionContext({ + row: { first_name: "Ada", last_name: "Lovelace", number_1: 1, number_2: 2 }, + }); + + it("@row.number_1 > @row.number_2", () => { + expect(evaluator.evaluate("@row.number_1 > @row.number_2")).toEqual(false); + }); + + it("@row.first_name === 'Ada'", () => { + expect(evaluator.evaluate("@row.first_name === 'Ada'")).toEqual(true); + }); + + it("@row.first_name.length", () => { + expect(evaluator.evaluate("@row.first_name.length")).toEqual(3); + }); + it("@row.first_name==='Ada' ? 'It is Ada' : 'It is not Ada'", () => { + const res = evaluator.evaluate("@row.first_name==='Ada' ? 'It is Ada' : 'It is not Ada'"); + expect(res).toEqual("It is Ada"); + }); +}); diff --git a/packages/shared/src/models/appDataEvaluator/appDataEvaluator.ts b/packages/shared/src/models/appDataEvaluator/appDataEvaluator.ts new file mode 100644 index 0000000000..946293322c --- /dev/null +++ b/packages/shared/src/models/appDataEvaluator/appDataEvaluator.ts @@ -0,0 +1,109 @@ +import { JSEvaluator } from "../jsEvaluator/jsEvaluator"; +import { TemplatedData } from "../templatedData/templatedData"; +import { isObjectLiteral } from "../../utils/object-utils"; +import { addJSDelimeters } from "../../utils/delimiters"; + +/** Variable context is stored in namespaces, e.g. `{item:{key:'value'},field:{key:'value'}}` */ +type IContext = { [nameSpace: string]: { [field: string]: any } }; + +/** + * Utility class to allow evaluation of app data that contain a mix of context expressions, + * JavaScript and static strings. It combines both TemplatedData and jsEvaluator models + * + * @example + * ``` + * const evaluator = new AppDataEvaluator() + * + * evaluator.setExecutionContext({ + * row:{ + * number_1:1, + * number_2:2 + * } + * }) + * + * const expression = `@row.number_1 > @row.number_2 ? '1 is greater' : '2 is greater'` + * evaluator.evaluate(expression1) + * // 2 is greater + * ``` + * */ +export class AppDataEvaluator { + private templatedData = new TemplatedData(); + private jsEvaluator = new JSEvaluator(); + constructor(private context = {}) { + this.setExecutionContext(context); + } + public setExecutionContext(context: IContext) { + this.context = context; + this.templatedData.updateContext(context); + } + public updateExecutionContext(update: IContext) { + this.setExecutionContext({ ...this.context, update }); + } + + /** + * Evaluate app expression in two stages + * 1) Replace any instances of context variables with values + * e.g. `@row.number_1 > @row.number_2 ? '1 is greater' : '2 is greater' + * -> `1 > 2 ? '1 is greater' : '2 is greater' + * + * 2) Attempt evaluation in a JS context + * -> '2 is greater' + * + * @param data Input data to evaluated. This can be any data type, including + * nested objects or arrays. All string entries within will be evaulated + */ + public evaluate(data: any) { + if (typeof data === "string") { + return this.evaluateExpression(data); + } + if (Array.isArray(data)) { + const evaluated = []; + for (const el of data) { + evaluated.push(this.evaluate(el)); + } + return evaluated; + } + if (isObjectLiteral(data)) { + // create a new object to avoid changing initial data + const evaluated = {}; + for (const [key, value] of Object.entries(data)) { + evaluated[key] = this.evaluate(value); + } + return evaluated; + } + return data; + } + + /** + * Evaluate app string expression in two stages + * 1) Attempt to parse as a JavaScript expression + * Any variables referenced with `@` syntax will be converted to `this.` + * and evaluated with available context variables + * @example + * ```js + * evaluateExpression(`@row.number_1 > @row.number_2 ? 'bigger' : 'smaller'`) + * // intermediate interpretation + * `this.row.number_1 > this.row.number_2 ? 'bigger' : 'smaller'` + * // output + * "smaller" + * ``` + * + * 2) Perform string replacement of individual variables + * @example + * ```js + * evaluateExpression(`Hello @row.first_name`) + * ``` + * // output + * "Hello Ada" + */ + private evaluateExpression(expression: string) { + try { + const jsDelimited = addJSDelimeters(expression, Object.keys(this.context)); + const evaluated = this.jsEvaluator.evaluate(jsDelimited, this.context); + return evaluated; + } catch (error) { + const parsed = this.templatedData.parse(expression); + return parsed; + } + } +} diff --git a/packages/shared/src/models/appStringEvaluator/appStringEvaluator.spec.ts b/packages/shared/src/models/appStringEvaluator/appStringEvaluator.spec.ts deleted file mode 100644 index 0d543d89d9..0000000000 --- a/packages/shared/src/models/appStringEvaluator/appStringEvaluator.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { AppStringEvaluator } from "./appStringEvaluator"; - -describe("App String Evaluator", () => { - const evaluator = new AppStringEvaluator(); - - evaluator.setExecutionContext({ - row: { first_name: "Ada", last_name: "Lovelace", number_1: 1, number_2: 2 }, - }); - - it("@row.number_1 > @row.number_2", () => { - expect(evaluator.evaluate("@row.number_1 > @row.number_2")).toEqual(false); - }); - - it("Hello @row.first_name @row.last_name)", () => { - expect(evaluator.evaluate("Hello @row.first_name @row.last_name")).toEqual( - "Hello Ada Lovelace" - ); - }); - - // TODO - doesn't work, should address in follow-up - // replaces string part first without quotation, i.e. - // `Ada === 'Ada'` and returns just as a string (can't evaluate) - it("@row.first_name === 'Ada'", () => { - pending("TODO - fix implementation to support"); - expect(evaluator.evaluate("@row.first_name === 'Ada'")).toEqual(true); - }); -}); diff --git a/packages/shared/src/models/appStringEvaluator/appStringEvaluator.ts b/packages/shared/src/models/appStringEvaluator/appStringEvaluator.ts deleted file mode 100644 index 7dbbb4cc00..0000000000 --- a/packages/shared/src/models/appStringEvaluator/appStringEvaluator.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { JSEvaluator } from "../jsEvaluator/jsEvaluator"; -import { TemplatedData } from ".."; - -/** - * Utility class to allow evaluation of strings that contain a mix of context expressions, - * JavaScript and static strings - * - * @example - * ``` - * const evaluator = new AppStringEvaluator() - * - * evaluator.setExecutionContext({ - * row:{ - * number_1:1, - * number_2:2 - * } - * }) - * - * const expression = `@row.number_1 > @row.number_2 ? '1 is greater' : '2 is greater'` - * evaluator.evaluate(expression1) - * // 2 is greater - * ``` - * */ -export class AppStringEvaluator { - private parser: TemplatedData; - private evaluator: JSEvaluator; - constructor(context = {}) { - this.evaluator = new JSEvaluator(); - this.setExecutionContext(context); - } - setExecutionContext(context: any) { - this.parser = new TemplatedData({ context }); - } - /** - * Evaluate app string expression in two stages - * 1) Replace any instances of context variables with values - * e.g. `@row.number_1 > @row.number_2 ? '1 is greater' : '2 is greater' - * -> `1 > 2 ? '1 is greater' : '2 is greater' - * - * 2) Attempt evaluation in a JS context - * -> '2 is greater' - * @returns - */ - evaluate(expression: string) { - const parsed = this.parser.parse(expression); - try { - const evaluated = this.evaluator.evaluate(parsed); - return evaluated; - } catch (error) { - return parsed; - } - } -} diff --git a/packages/shared/src/models/dataPipe/operators/appendColumns.ts b/packages/shared/src/models/dataPipe/operators/appendColumns.ts index 965c4fcc89..c7ff2e369d 100644 --- a/packages/shared/src/models/dataPipe/operators/appendColumns.ts +++ b/packages/shared/src/models/dataPipe/operators/appendColumns.ts @@ -1,5 +1,5 @@ import { DataFrame, toJSON } from "danfojs"; -import { AppStringEvaluator } from "../../appStringEvaluator/appStringEvaluator"; +import { AppDataEvaluator } from "../../appDataEvaluator/appDataEvaluator"; import BaseOperator from "./base"; import { parseStringValue } from "../../../utils"; @@ -24,7 +24,7 @@ class AppendColumnsOperator extends BaseOperator { return arg.key && arg.valueExpression !== undefined; } apply() { - const evaluator = new AppStringEvaluator(); + const evaluator = new AppDataEvaluator(); for (const { key, valueExpression } of this.args_list) { const rows = toJSON(this.df) as any[]; const appendValues = rows.map((row) => { diff --git a/packages/shared/src/models/index.ts b/packages/shared/src/models/index.ts index 78511e16d5..4b8ff96e3f 100644 --- a/packages/shared/src/models/index.ts +++ b/packages/shared/src/models/index.ts @@ -1,4 +1,4 @@ -export * from "./appStringEvaluator/appStringEvaluator"; +export * from "./appDataEvaluator/appDataEvaluator"; export * from "./templatedData/templatedData"; export * from "./jsEvaluator/jsEvaluator"; diff --git a/packages/shared/src/utils/delimiters.spec.ts b/packages/shared/src/utils/delimiters.spec.ts index 3bb5573e4b..13de9ce6cc 100644 --- a/packages/shared/src/utils/delimiters.spec.ts +++ b/packages/shared/src/utils/delimiters.spec.ts @@ -7,7 +7,7 @@ interface IDelimitedTestData { } const prefixes = ["row"]; -const delimitedTests: IDelimitedTestData[] = [ +const stringDelimiterTests: IDelimitedTestData[] = [ // basic { input: "Name: @row.first_name", @@ -39,6 +39,80 @@ interface IParseTestData { delimited: string; extracted: ITemplatedStringVariable; } + +describe("addStringDelimiters", () => { + // Test individual string parsing + for (const testData of stringDelimiterTests) { + execTest(testData); + } + + // Use a function wrapper to allow looping tests + function execTest(testData: IDelimitedTestData) { + const { input, delimited } = testData; + + it(JSON.stringify(input), () => { + const parsedValue = Delimiters.addStringDelimiters(input, prefixes); + expect(parsedValue).toEqual(delimited); + process.nextTick(() => console.log(` ${JSON.stringify(parsedValue)}\n`)); + // NOTE - in case of errors additional tests can be carried out just on intermediate + }); + } +}); + +const jsDelimterTests: IDelimitedTestData[] = [ + // nested + { + input: "@row.@row.first_name", + delimited: "this.row[this.row.first_name]", + }, + + // basic + { + input: "@row.first_name", + delimited: "this.row.first_name", + }, + // expression + { + input: "@row.first_name === 'Ada'", + delimited: "this.row.first_name === 'Ada'", + }, + + // nested with braces + { + input: "@row.{@row.first_name}", + delimited: "this.row[this.row.first_name]", + }, + // invalid (will need to be parsed as templated string) + { + input: "Hello @row.first_name @row.last_name", + delimited: "Hello this.row.first_name this.row.last_name", + }, +]; + +interface IParseTestData { + delimited: string; + extracted: ITemplatedStringVariable; +} + +describe("addJSDelimiters", () => { + // Test individual string parsing + for (const testData of jsDelimterTests) { + execTest(testData); + } + + // Use a function wrapper to allow looping tests + function execTest(testData: IDelimitedTestData) { + const { input, delimited } = testData; + + it(JSON.stringify(input), () => { + const parsedValue = Delimiters.addJSDelimeters(input, prefixes); + expect(parsedValue).toEqual(delimited); + process.nextTick(() => console.log(` ${JSON.stringify(parsedValue)}\n`)); + // NOTE - in case of errors additional tests can be carried out just on intermediate + }); + } +}); + const parseTests: IParseTestData[] = [ // basic { @@ -93,25 +167,6 @@ const parseTests: IParseTestData[] = [ }, ]; -describe("Converts non-delimited variables", () => { - // Test individual string parsing - for (const testData of delimitedTests) { - execTest(testData); - } - - // Use a function wrapper to allow looping tests - function execTest(testData: IDelimitedTestData) { - const { input, delimited } = testData; - - it(JSON.stringify(input), () => { - const parsedValue = Delimiters.addStringDelimiters(input, prefixes); - expect(parsedValue).toEqual(delimited); - process.nextTick(() => console.log(` ${JSON.stringify(parsedValue)}\n`)); - // NOTE - in case of errors additional tests can be carried out just on intermediate - }); - } -}); - describe("Parses delimiters", () => { // Test individual string parsing for (const testData of parseTests) { diff --git a/packages/shared/src/utils/delimiters.ts b/packages/shared/src/utils/delimiters.ts index 000c598813..83087de799 100644 --- a/packages/shared/src/utils/delimiters.ts +++ b/packages/shared/src/utils/delimiters.ts @@ -40,6 +40,28 @@ export function addStringDelimiters(value: string, contextPrefixes: string[], fi return value; } +/** + * Take a string with templated expressions and add delimeters for evaluation within a JS context + * @example + * ``` + * "@row.@row.lookup_variable" + * // output + * "this.row[this.row.lookup_variable]" + */ +export function addJSDelimeters(value: string, contextPrefixes: string[]) { + const delimited = addStringDelimiters(value, contextPrefixes); + let replaced = delimited; + // inner variables, e.g. `@row.@row.inner_variable` + // E.g. Regex /\.{@(row.[a-z0-9_.]*)}/gi + const innerRegex = new RegExp(`\\.{@(${contextPrefixes.join("|")}.[a-z0-9_.]*)}`, "gi"); + replaced = replaced.replace(innerRegex, "[this.$1]"); + // outer variables, e.g. `@row[this.inner_variable]` or `@row.outer_variable` + // E.g. Regex /{@(row.[a-z0-9_.\[\]]*)}/gi + const outerRegex = new RegExp(`{@(${contextPrefixes.join("|")}.[a-z0-9_.\\[\\]]*)}`, "gi"); + replaced = replaced.replace(outerRegex, "this.$1"); + return replaced; +} + function shouldAddDelimiter(expression: string) { const [startDelimiter] = [expression[0]]; // skip adding delimiters if starts with delimiter and contains an end delimiter within string diff --git a/src/app/shared/services/data/app-data-variable.service.ts b/src/app/shared/services/data/app-data-variable.service.ts index b86f1ee88a..256c2008a7 100644 --- a/src/app/shared/services/data/app-data-variable.service.ts +++ b/src/app/shared/services/data/app-data-variable.service.ts @@ -26,7 +26,10 @@ export class AppDataVariableService extends AsyncServiceBase { **/ public handlers: { [context in IVariableContext]: Handlers.AppDataHandlerBase }; - constructor(private localStorageService: LocalStorageService, private DBService: DbService) { + constructor( + private localStorageService: LocalStorageService, + private DBService: DbService + ) { super("App Data Evaluator"); this.registerInitFunction(this.initialise); } @@ -101,7 +104,7 @@ export class AppDataVariableService extends AsyncServiceBase { const { parsed, evaluatedVariables } = await this.parseExpression(expression); // Step 3 - Evaluate parsed expression - // NOTE - method called standalone instead of using appStringEvaluator to add support for recursive + // NOTE - method called standalone instead of using AppDataEvaluator to add support for recursive // If the parsed expression not valid JS (e.g. just text) then return as-is const jsEvaluator = new JSEvaluator();