feat(eval): compiled and cached expressions

This commit is contained in:
Ajai Shankar 2023-02-10 21:55:05 -06:00
parent ddd39e630d
commit df4f322024
3 changed files with 119 additions and 5 deletions

View File

@ -9,6 +9,9 @@
"peerDependencies": {
"vm2": "^3.9.13"
},
"scripts": {
"test": "jest --testPathIgnorePatterns test.js"
},
"dependencies": {
"atob": "^2.1.2",
"ajv": "^8.12.0",

View File

@ -1,15 +1,57 @@
const jsonQuery = require('json-query');
const JS_KEYWORDS = `
break case catch class const continue debugger default delete do
else export extends false finally for function if import in instanceof
new null return super switch this throw true try typeof var void while with
undefined let static yield arguments of
`.split(/\s+/).filter(word => word.length > 0);
/**
* Creates a function from a Javascript expression
*
* When the function is called, the variables used in this expression are picked up from the context
*
* ```js
* res.data.pets.map(pet => pet.name.toUpperCase())
*
* function(context) {
* const { res, pet } = context;
* return res.data.pets.map(pet => pet.name.toUpperCase())
* }
* ```
*/
const compileJsExpression = (expr) => {
// get all dotted identifiers (foo, bar.baz, .baz)
const matches = expr.match(/([\w\.$]+)/g) ?? [];
// get valid js identifiers (foo, bar)
const names = matches
.filter(match => /^[a-zA-Z$_]/.test(match))
.map(match => match.split('.')[0]);
// exclude js keywords and get unique vars
const vars = new Set(names.filter(name => !JS_KEYWORDS.includes(name)));
const spread = [...vars].join(", ");
const body = `const { ${spread} } = context; return ${expr}`;
return new Function("context", body);
};
const internalExpressionCache = new Map();
const evaluateJsExpression = (expression, context) => {
const fn = new Function(...Object.keys(context), `return ${expression}`);
return fn(...Object.values(context));
let fn = internalExpressionCache.get(expression);
if (fn == null) {
internalExpressionCache.set(expression, fn = compileJsExpression(expression));
}
return fn(context);
};
const createResponseParser = (response = {}) => {
const res = (expr) => {
const res = (expr) => {
const output = jsonQuery(expr, { data: response.data });
return output ? output.value : null;
}
};
res.status = response.status;
res.statusText = response.statusText;
@ -21,5 +63,6 @@ const createResponseParser = (response = {}) => {
module.exports = {
evaluateJsExpression,
createResponseParser
createResponseParser,
internalExpressionCache
};

View File

@ -0,0 +1,68 @@
const { evaluateJsExpression, internalExpressionCache: cache } = require("../src/utils");
describe("utils", () => {
describe("expression evaluation", () => {
const context = {
res: {
data: { pets: ["bruno", "max"] }
}
};
afterEach(() => cache.clear());
it("should evaluate expression", () => {
let result;
result = evaluateJsExpression("res.data.pets", context);
expect(result).toEqual(["bruno", "max"]);
result = evaluateJsExpression("res.data.pets[0].toUpperCase()", context);
expect(result).toEqual("BRUNO");
});
it("should cache expression", () => {
expect(cache.size).toBe(0);
evaluateJsExpression("res.data.pets", context);
expect(cache.size).toBe(1);
});
it("should identify top level variables", () => {
const expr = "res.data.pets[0].toUpperCase()";
evaluateJsExpression(expr, context);
expect(cache.get(expr).toString()).toContain("const { res } = context;");
});
it("should not duplicate variables", () => {
const expr = "res.data.pets[0] + res.data.pets[1]";
evaluateJsExpression(expr, context);
expect(cache.get(expr).toString()).toContain("const { res } = context;");
});
it("should exclude js keywords like true false from vars", () => {
const expr = "res.data.pets.length > 0 ? true : false";
evaluateJsExpression(expr, context);
expect(cache.get(expr).toString()).toContain("const { res } = context;");
});
it("should exclude numbers from vars", () => {
const expr = "res.data.pets.length + 10";
evaluateJsExpression(expr, context);
expect(cache.get(expr).toString()).toContain("const { res } = context;");
});
it("should pick variables from complex expressions", () => {
const expr = "res.data.pets.map(pet => pet.length)";
const result = evaluateJsExpression(expr, context);
expect(result).toEqual([5, 3]);
expect(cache.get(expr).toString()).toContain("const { res, pet } = context;");
});
it("should be ok picking extra vars from strings", () => {
const expr = "'hello' + ' ' + res.data.pets[0]";
const result = evaluateJsExpression(expr, context);
expect(result).toBe("hello bruno");
// extra var hello is harmless
expect(cache.get(expr).toString()).toContain("const { hello, res } = context;");
});
});
});