feat: Add support for custom 'this' and 'global' variables during evaluation (#9651)

This commit is contained in:
Hetu Nandu 2021-12-14 14:00:43 +05:30 committed by GitHub
parent 861ee2c46e
commit 4c54ea21fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 92 additions and 43 deletions

View File

@ -113,7 +113,7 @@ export interface ExecuteErrorPayload extends ErrorActionPayload {
export const urlGroupsRegexExp = /^(https?:\/{2}\S+?)(\/[\s\S]*?)(\?(?![^{]*})[\s\S]*)?$/;
export const EXECUTION_PARAM_KEY = "executionParams";
export const EXECUTION_PARAM_REFERENCE_REGEX = /this.params/g;
export const THIS_DOT_PARAMS_KEY = "params";
export const RESP_HEADER_DATATYPE = "X-APPSMITH-DATATYPE";
export const API_REQUEST_HEADERS: APIHeaders = {

View File

@ -53,12 +53,13 @@ import toposort from "toposort";
import equal from "fast-deep-equal/es6";
import {
EXECUTION_PARAM_KEY,
EXECUTION_PARAM_REFERENCE_REGEX,
THIS_DOT_PARAMS_KEY,
} from "constants/AppsmithActionConstants/ActionConstants";
import { DATA_BIND_REGEX } from "constants/BindingsConstants";
import evaluate, {
createGlobalData,
EvalResult,
EvaluateContext,
EvaluationScriptType,
getScriptToEval,
} from "workers/evaluate";
@ -584,6 +585,13 @@ export default class DataTreeEvaluator {
const evaluationSubstitutionType =
entity.bindingPaths[propertyPath] ||
EvaluationSubstitutionType.TEMPLATE;
const contextData: EvaluateContext = {};
if (isAction(entity)) {
contextData.thisContext = {
params: {},
};
}
try {
evalPropertyValue = this.getDynamicValue(
unEvalPropertyValue,
@ -591,6 +599,7 @@ export default class DataTreeEvaluator {
resolvedFunctions,
evaluationSubstitutionType,
false,
contextData,
undefined,
fullPropertyPath,
);
@ -757,6 +766,7 @@ export default class DataTreeEvaluator {
resolvedFunctions: Record<string, any>,
evaluationSubstitutionType: EvaluationSubstitutionType,
returnTriggers: boolean,
contextData?: EvaluateContext,
callBackData?: Array<any>,
fullPropertyPath?: string,
) {
@ -777,6 +787,7 @@ export default class DataTreeEvaluator {
jsSnippets[0],
data,
resolvedFunctions,
contextData,
callBackData,
returnTriggers,
);
@ -793,6 +804,7 @@ export default class DataTreeEvaluator {
toBeSentForEval,
data,
resolvedFunctions,
contextData,
callBackData,
entity && isJSAction(entity),
);
@ -848,6 +860,7 @@ export default class DataTreeEvaluator {
js: string,
data: DataTree,
resolvedFunctions: Record<string, any>,
contextData?: EvaluateContext,
callbackData?: Array<any>,
isTriggerBased = false,
): EvalResult {
@ -856,6 +869,7 @@ export default class DataTreeEvaluator {
js,
data,
resolvedFunctions,
contextData,
callbackData,
isTriggerBased,
);
@ -894,6 +908,7 @@ export default class DataTreeEvaluator {
EvaluationSubstitutionType.TEMPLATE,
true,
undefined,
undefined,
fullPropertyPath,
);
valueToValidate = triggers;
@ -1532,23 +1547,18 @@ export default class DataTreeEvaluator {
);
}
// Replace any reference of 'this.params' to 'executionParams' (backwards compatibility)
const bindingsForExecutionParams: string[] = bindings.map(
(binding: string) =>
binding.replace(EXECUTION_PARAM_REFERENCE_REGEX, EXECUTION_PARAM_KEY),
);
const dataTreeWithExecutionParams = Object.assign({}, this.evalTree, {
[EXECUTION_PARAM_KEY]: evaluatedExecutionParams,
});
return bindingsForExecutionParams.map((binding) =>
return bindings.map((binding) =>
this.getDynamicValue(
`{{${binding}}}`,
dataTreeWithExecutionParams,
this.evalTree,
this.resolvedFunctions,
EvaluationSubstitutionType.TEMPLATE,
false,
// params can be accessed via "this.params" or "executionParams"
{
thisContext: { [THIS_DOT_PARAMS_KEY]: evaluatedExecutionParams },
globalContext: { [EXECUTION_PARAM_KEY]: evaluatedExecutionParams },
},
),
);
}

View File

@ -2,7 +2,7 @@ import { parse, Node } from "acorn";
import { ancestor } from "acorn-walk";
import _ from "lodash";
import { ECMA_VERSION } from "workers/constants";
import { unEscapeScript } from "./evaluate";
import { sanitizeScript } from "./evaluate";
/*
* Valuable links:
@ -153,17 +153,17 @@ export const extractIdentifiersFromCode = (code: string): string[] => {
let functionalParams = new Set<string>();
let ast: Node = { end: 0, start: 0, type: "" };
try {
const unEscapedCode = unEscapeScript(code);
const sanitizedScript = sanitizeScript(code);
/* wrapCode - Wrapping code in a function, since all code/script get wrapped with a function during evaluation.
Some syntaxes won't be valid unless they're at the RHS of a statement.
Some syntax won't be valid unless they're at the RHS of a statement.
Since we're assigning all code/script to RHS during evaluation, we do the same here.
So that during ast parse, those errors are neglected.
*/
/* e.g. IIFE without braces
function() { return 123; }() -> is invalid
let result = function() { return 123; }() -> is valid
let result = function() { return 123; }() -> is valid
*/
const wrappedCode = wrapCode(unEscapedCode);
const wrappedCode = wrapCode(sanitizedScript);
ast = getAST(wrappedCode);
} catch (e) {
if (e instanceof SyntaxError) {
@ -196,7 +196,7 @@ export const extractIdentifiersFromCode = (code: string): string[] => {
isMemberExpressionNode(parent) &&
/* Member expressions that are "computed" (with [ ] search)
and the ones that have optional chaining ( a.b?.c )
will be considered top level node.
will be considered top level node.
We will stop looking for further parents */
/* "computed" exception - isArrayAccessorNode
Member expressions that are array accessors with static index - [9]

View File

@ -72,7 +72,7 @@ describe("evaluate", () => {
const result = wrongJS
return result;
}
closedFunction()
closedFunction.call(THIS_CONTEXT)
`,
severity: "error",
originalBinding: "wrongJS",
@ -86,7 +86,7 @@ describe("evaluate", () => {
const result = wrongJS
return result;
}
closedFunction()
closedFunction.call(THIS_CONTEXT)
`,
severity: "error",
originalBinding: "wrongJS",
@ -106,7 +106,7 @@ describe("evaluate", () => {
const result = {}.map()
return result;
}
closedFunction()
closedFunction.call(THIS_CONTEXT)
`,
severity: "error",
originalBinding: "{}.map()",
@ -121,7 +121,7 @@ describe("evaluate", () => {
});
it("gets triggers from a function", () => {
const js = "showAlert('message', 'info')";
const response = evaluate(js, dataTree, {}, undefined, true);
const response = evaluate(js, dataTree, {}, undefined, undefined, true);
//this will be changed again in new implemenation for promises
const data = {
action: {
@ -172,7 +172,7 @@ describe("evaluate", () => {
const result = setTimeout(() => {}, 100)
return result;
}
closedFunction()
closedFunction.call(THIS_CONTEXT)
`,
severity: "error",
originalBinding: "setTimeout(() => {}, 100)",
@ -188,7 +188,23 @@ describe("evaluate", () => {
it("evaluates functions with callback data", () => {
const js = "(arg1, arg2) => arg1.value + arg2";
const callbackData = [{ value: "test" }, "1"];
const response = evaluate(js, dataTree, {}, callbackData);
const response = evaluate(js, dataTree, {}, {}, callbackData);
expect(response.result).toBe("test1");
});
it("has access to this context", () => {
const js = "this.contextVariable";
const thisContext = { contextVariable: "test" };
const response = evaluate(js, dataTree, {}, { thisContext });
expect(response.result).toBe("test");
// there should not be any error when accessing "this" variables
expect(response.errors).toHaveLength(0);
});
it("has access to additional global context", () => {
const js = "contextVariable";
const globalContext = { contextVariable: "test" };
const response = evaluate(js, dataTree, {}, { globalContext });
expect(response.result).toBe("test");
expect(response.errors).toHaveLength(0);
});
});

View File

@ -32,12 +32,12 @@ export const EvaluationScripts: Record<EvaluationScriptType, string> = {
const result = ${ScriptTemplate}
return result;
}
closedFunction()
closedFunction.call(THIS_CONTEXT)
`,
[EvaluationScriptType.ANONYMOUS_FUNCTION]: `
function callback (script) {
const userFunction = script;
const result = userFunction.apply(self, ARGUMENTS);
const result = userFunction.apply(THIS_CONTEXT, ARGUMENTS);
return result;
}
callback(${ScriptTemplate})
@ -47,7 +47,7 @@ export const EvaluationScripts: Record<EvaluationScriptType, string> = {
const result = ${ScriptTemplate}
return result
}
closedFunction();
closedFunction.call(THIS_CONTEXT);
`,
};
@ -95,11 +95,24 @@ export const createGlobalData = (
dataTree: DataTree,
resolvedFunctions: Record<string, any>,
isTriggerBased: boolean,
context?: EvaluateContext,
evalArguments?: Array<any>,
) => {
const GLOBAL_DATA: Record<string, any> = {};
///// Adding callback data
GLOBAL_DATA.ARGUMENTS = evalArguments;
//// Adding contextual data not part of data tree
GLOBAL_DATA.THIS_CONTEXT = {};
if (context) {
if (context.thisContext) {
GLOBAL_DATA.THIS_CONTEXT = context.thisContext;
}
if (context.globalContext) {
Object.entries(context.globalContext).forEach(([key, value]) => {
GLOBAL_DATA[key] = value;
});
}
}
///// Mocking Promise class
GLOBAL_DATA.Promise = AppsmithPromise;
if (isTriggerBased) {
@ -128,26 +141,34 @@ export const createGlobalData = (
return GLOBAL_DATA;
};
export function unEscapeScript(js: string) {
export function sanitizeScript(js: string) {
// We remove any line breaks from the beginning of the script because that
// makes the final function invalid. We also unescape any escaped characters
// so that eval can happen
const trimmedJS = js.replace(beginsWithLineBreakRegex, "");
const unescapedJS =
self.evaluationVersion > 1 ? trimmedJS : unescapeJS(trimmedJS);
return unescapedJS;
return self.evaluationVersion > 1 ? trimmedJS : unescapeJS(trimmedJS);
}
/** Define a context just for this script
* thisContext will define it on the `this`
* globalContext will define it globally
*/
export type EvaluateContext = {
thisContext?: Record<string, any>;
globalContext?: Record<string, any>;
};
export default function evaluate(
js: string,
data: DataTree,
resolvedFunctions: Record<string, any>,
context?: EvaluateContext,
evalArguments?: Array<any>,
isTriggerBased = false,
): EvalResult {
const unescapedJS = unEscapeScript(js);
const sanitizedScript = sanitizeScript(js);
const scriptType = getScriptType(evalArguments, isTriggerBased);
const script = getScriptToEval(unescapedJS, scriptType);
const script = getScriptToEval(sanitizedScript, scriptType);
// We are linting original js binding,
// This will make sure that the character count is not messed up when we do unescapejs
const scriptToLint = getScriptToEval(js, scriptType);
@ -160,6 +181,7 @@ export default function evaluate(
data,
resolvedFunctions,
isTriggerBased,
context,
evalArguments,
);

View File

@ -193,6 +193,7 @@ ctx.addEventListener(
resolvedFunctions,
EvaluationSubstitutionType.TEMPLATE,
true,
undefined,
callbackData,
fullPropertyPath,
);
@ -265,6 +266,7 @@ ctx.addEventListener(
evalTree,
resolvedFunctions,
undefined,
undefined,
true,
);
return result;
@ -274,7 +276,7 @@ ctx.addEventListener(
const evalTree = dataTreeEvaluator?.evalTree;
if (!evalTree) return {};
return isTrigger
? evaluate(expression, evalTree, {}, [], true)
? evaluate(expression, evalTree, {}, undefined, [], true)
: evaluate(expression, evalTree, {});
case EVAL_WORKER_ACTIONS.UPDATE_REPLAY_OBJECT:
const { entity, entityId, entityType } = requestData;

View File

@ -6,7 +6,7 @@ import {
} from "utils/DynamicBindingUtils";
import { JSHINT as jshint } from "jshint";
import { Severity } from "entities/AppsmithConsole";
import { last, keys, isEmpty } from "lodash";
import { isEmpty, keys, last } from "lodash";
import {
EvaluationScripts,
EvaluationScriptType,
@ -27,21 +27,20 @@ export const getPositionInEvaluationScript = (
return { line: lines.length, ch: lastLine.length };
};
const EvalutionScriptPositions: Record<string, Position> = {};
const EvaluationScriptPositions: Record<string, Position> = {};
function getEvaluationScriptPosition(scriptType: EvaluationScriptType) {
if (isEmpty(EvalutionScriptPositions)) {
if (isEmpty(EvaluationScriptPositions)) {
// We are computing position of <<script>> in our templates.
// This will be used to get the exact location of error in linting
keys(EvaluationScripts).forEach((type) => {
const location = getPositionInEvaluationScript(
EvaluationScriptPositions[type] = getPositionInEvaluationScript(
type as EvaluationScriptType,
);
EvalutionScriptPositions[type] = location;
});
}
return EvalutionScriptPositions[scriptType];
return EvaluationScriptPositions[scriptType];
}
export const getLintingErrors = (

View File

@ -795,7 +795,7 @@ export const VALIDATORS: Record<ValidationTypes, Validator> = {
};
if (config.params?.fnString && isString(config.params?.fnString)) {
try {
const { result } = evaluate(config.params.fnString, {}, {}, [
const { result } = evaluate(config.params.fnString, {}, {}, undefined, [
value,
props,
_,