feat(core): Add Tournament as the new default expression evaluator (#6964)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Val 2023-09-21 13:57:45 +01:00 committed by GitHub
parent 67b985fe89
commit bf74f09d69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 434 additions and 223 deletions

View file

@ -0,0 +1,14 @@
import config from '@/config';
import { ErrorReporterProxy, ExpressionEvaluatorProxy } from 'n8n-workflow';
export const initExpressionEvaluator = () => {
ExpressionEvaluatorProxy.setEvaluator(config.getEnv('expression.evaluator'));
ExpressionEvaluatorProxy.setDifferEnabled(config.getEnv('expression.reportDifference'));
ExpressionEvaluatorProxy.setDiffReporter((expr) => {
ErrorReporterProxy.warn('Expression difference', {
extra: {
expression: expr,
},
});
});
};

View file

@ -336,6 +336,9 @@ export class Server extends AbstractServer {
variables: {
limit: 0,
},
expressions: {
evaluator: config.getEnv('expression.evaluator'),
},
banners: {
dismissed: [],
},

View file

@ -21,6 +21,7 @@ import { InternalHooks } from '@/InternalHooks';
import { PostHogClient } from '@/posthog';
import { License } from '@/License';
import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee';
import { initExpressionEvaluator } from '@/ExpressionEvalator';
export abstract class BaseCommand extends Command {
protected logger = LoggerProxy.init(getLogger());
@ -39,6 +40,7 @@ export abstract class BaseCommand extends Command {
async init(): Promise<void> {
await initErrorHandling();
initExpressionEvaluator();
process.once('SIGTERM', async () => this.stopProcess());
process.once('SIGINT', async () => this.stopProcess());

View file

@ -1199,6 +1199,21 @@ export const schema = {
},
},
expression: {
evaluator: {
doc: 'Expression evaluator to use',
format: ['tmpl', 'tournament'] as const,
default: 'tournament',
env: 'N8N_EXPRESSION_EVALUATOR',
},
reportDifference: {
doc: 'Whether to report differences in the evaluator outputs',
format: Boolean,
default: false,
env: 'N8N_EXPRESSION_REPORT_DIFFERENCE',
},
},
sourceControl: {
defaultKeyPairType: {
doc: 'Default SSH key type to use when generating SSH keys',

View file

@ -59,6 +59,7 @@ import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { newVersions } from '@/mixins/newVersions';
import { useRoute } from 'vue-router';
import { useExternalHooks } from '@/composables';
import { ExpressionEvaluatorProxy } from 'n8n-workflow';
export default defineComponent({
name: 'App',
@ -148,6 +149,7 @@ export default defineComponent({
},
async initialize(): Promise<void> {
await this.initSettings();
ExpressionEvaluatorProxy.setEvaluator(useSettingsStore().settings.expressions.evaluator);
await Promise.all([this.loginWithCookie(), this.initTemplates()]);
},
trackPage(): void {

View file

@ -48,6 +48,7 @@
"@types/xml2js": "^0.4.11"
},
"dependencies": {
"@n8n/tournament": "^1.0.2",
"@n8n_io/riot-tmpl": "^4.0.0",
"ast-types": "0.15.2",
"crypto-js": "^4.1.1",

View file

@ -1,5 +1,5 @@
import * as tmpl from '@n8n_io/riot-tmpl';
import { DateTime, Duration, Interval } from 'luxon';
import * as tmpl from '@n8n_io/riot-tmpl';
import type {
IDataObject,
@ -22,6 +22,7 @@ import type { Workflow } from './Workflow';
import { extend, extendOptional } from './Extensions';
import { extendedFunctions } from './Extensions/ExtendedFunctions';
import { extendSyntax } from './Extensions/ExpressionExtension';
import { evaluateExpression, setErrorHandler } from './ExpressionEvaluatorProxy';
const IS_FRONTEND_IN_DEV_MODE =
typeof process === 'object' &&
@ -40,13 +41,10 @@ export const isExpressionError = (error: unknown): error is ExpressionError =>
export const isTypeError = (error: unknown): error is TypeError =>
error instanceof TypeError || (error instanceof Error && error.name === 'TypeError');
// Set it to use double curly brackets instead of single ones
tmpl.brackets.set('{{ }}');
// Make sure that error get forwarded
tmpl.tmpl.errorHandler = (error: Error) => {
setErrorHandler((error: Error) => {
if (isExpressionError(error)) throw error;
};
});
// eslint-disable-next-line @typescript-eslint/naming-convention
const AsyncFunction = (async () => {}).constructor as FunctionConstructor;
@ -339,7 +337,7 @@ export class Expression {
[Function, AsyncFunction].forEach(({ prototype }) =>
Object.defineProperty(prototype, 'constructor', { value: fnConstructors.mock }),
);
return tmpl.tmpl(expression, data);
return evaluateExpression(expression, data);
} catch (error) {
if (isExpressionError(error)) throw error;

View file

@ -0,0 +1,149 @@
import * as tmpl from '@n8n_io/riot-tmpl';
import type { ReturnValue, TmplDifference } from '@n8n/tournament';
import { Tournament } from '@n8n/tournament';
import type { ExpressionEvaluatorType } from './Interfaces';
import * as LoggerProxy from './LoggerProxy';
type Evaluator = (expr: string, data: unknown) => tmpl.ReturnValue;
type ErrorHandler = (error: Error) => void;
type DifferenceHandler = (expr: string) => void;
// Set it to use double curly brackets instead of single ones
tmpl.brackets.set('{{ }}');
let errorHandler: ErrorHandler = () => {};
let differenceHandler: DifferenceHandler = () => {};
const differenceChecker = (diff: TmplDifference) => {
try {
if (diff.same) {
return;
}
if (diff.has?.function || diff.has?.templateString) {
return;
}
if (diff.expression === 'UNPARSEABLE') {
differenceHandler(diff.expression);
} else {
differenceHandler(diff.expression.value);
}
} catch {
LoggerProxy.error('Expression evaluator difference checker failed');
}
};
const tournamentEvaluator = new Tournament(errorHandler, undefined);
let evaluator: Evaluator = tmpl.tmpl;
let currentEvaluatorType: ExpressionEvaluatorType = 'tmpl';
let diffExpressions = false;
export const setErrorHandler = (handler: ErrorHandler) => {
errorHandler = handler;
tmpl.tmpl.errorHandler = handler;
tournamentEvaluator.errorHandler = handler;
};
export const setEvaluator = (evalType: ExpressionEvaluatorType) => {
currentEvaluatorType = evalType;
if (evalType === 'tmpl') {
evaluator = tmpl.tmpl;
} else if (evalType === 'tournament') {
evaluator = tournamentEvaluator.execute.bind(tournamentEvaluator);
}
};
export const setDiffReporter = (reporter: (expr: string) => void) => {
differenceHandler = reporter;
};
export const setDifferEnabled = (enabled: boolean) => {
diffExpressions = enabled;
};
const diffCache: Record<string, TmplDifference | null> = {};
export const checkEvaluatorDifferences = (expr: string): TmplDifference | null => {
if (expr in diffCache) {
return diffCache[expr];
}
let diff: TmplDifference | null;
try {
diff = tournamentEvaluator.tmplDiff(expr);
} catch {
// We don't include the expression for privacy reasons
try {
differenceHandler('ERROR');
} catch {}
diff = null;
}
if (diff?.same === false) {
differenceChecker(diff);
}
diffCache[expr] = diff;
return diff;
};
export const getEvaluator = () => {
return evaluator;
};
export const evaluateExpression: Evaluator = (expr, data) => {
if (!diffExpressions) {
return evaluator(expr, data);
}
const diff = checkEvaluatorDifferences(expr);
// We already know that they're different so don't bother
// evaluating with both evaluators
if (!diff?.same) {
return evaluator(expr, data);
}
let tmplValue: tmpl.ReturnValue;
let tournValue: ReturnValue;
let wasTmplError = false;
let tmplError: unknown;
let wasTournError = false;
let tournError: unknown;
try {
tmplValue = tmpl.tmpl(expr, data);
} catch (error) {
tmplError = error;
wasTmplError = true;
}
try {
tournValue = tournamentEvaluator.execute(expr, data);
} catch (error) {
tournError = error;
wasTournError = true;
}
if (
wasTmplError !== wasTournError ||
JSON.stringify(tmplValue!) !== JSON.stringify(tournValue!)
) {
try {
if (diff.expression) {
differenceHandler(diff.expression.value);
} else {
differenceHandler('VALUEDIFF');
}
} catch {
LoggerProxy.error('Failed to report error difference');
}
}
if (currentEvaluatorType === 'tmpl') {
if (wasTmplError) {
throw tmplError;
}
return tmplValue!;
}
if (wasTournError) {
throw tournError;
}
return tournValue!;
};

View file

@ -2117,6 +2117,8 @@ export interface IPublicApiSettings {
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent';
export type ExpressionEvaluatorType = 'tmpl' | 'tournament';
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@ -2203,6 +2205,9 @@ export interface IN8nUISettings {
variables: {
limit: number;
};
expressions: {
evaluator: ExpressionEvaluatorType;
};
mfa: {
enabled: boolean;
};

View file

@ -1,5 +1,6 @@
import * as LoggerProxy from './LoggerProxy';
export * as ErrorReporterProxy from './ErrorReporterProxy';
export * as ExpressionEvaluatorProxy from './ExpressionEvaluatorProxy';
import * as NodeHelpers from './NodeHelpers';
import * as ObservableObject from './ObservableObject';
import * as TelemetryHelpers from './TelemetryHelpers';

View file

@ -11,8 +11,13 @@ import { baseFixtures } from './ExpressionFixtures/base';
import type { INodeExecutionData } from '@/Interfaces';
import { extendSyntax } from '@/Extensions/ExpressionExtension';
import { ExpressionError } from '@/ExpressionError';
import { setDifferEnabled, setEvaluator } from '@/ExpressionEvaluatorProxy';
describe('Expression', () => {
setDifferEnabled(true);
for (const evaluator of ['tmpl', 'tournament'] as const) {
setEvaluator(evaluator);
describe(`Expression (with ${evaluator})`, () => {
describe('getParameterValue()', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
@ -205,9 +210,9 @@ describe('Expression', () => {
for (const test of t.tests.filter(
(test) => test.type === 'evaluation',
) as ExpressionTestEvaluation[]) {
expect(evaluate(t.expression, test.input.map((d) => ({ json: d })) as any)).toStrictEqual(
test.output,
);
expect(
evaluate(t.expression, test.input.map((d) => ({ json: d })) as any),
).toStrictEqual(test.output);
}
});
}
@ -228,4 +233,5 @@ describe('Expression', () => {
});
}
});
});
});
}

View file

@ -1277,6 +1277,9 @@ importers:
packages/workflow:
dependencies:
'@n8n/tournament':
specifier: ^1.0.2
version: 1.0.2
'@n8n_io/riot-tmpl':
specifier: ^4.0.0
version: 4.0.0
@ -4635,6 +4638,16 @@ packages:
- '@lezer/common'
dev: false
/@n8n/tournament@1.0.2:
resolution: {integrity: sha512-fTpi7F8ra5flGSVfRzohPyG7czAAKCZPlLjdKdwbLJivLoI/Ekhgodov1jfVSCVFVbwQ06gRQRxLEDzl2jl8ig==}
engines: {node: '>=18.10', pnpm: '>=8.6'}
dependencies:
'@n8n_io/riot-tmpl': 4.0.1
ast-types: 0.16.1
esprima-next: 5.8.4
recast: 0.22.0
dev: false
/@n8n/vm2@3.9.20:
resolution: {integrity: sha512-qk2oJYkuFRVSTxoro4obX/sv/wT1pViZjHh/isjOvFB93D52QIg3TCjMPsHOfHTmkxCKJffjLrUvjIwvWzSMCQ==}
engines: {node: '>=18.10', pnpm: '>=8.6.12'}
@ -4660,6 +4673,12 @@ packages:
eslint-config-riot: 1.0.0
dev: false
/@n8n_io/riot-tmpl@4.0.1:
resolution: {integrity: sha512-/zdRbEfTFjsm1NqnpPQHgZTkTdbp5v3VUxGeMA9098sps8jRCTraQkc3AQstJgHUm7ylBXJcIVhnVeLUMWAfwQ==}
dependencies:
eslint-config-riot: 1.0.0
dev: false
/@ndelangen/get-tarball@3.0.7:
resolution: {integrity: sha512-NqGfTZIZpRFef1GoVaShSSRwDC3vde3ThtTeqFdcYd6ipKqnfEVhjK2hUeHjCQUcptyZr2TONqcloFXM+5QBrQ==}
dependencies:
@ -8890,7 +8909,6 @@ packages:
is-nan: 1.3.2
object-is: 1.1.5
util: 0.12.5
dev: true
/assertion-error@1.1.0:
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
@ -8919,7 +8937,6 @@ packages:
engines: {node: '>=4'}
dependencies:
tslib: 2.6.1
dev: true
/astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
@ -11483,7 +11500,6 @@ packages:
/es6-object-assign@1.1.0:
resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==}
dev: true
/es6-symbol@3.1.3:
resolution: {integrity: sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==}
@ -18637,7 +18653,6 @@ packages:
esprima: 4.0.1
source-map: 0.6.1
tslib: 2.6.1
dev: true
/recast@0.23.3:
resolution: {integrity: sha512-HbCVFh2ANP6a09nzD4lx7XthsxMOJWKX5pIcUwtLrmeEIl3I0DwjCoVXDE0Aobk+7k/mS3H50FK4iuYArpcT6Q==}