n8n/packages/workflow/src/Extensions/ExpressionExtension.ts

270 lines
8.9 KiB
TypeScript
Raw Normal View History

feat: Expression extension framework (#4372) * :zap: Introduce a framework for expression extension * :bulb: Add some inline comments * :zap: Introduce hash alias for encrypt * :zap: Introduce a manual granular level approach to shadowing/overrideing extensions * :fire: Cleanup comments * :zap: Introduce a basic method of extension for native functions * :zap: Add length to StringExtension * :zap: Add number type to extension return types * :zap: Temporarily introduce DateTime with extension * :zap: Cleanup comments * :zap: Organize imports * :recycle: Fix up some typings * :zap: Fix typings * :recycle: Remove unnecessary resolve of expression * :zap: Extensions Improvement * :recycle: Refactor EXPRESSION_EXTENSION_METHODS * :recycle: Refactor EXPRESSION_EXTENSION_METHODS * :recycle: Update extraArgs types * :recycle: Fix tests * :recycle: Fix bind type issue * :recycle: Fixing duration type issue * :recycle: Refactor to allow overrides on native methods * :recycle: Temporarily remove Date Extensions to pass tests * feat(dt-functions): introduce date expression extensions (#4045) * :tada: Add Date Extensions into the mix * :sparkles: Introduce additional date extension methods * :white_check_mark: Add Date Expression Extension tests * :wrench: Add ability to debug tests * :recycle: Refactor extension for native types * :fire: Move sayHi method to String Extension class * :recycle: Update scope when binding member methods * :white_check_mark: Add String Extension tests * feat(dt-functions): introduce array expression extensions (#4044) * :sparkles: Introduce Array Extensions * :white_check_mark: Add Array Expression tests * feat(dt-functions): introduce number expression extensions (#4046) * :tada: Introduce Number Extensions * :zap: Support more shared extensions * :zap: Improve handling of name collision * :white_check_mark: Update tests * Fixed up tests * :fire: Remove remove markdown * :recylce: Replace remove-markdown dependencies with implementation * :recycle: Replace remove-markdown dependencies with implementation * :white_check_mark: Update tests * :recycle: Fix scoping and cleanup * :recycle: Update comments and errors * :recycle: Fix linting errors * :heavy_minus_sign: Remove unused dependencies * fix: expression extension not working with multiple extensions * refactor: change extension transform to be more efficient * test: update most test to work with new extend function * fix: update and fix type error in config * refactor: replace babel with recast * feat: add hashing functions to string extension * fix: removed export * test: add extension parser and transform tests * fix: vite tests breaking * refactor: remove commented out code * fix: parse dates passed from $json in extend function * refactor: review feedback changes for date extensions * refactor: review feedback changes for number extensions * fix: date extension beginningOf test * fix: broken build from merge * fix: another merge issue * refactor: address review feedback (remove ignores) * feat: new extension functions and tests * feat: non-dot notation functions * test: most of the other tests * fix: toSentenceCase for node versions below 16.6 * feat: add $if and $not expression extensions * Fix test to work on every timezone * lint: fix remaining lint issues Co-authored-by: Csaba Tuncsik <csaba@n8n.io> Co-authored-by: Omar Ajoue <krynble@gmail.com>
2023-01-10 05:06:12 -08:00
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { DateTime } from 'luxon';
import { ExpressionExtensionError } from '../ExpressionError';
import { parse, visit, types, print } from 'recast';
import { arrayExtensions } from './ArrayExtensions';
import { dateExtensions } from './DateExtensions';
import { numberExtensions } from './NumberExtensions';
import { stringExtensions } from './StringExtensions';
import { objectExtensions } from './ObjectExtensions';
const EXPRESSION_EXTENDER = 'extend';
function isBlank(value: unknown) {
return value === null || value === undefined || !value;
}
function isPresent(value: unknown) {
return !isBlank(value);
}
const EXTENSION_OBJECTS = [
arrayExtensions,
dateExtensions,
numberExtensions,
objectExtensions,
stringExtensions,
];
// eslint-disable-next-line @typescript-eslint/ban-types
const genericExtensions: Record<string, Function> = {
isBlank,
isPresent,
};
const EXPRESSION_EXTENSION_METHODS = Array.from(
new Set([
...Object.keys(stringExtensions.functions),
...Object.keys(numberExtensions.functions),
...Object.keys(dateExtensions.functions),
...Object.keys(arrayExtensions.functions),
...Object.keys(objectExtensions.functions),
...Object.keys(genericExtensions),
'$if',
]),
);
const isExpressionExtension = (str: string) => EXPRESSION_EXTENSION_METHODS.some((m) => m === str);
export const hasExpressionExtension = (str: string): boolean =>
EXPRESSION_EXTENSION_METHODS.some((m) => str.includes(m));
export const hasNativeMethod = (method: string): boolean => {
if (hasExpressionExtension(method)) {
return false;
}
const methods = method
.replace(/[^\w\s]/gi, ' ')
.split(' ')
.filter(Boolean); // DateTime.now().toLocaleString().format() => [DateTime,now,toLocaleString,format]
return methods.every((methodName) => {
return [String.prototype, Array.prototype, Number.prototype, Date.prototype].some(
(nativeType) => {
if (methodName in nativeType) {
return true;
}
return false;
},
);
});
};
/**
* recast's types aren't great and we need to use a lot of anys
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const findParent = <T>(path: T, matcher: (path: T) => boolean): T | undefined => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let parent = path.parentPath;
while (parent) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (matcher(parent)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return parent;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parent = parent.parentPath;
}
return;
};
/**
* A function to inject an extender function call into the AST of an expression.
* This uses recast to do the transform.
*
* ```ts
* 'a'.method('x') // becomes
* extend('a', 'method', ['x']);
*
* 'a'.first('x').second('y') // becomes
* extend(extend('a', 'first', ['x']), 'second', ['y']));
* ```
*/
export const extendTransform = (expression: string): { code: string } | undefined => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ast = parse(expression);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
visit(ast, {
visitIdentifier(path) {
this.traverse(path);
if (path.node.name === '$if') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callPath: any = findParent(path, (p) => p.value?.type === 'CallExpression');
if (!callPath || callPath.value?.type !== 'CallExpression') {
return;
}
if (callPath.node.arguments.length < 2) {
throw new ExpressionExtensionError(
'$if requires at least 2 parameters: test, value_if_true[, and value_if_false]',
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const test = callPath.node.arguments[0];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const consequent = callPath.node.arguments[1];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const alternative =
callPath.node.arguments[2] === undefined
? types.builders.booleanLiteral(false)
: callPath.node.arguments[2];
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument
callPath.replace(types.builders.conditionalExpression(test, consequent, alternative));
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
visit(ast, {
visitIdentifier(path) {
this.traverse(path);
if (
isExpressionExtension(path.node.name) &&
// types.namedTypes.MemberExpression.check(path.parent)
path.parentPath?.value?.type === 'MemberExpression'
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callPath: any = findParent(path, (p) => p.value?.type === 'CallExpression');
if (!callPath || callPath.value?.type !== 'CallExpression') {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
callPath.replace(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
types.builders.callExpression(types.builders.identifier(EXPRESSION_EXTENDER), [
path.parentPath.value.object,
types.builders.stringLiteral(path.node.name),
// eslint-disable-next-line
types.builders.arrayExpression(callPath.node.arguments),
]),
);
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument
return print(ast);
} catch (e) {
return;
}
};
function isDate(input: unknown): boolean {
if (typeof input !== 'string' || !input.length) {
return false;
}
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(input)) {
return false;
}
const d = new Date(input);
return d instanceof Date && !isNaN(d.valueOf()) && d.toISOString() === input;
}
/**
* Extender function injected by expression extension plugin to allow calls to extensions.
*
* ```ts
* extend(input, "functionName", [...args]);
* ```
*/
export function extend(input: unknown, functionName: string, args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/ban-types
let foundFunction: Function | undefined;
if (Array.isArray(input)) {
foundFunction = arrayExtensions.functions[functionName];
} else if (isDate(input) && functionName !== 'toDate') {
// If it's a string date (from $json), convert it to a Date object,
// unless that function is `toDate`, since `toDate` does something
// very different on date objects
input = new Date(input as string);
foundFunction = dateExtensions.functions[functionName];
} else if (typeof input === 'string') {
foundFunction = stringExtensions.functions[functionName];
} else if (typeof input === 'number') {
foundFunction = numberExtensions.functions[functionName];
} else if (input && (DateTime.isDateTime(input) || input instanceof Date)) {
foundFunction = dateExtensions.functions[functionName];
} else if (input !== null && typeof input === 'object') {
foundFunction = objectExtensions.functions[functionName];
}
// Look for generic or builtin
if (!foundFunction) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputAny: any = input;
// This is likely a builtin we're implementing for another type
// (e.g. toLocaleString). We'll just call it
if (
inputAny &&
functionName &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
typeof inputAny[functionName] === 'function'
) {
// I was having weird issues with eslint not finding rules on this line.
// Just disabling all eslint rules for now.
// eslint-disable-next-line
return inputAny[functionName](...args);
}
// Use a generic version if available
foundFunction = genericExtensions[functionName];
}
// No type specific or generic function found. Check to see if
// any types have a function with that name. Then throw an error
// letting the user know the available types.
if (!foundFunction) {
const haveFunction = EXTENSION_OBJECTS.filter((v) => functionName in v.functions);
if (!haveFunction.length) {
// This shouldn't really be possible but we should cover it anyway
throw new ExpressionExtensionError(`Unknown expression function: ${functionName}`);
}
if (haveFunction.length > 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const lastType = `"${haveFunction.pop()!.typeName}"`;
const typeNames = `${haveFunction.map((v) => `"${v.typeName}"`).join(', ')}, and ${lastType}`;
throw new ExpressionExtensionError(
`${functionName}() is only callable on types ${typeNames}`,
);
} else {
throw new ExpressionExtensionError(
`${functionName}() is only callable on type "${haveFunction[0].typeName}"`,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction(input, args);
}