feat(core): Improve paired item and add additional variables (#3765)

*  Remove duplicate and old string

*  Add telemetry

*  Futher improvements

*  Change error message and display only name of last parameter

* 👕 Fix lint issue

*  Remove not needed comments

*  Rename properties, add new ones and improve error messages

*  Add support for $execution, $prevNode and make it possible to use proxies as object

*  Some small improvements

* 🐛 Fix error message

*  Improve some error messages

*  Change resumeUrl variable and display in editor

*  Fix and extend tests

*  Multiple pairedItem improvements

*  Display "More Info" link with error messages if user can fix issue

*  Display different errors in Function Nodes
This commit is contained in:
Jan Oberhauser 2022-09-29 23:02:25 +02:00 committed by GitHub
parent 737cbf9694
commit 5526057efc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 684 additions and 301 deletions

View file

@ -160,8 +160,14 @@ export class InternalHooksClass implements IInternalHooksClass {
if (!properties.success && runData?.data.resultData.error) { if (!properties.success && runData?.data.resultData.error) {
properties.error_message = runData?.data.resultData.error.message; properties.error_message = runData?.data.resultData.error.message;
let errorNodeName = runData?.data.resultData.error.node?.name; let errorNodeName =
properties.error_node_type = runData?.data.resultData.error.node?.type; 'node' in runData?.data.resultData.error
? runData?.data.resultData.error.node?.name
: undefined;
properties.error_node_type =
'node' in runData?.data.resultData.error
? runData?.data.resultData.error.node?.type
: undefined;
if (runData.data.resultData.lastNodeExecuted) { if (runData.data.resultData.lastNodeExecuted) {
const lastNode = TelemetryHelpers.getNodeTypeForName( const lastNode = TelemetryHelpers.getNodeTypeForName(

View file

@ -1487,11 +1487,20 @@ export async function requestWithAuthentication(
*/ */
export function getAdditionalKeys( export function getAdditionalKeys(
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
): IWorkflowDataProxyAdditionalKeys { ): IWorkflowDataProxyAdditionalKeys {
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID; const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
return { return {
$execution: {
id: executionId,
mode: mode === 'manual' ? 'test' : 'production',
resumeUrl,
},
// deprecated
$executionId: executionId, $executionId: executionId,
$resumeWebhookUrl: `${additionalData.webhookWaitingBaseUrl}/${executionId}`, $resumeWebhookUrl: resumeUrl,
}; };
} }
@ -1601,7 +1610,7 @@ export async function getCredentials(
// TODO: solve using credentials via expression // TODO: solve using credentials via expression
// if (name.charAt(0) === '=') { // if (name.charAt(0) === '=') {
// // If the credential name is an expression resolve it // // If the credential name is an expression resolve it
// const additionalKeys = getAdditionalKeys(additionalData); // const additionalKeys = getAdditionalKeys(additionalData, mode);
// name = workflow.expression.getParameterValue( // name = workflow.expression.getParameterValue(
// name, // name,
// runExecutionData || null, // runExecutionData || null,
@ -1638,30 +1647,29 @@ export function getNode(node: INode): INode {
* Clean up parameter data to make sure that only valid data gets returned * Clean up parameter data to make sure that only valid data gets returned
* INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking * INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking
*/ */
function cleanupParameterData(inputData: NodeParameterValueType): NodeParameterValueType { function cleanupParameterData(inputData: NodeParameterValueType): void {
if (inputData === null || inputData === undefined) { if (typeof inputData !== 'object' || inputData === null) {
return inputData; return;
} }
if (Array.isArray(inputData)) { if (Array.isArray(inputData)) {
inputData.forEach((value) => cleanupParameterData(value)); inputData.forEach((value) => cleanupParameterData(value));
return inputData; return;
}
if (inputData.constructor.name === 'DateTime') {
// Is a special luxon date so convert to string
return inputData.toString();
} }
if (typeof inputData === 'object') { if (typeof inputData === 'object') {
Object.keys(inputData).forEach((key) => { Object.keys(inputData).forEach((key) => {
inputData[key as keyof typeof inputData] = cleanupParameterData( if (typeof inputData[key as keyof typeof inputData] === 'object') {
inputData[key as keyof typeof inputData], if (inputData[key as keyof typeof inputData]?.constructor.name === 'DateTime') {
); // Is a special luxon date so convert to string
inputData[key as keyof typeof inputData] =
inputData[key as keyof typeof inputData]?.toString();
} else {
cleanupParameterData(inputData[key as keyof typeof inputData]);
}
}
}); });
} }
return inputData;
} }
/** /**
@ -1710,7 +1718,7 @@ export function getNodeParameter(
executeData, executeData,
); );
returnData = cleanupParameterData(returnData); cleanupParameterData(returnData);
} catch (e) { } catch (e) {
if (e.context) e.context.parameter = parameterName; if (e.context) e.context.parameter = parameterName;
e.cause = value; e.cause = value;
@ -1883,7 +1891,7 @@ export function getExecutePollFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2032,7 +2040,7 @@ export function getExecuteTriggerFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2160,7 +2168,7 @@ export function getExecuteFunctions(
connectionInputData, connectionInputData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
executeData, executeData,
); );
}, },
@ -2237,7 +2245,7 @@ export function getExecuteFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
executeData, executeData,
fallbackValue, fallbackValue,
options, options,
@ -2272,7 +2280,7 @@ export function getExecuteFunctions(
{}, {},
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
executeData, executeData,
); );
return dataProxy.getDataProxy(); return dataProxy.getDataProxy();
@ -2421,7 +2429,7 @@ export function getExecuteSingleFunctions(
connectionInputData, connectionInputData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
executeData, executeData,
); );
}, },
@ -2501,7 +2509,7 @@ export function getExecuteSingleFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
executeData, executeData,
fallbackValue, fallbackValue,
options, options,
@ -2521,7 +2529,7 @@ export function getExecuteSingleFunctions(
{}, {},
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
executeData, executeData,
); );
return dataProxy.getDataProxy(); return dataProxy.getDataProxy();
@ -2658,6 +2666,7 @@ export function getLoadOptionsFunctions(
const runExecutionData: IRunExecutionData | null = null; const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0; const itemIndex = 0;
const runIndex = 0; const runIndex = 0;
const mode = 'internal' as WorkflowExecuteMode;
const connectionInputData: INodeExecutionData[] = []; const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter( return getNodeParameter(
@ -2668,9 +2677,9 @@ export function getLoadOptionsFunctions(
node, node,
parameterName, parameterName,
itemIndex, itemIndex,
'internal' as WorkflowExecuteMode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2792,7 +2801,7 @@ export function getExecuteHookFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2806,7 +2815,7 @@ export function getExecuteHookFunctions(
additionalData, additionalData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
isTest, isTest,
); );
}, },
@ -2945,7 +2954,7 @@ export function getExecuteWebhookFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2983,7 +2992,7 @@ export function getExecuteWebhookFunctions(
additionalData, additionalData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData), getAdditionalKeys(additionalData, mode),
); );
}, },
getTimezone: (): string => { getTimezone: (): string => {

View file

@ -78,6 +78,13 @@ export default mixins(
const connectionInputData = this.connectionInputData(parentNode, activeNode!.name, inputName, runIndex, nodeConnection); const connectionInputData = this.connectionInputData(parentNode, activeNode!.name, inputName, runIndex, nodeConnection);
const additionalProxyKeys: IWorkflowDataProxyAdditionalKeys = { const additionalProxyKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, $executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, $resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
}; };

View file

@ -2,7 +2,7 @@
<div> <div>
<div class="error-header"> <div class="error-header">
<div class="error-message">{{ $locale.baseText('nodeErrorView.error') + ': ' + getErrorMessage() }}</div> <div class="error-message">{{ $locale.baseText('nodeErrorView.error') + ': ' + getErrorMessage() }}</div>
<div class="error-description" v-if="error.description">{{getErrorDescription()}}</div> <div class="error-description" v-if="error.description" v-html="getErrorDescription()"></div>
</div> </div>
<details> <details>
<summary class="error-details__summary"> <summary class="error-details__summary">
@ -139,28 +139,34 @@ export default mixins(
}, },
}, },
methods: { methods: {
replacePlaceholders (parameter: string, message: string): string {
const parameterName = this.parameterDisplayName(parameter, false);
const parameterFullName = this.parameterDisplayName(parameter, true);
return message.replace(/%%PARAMETER%%/g, parameterName).replace(/%%PARAMETER_FULL%%/g, parameterFullName);
},
getErrorDescription (): string { getErrorDescription (): string {
if (!this.error.context || !this.error.context.descriptionTemplate) { if (!this.error.context || !this.error.context.descriptionTemplate) {
return this.error.description; return this.error.description;
} }
return this.replacePlaceholders(this.error.context.parameter, this.error.context.descriptionTemplate);
const parameterName = this.parameterDisplayName(this.error.context.parameter);
return this.error.context.descriptionTemplate.replace(/%%PARAMETER%%/g, parameterName);
}, },
getErrorMessage (): string { getErrorMessage (): string {
if (!this.error.context || !this.error.context.messageTemplate) { if (!this.error.context || !this.error.context.messageTemplate) {
return this.error.message; return this.error.message;
} }
const parameterName = this.parameterDisplayName(this.error.context.parameter); return this.replacePlaceholders(this.error.context.parameter, this.error.context.messageTemplate);
return this.error.context.messageTemplate.replace(/%%PARAMETER%%/g, parameterName);
}, },
parameterDisplayName(path: string) { parameterDisplayName(path: string, fullPath = true) {
try { try {
const parameters = this.parameterName(this.parameters, path.split('.')); const parameters = this.parameterName(this.parameters, path.split('.'));
if (!parameters.length) { if (!parameters.length) {
throw new Error(); throw new Error();
} }
if (fullPath === false) {
return parameters.pop()!.displayName;
}
return parameters.map(parameter => parameter.displayName).join(' > '); return parameters.map(parameter => parameter.displayName).join(' > ');
} catch (error) { } catch (error) {
return `Could not find parameter "${path}"`; return `Could not find parameter "${path}"`;

View file

@ -420,6 +420,13 @@ export default mixins(
} }
const additionalKeys: IWorkflowDataProxyAdditionalKeys = { const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, $executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, $resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
}; };

View file

@ -1,12 +1,6 @@
import { import {
IExecutionsCurrentSummaryExtended, IExecutionsCurrentSummaryExtended,
IPushData, IPushData,
IPushDataConsoleMessage,
IPushDataExecutionFinished,
IPushDataExecutionStarted,
IPushDataNodeExecuteAfter,
IPushDataNodeExecuteBefore,
IPushDataTestWebhook,
} from '../../Interface'; } from '../../Interface';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
@ -16,7 +10,11 @@ import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { import {
ExpressionError,
IDataObject,
INodeTypeNameVersion, INodeTypeNameVersion,
IWorkflowBase,
TelemetryHelpers,
} from 'n8n-workflow'; } from 'n8n-workflow';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
@ -215,7 +213,7 @@ export const pushConnection = mixins(
const runDataExecuted = pushData.data; const runDataExecuted = pushData.data;
const runDataExecutedErrorMessage = this.$getExecutionError(runDataExecuted.data.resultData.error); const runDataExecutedErrorMessage = this.$getExecutionError(runDataExecuted.data);
const workflow = this.getCurrentWorkflow(); const workflow = this.getCurrentWorkflow();
if (runDataExecuted.waitTill !== undefined) { if (runDataExecuted.waitTill !== undefined) {
@ -251,8 +249,48 @@ export const pushConnection = mixins(
} else if (runDataExecuted.finished !== true) { } else if (runDataExecuted.finished !== true) {
this.$titleSet(workflow.name as string, 'ERROR'); this.$titleSet(workflow.name as string, 'ERROR');
if (
runDataExecuted.data.resultData.error!.name === 'ExpressionError' &&
(runDataExecuted.data.resultData.error as ExpressionError).context.functionality === 'pairedItem'
) {
const error = runDataExecuted.data.resultData.error as ExpressionError;
this.getWorkflowDataToSave().then((workflowData) => {
const eventData: IDataObject = {
caused_by_credential: false,
error_message: error.description,
error_title: error.message,
error_type: error.context.type,
node_graph_string: JSON.stringify(TelemetryHelpers.generateNodesGraph(workflowData as IWorkflowBase, this.getNodeTypes()).nodeGraph),
workflow_id: this.$store.getters.workflowId,
};
if (error.context.nodeCause && ['no pairing info', 'invalid pairing info'].includes(error.context.type as string)) {
const node = workflow.getNode(error.context.nodeCause as string);
if (node) {
eventData.is_pinned = !!workflow.getPinDataOfNode(node.name);
eventData.mode = node.parameters.mode;
eventData.node_type = node.type;
eventData.operation = node.parameters.operation;
eventData.resource = node.parameters.resource;
}
}
this.$telemetry.track('Instance FE emitted paired item error', eventData);
});
}
let title: string;
if (runDataExecuted.data.resultData.lastNodeExecuted) {
title = `Problem in node ${runDataExecuted.data.resultData.lastNodeExecuted}`;
} else {
title = 'Problem executing workflow';
}
this.$showMessage({ this.$showMessage({
title: 'Problem executing workflow', title,
message: runDataExecutedErrorMessage, message: runDataExecutedErrorMessage,
type: 'error', type: 'error',
duration: 0, duration: 0,

View file

@ -3,10 +3,9 @@ import { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { ExecutionError } from 'n8n-workflow'; import { IRunExecutionData } from 'n8n-workflow';
import type { ElMessageBoxOptions } from 'element-ui/types/message-box'; import type { ElMessageBoxOptions } from 'element-ui/types/message-box';
import type { ElMessageComponent, ElMessageOptions, MessageType } from 'element-ui/types/message'; import type { ElMessageComponent, ElMessageOptions, MessageType } from 'element-ui/types/message';
import { isChildOf } from './helpers';
import { sanitizeHtml } from '@/utils'; import { sanitizeHtml } from '@/utils';
let stickyNotificationQueue: ElNotificationComponent[] = []; let stickyNotificationQueue: ElNotificationComponent[] = [];
@ -83,16 +82,22 @@ export const showMessage = mixins(externalHooks).extend({
return this.$message(config); return this.$message(config);
}, },
$getExecutionError(error?: ExecutionError) { $getExecutionError(data: IRunExecutionData) {
// There was a problem with executing the workflow const error = data.resultData.error;
let errorMessage = 'There was a problem executing the workflow!';
let errorMessage: string;
if (data.resultData.lastNodeExecuted && error) {
errorMessage = error.message;
} else {
errorMessage = 'There was a problem executing the workflow!';
if (error && error.message) { if (error && error.message) {
let nodeName: string | undefined; let nodeName: string | undefined;
if (error.node) { if ('node' in error) {
nodeName = typeof error.node === 'string' nodeName = typeof error.node === 'string'
? error.node ? error.node
: error.node.name; : error.node!.name;
} }
const receivedError = nodeName const receivedError = nodeName
@ -100,6 +105,7 @@ export const showMessage = mixins(externalHooks).extend({
: error.message; : error.message;
errorMessage = `There was a problem executing the workflow:<br /><strong>"${receivedError}"</strong>`; errorMessage = `There was a problem executing the workflow:<br /><strong>"${receivedError}"</strong>`;
} }
}
return errorMessage; return errorMessage;
}, },

View file

@ -498,7 +498,7 @@ export const workflowHelpers = mixins(
getWebhookUrl (webhookData: IWebhookDescription, node: INode, showUrlFor?: string): string { getWebhookUrl (webhookData: IWebhookDescription, node: INode, showUrlFor?: string): string {
if (webhookData.restartWebhook === true) { if (webhookData.restartWebhook === true) {
return '$resumeWebhookUrl'; return '$execution.resumeUrl';
} }
let baseUrl = this.$store.getters.getWebhookUrl; let baseUrl = this.$store.getters.getWebhookUrl;
if (showUrlFor === 'test') { if (showUrlFor === 'test') {
@ -577,6 +577,13 @@ export const workflowHelpers = mixins(
} }
const additionalKeys: IWorkflowDataProxyAdditionalKeys = { const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, $executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, $resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
}; };

View file

@ -309,7 +309,27 @@ export const TEST_PIN_DATA = [
code: 2, code: 2,
}, },
]; ];
export const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`, '$parameter']; export const MAPPING_PARAMS = [
'$binary',
'$data',
'$env',
'$evaluateExpression',
'$execution',
'$input',
'$item',
'$jmespath',
'$json',
'$node',
'$now',
'$parameter',
'$parameters',
'$position',
'$prevNode',
'$resumeWebhookUrl',
'$runIndex',
'$today',
'$workflow',
];
export const DEFAULT_STICKY_HEIGHT = 160; export const DEFAULT_STICKY_HEIGHT = 160;
export const DEFAULT_STICKY_WIDTH = 240; export const DEFAULT_STICKY_WIDTH = 240;

View file

@ -535,8 +535,8 @@ export default mixins(
if (nodeErrorFound === false) { if (nodeErrorFound === false) {
const resultError = data.data.resultData.error; const resultError = data.data.resultData.error;
const errorMessage = this.$getExecutionError(resultError); const errorMessage = this.$getExecutionError(data.data);
const shouldTrack = resultError && resultError.node && resultError.node.type.startsWith('n8n-nodes-base'); const shouldTrack = resultError && 'node' in resultError && resultError.node!.type.startsWith('n8n-nodes-base');
this.$showMessage({ this.$showMessage({
title: 'Failed execution', title: 'Failed execution',
message: errorMessage, message: errorMessage,

View file

@ -114,7 +114,7 @@ export class Wait implements INodeType {
], ],
default: 'none', default: 'none',
description: description:
'If and how incoming resume-webhook-requests to $resumeWebhookUrl should be authenticated for additional security', 'If and how incoming resume-webhook-requests to $execution.resumeUrl should be authenticated for additional security',
}, },
{ {
displayName: 'Resume', displayName: 'Resume',
@ -212,7 +212,7 @@ export class Wait implements INodeType {
// ---------------------------------- // ----------------------------------
{ {
displayName: displayName:
'The webhook URL will be generated at run time. It can be referenced with the <strong>$resumeWebhookUrl</strong> variable. Send it somewhere before getting to this node. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=n8n-nodes-base.wait" target="_blank">More info</a>', 'The webhook URL will be generated at run time. It can be referenced with the <strong>$execution.resumeUrl</strong> variable. Send it somewhere before getting to this node. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=n8n-nodes-base.wait" target="_blank">More info</a>',
name: 'webhookNotice', name: 'webhookNotice',
type: 'notice', type: 'notice',
displayOptions: { displayOptions: {

View file

@ -1,3 +1,5 @@
/* eslint-disable import/no-cycle */
import { IDataObject } from './Interfaces';
import { ExecutionBaseError } from './NodeErrors'; import { ExecutionBaseError } from './NodeErrors';
/** /**
@ -10,11 +12,14 @@ export class ExpressionError extends ExecutionBaseError {
causeDetailed?: string; causeDetailed?: string;
description?: string; description?: string;
descriptionTemplate?: string; descriptionTemplate?: string;
runIndex?: number; failExecution?: boolean;
functionality?: 'pairedItem';
itemIndex?: number; itemIndex?: number;
messageTemplate?: string; messageTemplate?: string;
nodeCause?: string;
parameter?: string; parameter?: string;
failExecution?: boolean; runIndex?: number;
type?: string;
}, },
) { ) {
super(new Error(message)); super(new Error(message));
@ -23,30 +28,25 @@ export class ExpressionError extends ExecutionBaseError {
this.description = options.description; this.description = options.description;
} }
if (options?.descriptionTemplate !== undefined) {
this.context.descriptionTemplate = options.descriptionTemplate;
}
if (options?.causeDetailed !== undefined) {
this.context.causeDetailed = options.causeDetailed;
}
if (options?.runIndex !== undefined) {
this.context.runIndex = options.runIndex;
}
if (options?.itemIndex !== undefined) {
this.context.itemIndex = options.itemIndex;
}
if (options?.parameter !== undefined) {
this.context.parameter = options.parameter;
}
if (options?.messageTemplate !== undefined) {
this.context.messageTemplate = options.messageTemplate;
}
this.context.failExecution = !!options?.failExecution; this.context.failExecution = !!options?.failExecution;
const allowedKeys = [
'causeDetailed',
'descriptionTemplate',
'functionality',
'itemIndex',
'messageTemplate',
'nodeCause',
'parameter',
'runIndex',
'type',
];
if (options !== undefined) {
Object.keys(options as IDataObject).forEach((key) => {
if (allowedKeys.includes(key)) {
this.context[key] = (options as IDataObject)[key];
}
});
}
} }
} }

View file

@ -11,6 +11,7 @@ import type { WorkflowHooks } from './WorkflowHooks';
import type { WorkflowActivationError } from './WorkflowActivationError'; import type { WorkflowActivationError } from './WorkflowActivationError';
import type { WorkflowOperationError } from './WorkflowErrors'; import type { WorkflowOperationError } from './WorkflowErrors';
import type { NodeApiError, NodeOperationError } from './NodeErrors'; import type { NodeApiError, NodeOperationError } from './NodeErrors';
import { ExpressionError } from './ExpressionError';
export interface IAdditionalCredentialOptions { export interface IAdditionalCredentialOptions {
oauth2?: IOAuth2Options; oauth2?: IOAuth2Options;
@ -62,6 +63,7 @@ export interface IConnection {
} }
export type ExecutionError = export type ExecutionError =
| ExpressionError
| WorkflowActivationError | WorkflowActivationError
| WorkflowOperationError | WorkflowOperationError
| NodeOperationError | NodeOperationError

View file

@ -106,6 +106,16 @@ export class WorkflowDataProxy {
const that = this; const that = this;
const node = this.workflow.nodes[nodeName]; const node = this.workflow.nodes[nodeName];
if (!that.runExecutionData?.executionData) {
throw new ExpressionError(
`The workflow hasn't been executed yet, so you can't reference any context data`,
{
runIndex: that.runIndex,
itemIndex: that.itemIndex,
},
);
}
return new Proxy( return new Proxy(
{}, {},
{ {
@ -128,11 +138,6 @@ export class WorkflowDataProxy {
name = name.toString(); name = name.toString();
const contextData = NodeHelpers.getContext(that.runExecutionData!, 'node', node); const contextData = NodeHelpers.getContext(that.runExecutionData!, 'node', node);
if (!contextData.hasOwnProperty(name)) {
// Parameter does not exist on node
throw new Error(`Could not find parameter "${name}" on context of node "${nodeName}"`);
}
return contextData[name]; return contextData[name];
}, },
}, },
@ -253,10 +258,13 @@ export class WorkflowDataProxy {
// Long syntax got used to return data from node in path // Long syntax got used to return data from node in path
if (that.runExecutionData === null) { if (that.runExecutionData === null) {
throw new ExpressionError(`Workflow did not run so do not have any execution-data.`, { throw new ExpressionError(
`The workflow hasn't been executed yet, so you can't reference any output data`,
{
runIndex: that.runIndex, runIndex: that.runIndex,
itemIndex: that.itemIndex, itemIndex: that.itemIndex,
}); },
);
} }
if (!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) { if (!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
@ -450,6 +458,46 @@ export class WorkflowDataProxy {
); );
} }
private prevNodeGetter() {
const allowedValues = ['name', 'outputIndex', 'runIndex'];
const that = this;
return new Proxy(
{},
{
ownKeys(target) {
return allowedValues;
},
getOwnPropertyDescriptor(k) {
return {
enumerable: true,
configurable: true,
};
},
get(target, name, receiver) {
if (!that.executeData?.source) {
// Means the previous node did not get executed yet
return undefined;
}
const sourceData: ISourceData = that.executeData?.source.main![0] as ISourceData;
if (name === 'name') {
return sourceData.previousNode;
}
if (name === 'outputIndex') {
return sourceData.previousNodeOutput || 0;
}
if (name === 'runIndex') {
return sourceData.previousNodeRun || 0;
}
return Reflect.get(target, name, receiver);
},
},
);
}
/** /**
* Returns a proxy to query data from the workflow * Returns a proxy to query data from the workflow
* *
@ -472,11 +520,22 @@ export class WorkflowDataProxy {
}; };
}, },
get(target, name, receiver) { get(target, name, receiver) {
if (!allowedValues.includes(name.toString())) { if (allowedValues.includes(name.toString())) {
throw new Error(`The key "${name.toString()}" is not supported!`); const value = that.workflow[name as keyof typeof target];
if (value === undefined && name === 'id') {
throw new ExpressionError('Workflow is not saved', {
description: `Please save the workflow first to use $workflow`,
runIndex: that.runIndex,
itemIndex: that.itemIndex,
failExecution: true,
});
} }
return that.workflow[name as keyof typeof target]; return value;
}
return Reflect.get(target, name, receiver);
}, },
}, },
); );
@ -528,27 +587,60 @@ export class WorkflowDataProxy {
return jmespath.search(data, query); return jmespath.search(data, query);
}; };
const isFunctionNode = (nodeName: string) => {
const node = that.workflow.getNode(nodeName);
return node && ['n8n-nodes-base.function', 'n8n-nodes-base.functionItem'].includes(node.type);
};
const createExpressionError = ( const createExpressionError = (
message: string, message: string,
context?: { context?: {
causeDetailed?: string; causeDetailed?: string;
description?: string; description?: string;
descriptionTemplate?: string; descriptionTemplate?: string;
functionOverrides?: {
// Custom data to display for Function-Nodes
message?: string;
description?: string;
};
itemIndex?: number;
messageTemplate?: string; messageTemplate?: string;
moreInfoLink?: boolean;
nodeCause?: string;
runIndex?: number;
type?: string;
}, },
nodeName?: string,
) => { ) => {
if (nodeName) { if (isFunctionNode(that.activeNodeName) && context?.functionOverrides) {
// If the node in which the error is thrown is a function node,
// display a different error message in case there is one defined
message = context.functionOverrides.message || message;
context.description = context.functionOverrides.description || context.description;
// The error will be in the code and not on an expression on a parameter
// so remove the messageTemplate as it would overwrite the message
context.messageTemplate = undefined;
}
if (context?.nodeCause) {
const nodeName = context.nodeCause;
const pinData = this.workflow.getPinDataOfNode(nodeName); const pinData = this.workflow.getPinDataOfNode(nodeName);
if (pinData) { if (pinData) {
if (!context) { if (!context) {
context = {}; context = {};
} }
message = `${nodeName} must be unpinned to execute`; message = `Node ${nodeName} must be unpinned to execute`;
context.description = `To fetch the data the expression needs, The node ${nodeName} needs to execute without being pinned. <a>Unpin it</a>`; context.messageTemplate = undefined;
context.description = `To fetch the data for the expression, you must unpin the node '${nodeName}' and execute the workflow again.`; context.description = `To fetch the data for the expression, you must unpin the node <strong>'${nodeName}'</strong> and execute the workflow again.`;
context.descriptionTemplate = `To fetch the data for the expression under '%%PARAMETER%%', you must unpin the node '${nodeName}' and execute the workflow again.`; context.descriptionTemplate = `To fetch the data for the expression under '%%PARAMETER%%', you must unpin the node <strong>'${nodeName}'</strong> and execute the workflow again.`;
}
if (context.moreInfoLink && (pinData || isFunctionNode(nodeName))) {
const moreInfoLink =
' <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-errors/">More info</a>';
context.description += moreInfoLink;
context.descriptionTemplate += moreInfoLink;
} }
} }
@ -556,6 +648,7 @@ export class WorkflowDataProxy {
runIndex: that.runIndex, runIndex: that.runIndex,
itemIndex: that.itemIndex, itemIndex: that.itemIndex,
failExecution: true, failExecution: true,
functionality: 'pairedItem',
...context, ...context,
}); });
}; };
@ -575,6 +668,8 @@ export class WorkflowDataProxy {
}; };
} }
let currentPairedItem = pairedItem;
let nodeBeforeLast: string | undefined; let nodeBeforeLast: string | undefined;
while (sourceData !== null && destinationNodeName !== sourceData.previousNode) { while (sourceData !== null && destinationNodeName !== sourceData.previousNode) {
taskData = taskData =
@ -584,46 +679,54 @@ export class WorkflowDataProxy {
const previousNodeOutput = sourceData.previousNodeOutput || 0; const previousNodeOutput = sourceData.previousNodeOutput || 0;
if (previousNodeOutput >= taskData.data!.main.length) { if (previousNodeOutput >= taskData.data!.main.length) {
// `Could not resolve as the defined node-output is not valid on node '${sourceData.previousNode}'.` throw createExpressionError('Cant get data for expression', {
throw createExpressionError( messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
'Cant get data for expression', functionOverrides: {
{ message: 'Cant get data',
messageTemplate: 'Cant get data for expression under %%PARAMETER%%',
description: `Apologies, this is an internal error. See details for more information`,
causeDetailed:
'Referencing a non-existent output on a node, problem with source data',
}, },
nodeBeforeLast, nodeCause: nodeBeforeLast,
); description: `Apologies, this is an internal error. See details for more information`,
causeDetailed: 'Referencing a non-existent output on a node, problem with source data',
type: 'internal',
});
} }
if (pairedItem.item >= taskData.data!.main[previousNodeOutput]!.length) { if (pairedItem.item >= taskData.data!.main[previousNodeOutput]!.length) {
// `Could not resolve as the defined item index is not valid on node '${sourceData.previousNode}'. throw createExpressionError('Cant get data for expression', {
throw createExpressionError( messageTemplate: `Cant get data for expression under %%PARAMETER%% field`,
'Cant get data for expression', functionOverrides: {
{ message: 'Cant get data',
messageTemplate: `Cant get data for expression under %%PARAMETER%%`,
description: `Item points to an item which does not exist`,
causeDetailed: `The pairedItem data points to an item ${pairedItem.item} which does not exist on node ${sourceData.previousNode} (output node did probably supply a wrong one)`,
}, },
nodeBeforeLast, nodeCause: nodeBeforeLast,
); description: `In node <strong>${nodeBeforeLast!}</strong>, output item ${
currentPairedItem.item || 0
} ${
sourceData.previousNodeRun
? `of run ${(sourceData.previousNodeRun || 0).toString()} `
: ''
}points to an input item on node <strong>${
sourceData.previousNode
}</strong> that doesnt exist.`,
type: 'invalid pairing info',
moreInfoLink: true,
});
} }
const itemPreviousNode: INodeExecutionData = const itemPreviousNode: INodeExecutionData =
taskData.data!.main[previousNodeOutput]![pairedItem.item]; taskData.data!.main[previousNodeOutput]![pairedItem.item];
if (itemPreviousNode.pairedItem === undefined) { if (itemPreviousNode.pairedItem === undefined) {
// `Could not resolve, as pairedItem data is missing on node '${sourceData.previousNode}'.`, throw createExpressionError('Cant get data for expression', {
throw createExpressionError( messageTemplate: `Cant get data for expression under %%PARAMETER%% field`,
'Cant get data for expression', functionOverrides: {
{ message: 'Cant get data',
messageTemplate: `Cant get data for expression under %%PARAMETER%%`,
description: `To fetch the data from other nodes that this expression needs, more information is needed from the node ${sourceData.previousNode}`,
causeDetailed: `Missing pairedItem data (node ${sourceData.previousNode} did probably not supply it)`,
}, },
sourceData.previousNode, nodeCause: sourceData.previousNode,
); description: `To fetch the data from other nodes that this expression needs, more information is needed from the node <strong>${sourceData.previousNode}</strong>`,
causeDetailed: `Missing pairedItem data (node ${sourceData.previousNode} probably didnt supply it)`,
type: 'no pairing info',
moreInfoLink: true,
});
} }
if (Array.isArray(itemPreviousNode.pairedItem)) { if (Array.isArray(itemPreviousNode.pairedItem)) {
@ -650,13 +753,20 @@ export class WorkflowDataProxy {
if (results.length !== 1) { if (results.length !== 1) {
throw createExpressionError('Invalid expression', { throw createExpressionError('Invalid expression', {
messageTemplate: 'Invalid expression under %%PARAMETER%%', messageTemplate: 'Invalid expression under %%PARAMETER%%',
description: `The expression uses data in node ${destinationNodeName} but there is more than one matching item in that node`, functionOverrides: {
description: `The code uses data in the node <strong>${destinationNodeName}</strong> but there is more than one matching item in that node`,
message: 'Invalid code',
},
description: `The expression uses data in the node <strong>${destinationNodeName}</strong> but there is more than one matching item in that node`,
type: 'multiple matches',
}); });
} }
return results[0]; return results[0];
} }
currentPairedItem = pairedItem;
// pairedItem is not an array // pairedItem is not an array
if (typeof itemPreviousNode.pairedItem === 'number') { if (typeof itemPreviousNode.pairedItem === 'number') {
pairedItem = { pairedItem = {
@ -672,19 +782,30 @@ export class WorkflowDataProxy {
// A trigger node got reached, so looks like that that item can not be resolved // A trigger node got reached, so looks like that that item can not be resolved
throw createExpressionError('Invalid expression', { throw createExpressionError('Invalid expression', {
messageTemplate: 'Invalid expression under %%PARAMETER%%', messageTemplate: 'Invalid expression under %%PARAMETER%%',
description: `The expression uses data in node ${destinationNodeName} but there is no path back to it. Please check this node is connected to node ${that.activeNodeName} (there can be other nodes in between).`, functionOverrides: {
description: `The code uses data in the node <strong>${destinationNodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
message: 'Invalid code',
},
description: `The expression uses data in the node <strong>${destinationNodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
type: 'no connection',
moreInfoLink: true,
}); });
} }
// `Could not resolve pairedItem as the defined node input '${itemInput}' does not exist on node '${sourceData.previousNode}'.` throw createExpressionError('Cant get data for expression', {
throw createExpressionError( messageTemplate: `Cant get data for expression under %%PARAMETER%% field`,
'Cant get data for expression', functionOverrides: {
{ message: `Cant get data`,
messageTemplate: `Cant get data for expression under %%PARAMETER%%`,
description: `Item points to a node input which does not exist`,
causeDetailed: `The pairedItem data points to a node input ${itemInput} which does not exist on node ${sourceData.previousNode} (node did probably supply a wrong one)`,
}, },
nodeBeforeLast, nodeCause: nodeBeforeLast,
); description: `In node <strong>${sourceData.previousNode}</strong>, output item ${
currentPairedItem.item || 0
} of ${
sourceData.previousNodeRun
? `of run ${(sourceData.previousNodeRun || 0).toString()} `
: ''
}points to a branch that doesnt exist.`,
type: 'invalid pairing info',
});
} }
nodeBeforeLast = sourceData.previousNode; nodeBeforeLast = sourceData.previousNode;
@ -692,15 +813,16 @@ export class WorkflowDataProxy {
} }
if (sourceData === null) { if (sourceData === null) {
// 'Could not resolve, probably no pairedItem exists.' throw createExpressionError('Cant get data for expression', {
throw createExpressionError( messageTemplate: `Cant get data for expression under %%PARAMETER%% field`,
'Cant get data for expression', functionOverrides: {
{ message: `Cant get data`,
messageTemplate: `Cant get data for expression under %%PARAMETER%%`,
description: `Could not resolve, probably no pairedItem exists`,
}, },
nodeBeforeLast, nodeCause: nodeBeforeLast,
); description: `Could not resolve, proably no pairedItem exists`,
type: 'no pairing info',
moreInfoLink: true,
});
} }
taskData = taskData =
@ -710,25 +832,36 @@ export class WorkflowDataProxy {
const previousNodeOutput = sourceData.previousNodeOutput || 0; const previousNodeOutput = sourceData.previousNodeOutput || 0;
if (previousNodeOutput >= taskData.data!.main.length) { if (previousNodeOutput >= taskData.data!.main.length) {
// `Could not resolve pairedItem as the node output '${previousNodeOutput}' does not exist on node '${sourceData.previousNode}'`
throw createExpressionError('Cant get data for expression', { throw createExpressionError('Cant get data for expression', {
messageTemplate: `Cant get data for expression under %%PARAMETER%%`, messageTemplate: `Cant get data for expression under %%PARAMETER%% field`,
functionOverrides: {
message: `Cant get data`,
},
description: `Item points to a node output which does not exist`, description: `Item points to a node output which does not exist`,
causeDetailed: `The sourceData points to a node output ${previousNodeOutput} which does not exist on node ${sourceData.previousNode} (output node did probably supply a wrong one)`, causeDetailed: `The sourceData points to a node output ${previousNodeOutput} which does not exist on node ${sourceData.previousNode} (output node did probably supply a wrong one)`,
type: 'invalid pairing info',
}); });
} }
if (pairedItem.item >= taskData.data!.main[previousNodeOutput]!.length) { if (pairedItem.item >= taskData.data!.main[previousNodeOutput]!.length) {
// `Could not resolve pairedItem as the item with the index '${pairedItem.item}' does not exist on node '${sourceData.previousNode}'.` throw createExpressionError('Cant get data for expression', {
throw createExpressionError( messageTemplate: `Cant get data for expression under %%PARAMETER%% field`,
'Cant get data for expression', functionOverrides: {
{ message: `Cant get data`,
messageTemplate: `Cant get data for expression under %%PARAMETER%%`,
description: `Item points to an item which does not exist`,
causeDetailed: `The pairedItem data points to an item ${pairedItem.item} which does not exist on node ${sourceData.previousNode} (output node did probably supply a wrong one)`,
}, },
nodeBeforeLast, nodeCause: nodeBeforeLast,
); description: `In node <strong>${nodeBeforeLast!}</strong>, output item ${
currentPairedItem.item || 0
} ${
sourceData.previousNodeRun
? `of run ${(sourceData.previousNodeRun || 0).toString()} `
: ''
}points to an input item on node <strong>${
sourceData.previousNode
}</strong> that doesnt exist.`,
type: 'invalid pairing info',
moreInfoLink: true,
});
} }
return taskData.data!.main[previousNodeOutput]![pairedItem.item]; return taskData.data!.main[previousNodeOutput]![pairedItem.item];
@ -737,20 +870,26 @@ export class WorkflowDataProxy {
const base = { const base = {
$: (nodeName: string) => { $: (nodeName: string) => {
if (!nodeName) { if (!nodeName) {
throw new ExpressionError('When calling $(), please specify a node', { throw createExpressionError('When calling $(), please specify a node');
runIndex: that.runIndex, }
itemIndex: that.itemIndex,
failExecution: true, const referencedNode = that.workflow.getNode(nodeName);
}); if (referencedNode === null) {
throw createExpressionError(`No node called ${nodeName}`);
} }
return new Proxy( return new Proxy(
{}, {},
{ {
get(target, property, receiver) { get(target, property, receiver) {
if (property === 'pairedItem') { if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) {
return (itemIndex?: number) => { const pairedItemMethod = (itemIndex?: number) => {
if (itemIndex === undefined) { if (itemIndex === undefined) {
if (property === 'itemMatching') {
throw createExpressionError('Missing item index for .itemMatching()', {
itemIndex,
});
}
itemIndex = that.itemIndex; itemIndex = that.itemIndex;
} }
@ -762,24 +901,27 @@ export class WorkflowDataProxy {
const pairedItem = executionData[itemIndex].pairedItem as IPairedItemData; const pairedItem = executionData[itemIndex].pairedItem as IPairedItemData;
if (pairedItem === undefined) { if (pairedItem === undefined) {
throw new ExpressionError('Cant get data for expression', { throw createExpressionError('Cant get data for expression', {
messageTemplate: `Cant get data for expression under %%PARAMETER%%`, messageTemplate: `Cant get data for expression under %%PARAMETER%% field`,
description: `To fetch the data from other nodes that this expression needs, more information is needed from the node ${that.activeNodeName}`, functionOverrides: {
causeDetailed: `Missing pairedItem data (node ${that.activeNodeName} did probably not supply it)`, description: `To fetch the data from other nodes that this code needs, more information is needed from the node <strong>${that.activeNodeName}</strong>`,
runIndex: that.runIndex, message: `Cant get data`,
},
description: `To fetch the data from other nodes that this expression needs, more information is needed from the node <strong>${that.activeNodeName}</strong>`,
causeDetailed: `Missing pairedItem data (node ${that.activeNodeName} probably didnt supply it)`,
itemIndex, itemIndex,
failExecution: true,
}); });
} }
if (!that.executeData?.source) { if (!that.executeData?.source) {
throw new ExpressionError('Cant get data for expression', { throw createExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%%', messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
functionOverrides: {
message: `Cant get data`,
},
description: `Apologies, this is an internal error. See details for more information`, description: `Apologies, this is an internal error. See details for more information`,
causeDetailed: `Missing sourceData (probably an internal error)`, causeDetailed: `Missing sourceData (probably an internal error)`,
runIndex: that.runIndex,
itemIndex, itemIndex,
failExecution: true,
}); });
} }
@ -787,12 +929,14 @@ export class WorkflowDataProxy {
// graph before the current one // graph before the current one
const parentNodes = that.workflow.getParentNodes(that.activeNodeName); const parentNodes = that.workflow.getParentNodes(that.activeNodeName);
if (!parentNodes.includes(nodeName)) { if (!parentNodes.includes(nodeName)) {
throw new ExpressionError('Invalid expression', { throw createExpressionError('Invalid expression', {
messageTemplate: 'Invalid expression under %%PARAMETER%%', messageTemplate: 'Invalid expression under %%PARAMETER%%',
description: `The expression uses data in node ${nodeName} but there is no path back to it. Please check this node is connected to node ${that.activeNodeName} (there can be other nodes in between).`, functionOverrides: {
runIndex: that.runIndex, description: `The code uses data in the node <strong>${nodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
message: `No path back to node ${nodeName}`,
},
description: `The expression uses data in the node <strong>${nodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
itemIndex, itemIndex,
failExecution: true,
}); });
} }
@ -802,49 +946,11 @@ export class WorkflowDataProxy {
return getPairedItem(nodeName, sourceData, pairedItem); return getPairedItem(nodeName, sourceData, pairedItem);
}; };
}
if (property === 'item') { if (property === 'item') {
return (itemIndex?: number, branchIndex?: number, runIndex?: number) => { return pairedItemMethod();
if (itemIndex === undefined) {
itemIndex = that.itemIndex;
branchIndex = 0;
runIndex = that.runIndex;
} }
const executionData = getNodeOutput(nodeName, branchIndex, runIndex); return pairedItemMethod;
if (executionData[itemIndex]) {
return executionData[itemIndex];
}
let errorMessage = '';
if (branchIndex === undefined && runIndex === undefined) {
errorMessage = `
No item found at index ${itemIndex}
(for node "${nodeName}")`;
throw new Error(errorMessage);
}
if (branchIndex === undefined) {
errorMessage = `
No item found at index ${itemIndex}
in run ${runIndex || that.runIndex}
(for node "${nodeName}")`;
throw new Error(errorMessage);
}
if (runIndex === undefined) {
errorMessage = `
No item found at index ${itemIndex}
of branch ${branchIndex || 0}
(for node "${nodeName}")`;
throw new Error(errorMessage);
}
errorMessage = `
No item found at index ${itemIndex}
of branch ${branchIndex || 0}
in run ${runIndex || that.runIndex}
(for node "${nodeName}")`;
throw new Error(errorMessage);
};
} }
if (property === 'first') { if (property === 'first') {
return (branchIndex?: number, runIndex?: number) => { return (branchIndex?: number, runIndex?: number) => {
@ -882,22 +988,25 @@ export class WorkflowDataProxy {
$input: new Proxy( $input: new Proxy(
{}, {},
{ {
ownKeys(target) {
return ['all', 'context', 'first', 'item', 'last', 'params'];
},
getOwnPropertyDescriptor(k) {
return {
enumerable: true,
configurable: true,
};
},
get(target, property, receiver) { get(target, property, receiver) {
if (property === 'thisItem') { if (property === 'item') {
return that.connectionInputData[that.itemIndex]; return that.connectionInputData[that.itemIndex];
} }
if (property === 'item') {
return (itemIndex?: number) => {
if (itemIndex === undefined) itemIndex = that.itemIndex;
const result = that.connectionInputData;
if (result[itemIndex]) {
return result[itemIndex];
}
return undefined;
};
}
if (property === 'first') { if (property === 'first') {
return () => { return (...args: unknown[]) => {
if (args.length) {
throw createExpressionError('$input.first() should have no arguments');
}
const result = that.connectionInputData; const result = that.connectionInputData;
if (result[0]) { if (result[0]) {
return result[0]; return result[0];
@ -906,7 +1015,11 @@ export class WorkflowDataProxy {
}; };
} }
if (property === 'last') { if (property === 'last') {
return () => { return (...args: unknown[]) => {
if (args.length) {
throw createExpressionError('$input.last() should have no arguments');
}
const result = that.connectionInputData; const result = that.connectionInputData;
if (result.length && result[result.length - 1]) { if (result.length && result[result.length - 1]) {
return result[result.length - 1]; return result[result.length - 1];
@ -923,12 +1036,37 @@ export class WorkflowDataProxy {
return []; return [];
}; };
} }
if (['context', 'params'].includes(property as string)) {
// For the following properties we need the source data so fail in case it is missing
// for some reason (even though that should actually never happen)
if (!that.executeData?.source) {
throw createExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
functionOverrides: {
message: 'Cant get data',
},
description: `Apologies, this is an internal error. See details for more information`,
causeDetailed: `Missing sourceData (probably an internal error)`,
runIndex: that.runIndex,
});
}
const sourceData: ISourceData = that.executeData?.source.main![0] as ISourceData;
if (property === 'context') {
return that.nodeContextGetter(sourceData.previousNode);
}
if (property === 'params') {
return that.workflow.getNode(sourceData.previousNode)?.parameters;
}
}
return Reflect.get(target, property, receiver); return Reflect.get(target, property, receiver);
}, },
}, },
), ),
$thisItem: that.connectionInputData[that.itemIndex],
$binary: {}, // Placeholder $binary: {}, // Placeholder
$data: {}, // Placeholder $data: {}, // Placeholder
$env: this.envGetter(), $env: this.envGetter(),
@ -982,15 +1120,14 @@ export class WorkflowDataProxy {
$node: this.nodeGetter(), $node: this.nodeGetter(),
$self: this.selfGetter(), $self: this.selfGetter(),
$parameter: this.nodeParameterGetter(this.activeNodeName), $parameter: this.nodeParameterGetter(this.activeNodeName),
$position: this.itemIndex, $prevNode: this.prevNodeGetter(),
$runIndex: this.runIndex, $runIndex: this.runIndex,
$mode: this.mode, $mode: this.mode,
$workflow: this.workflowGetter(), $workflow: this.workflowGetter(),
$thisRunIndex: this.runIndex, $itemIndex: this.itemIndex,
$thisItemIndex: this.itemIndex,
$now: DateTime.now(), $now: DateTime.now(),
$today: DateTime.now().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }), $today: DateTime.now().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }),
$jmespath: jmespathWrapper, $jmesPath: jmespathWrapper,
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
DateTime, DateTime,
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -998,6 +1135,13 @@ export class WorkflowDataProxy {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
Duration, Duration,
...that.additionalKeys, ...that.additionalKeys,
// deprecated
$jmespath: jmespathWrapper,
$position: this.itemIndex,
$thisItem: that.connectionInputData[that.itemIndex],
$thisItemIndex: this.itemIndex,
$thisRunIndex: this.runIndex,
}; };
return new Proxy(base, { return new Proxy(base, {

View file

@ -1,46 +1,48 @@
import { Workflow, WorkflowDataProxy } from '../src'; import { Workflow, WorkflowDataProxy } from '../src';
import * as Helpers from './Helpers'; import * as Helpers from './Helpers';
import { IConnections, INode, INodeExecutionData, IRunExecutionData } from '../src/Interfaces'; import { IConnections, IExecuteData, INode, IRunExecutionData } from '../src/Interfaces';
describe('WorkflowDataProxy', () => { describe('WorkflowDataProxy', () => {
describe('test data proxy', () => { describe('test data proxy', () => {
const nodes: INode[] = [ const nodes: INode[] = [
{ {
parameters: {},
name: 'Start', name: 'Start',
type: 'test.set', type: 'test.set',
parameters: {},
typeVersion: 1, typeVersion: 1,
id: 'uuid-1', id: 'uuid-1',
position: [100, 200], position: [100, 200],
}, },
{ {
name: 'Function',
type: 'test.set',
parameters: { parameters: {
functionCode: functionCode:
'// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));', '// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));',
}, },
name: 'Function',
type: 'test.set',
typeVersion: 1, typeVersion: 1,
id: 'uuid-2', id: 'uuid-2',
position: [280, 200], position: [280, 200],
}, },
{ {
parameters: {
keys: {
key: [
{
currentKey: 'length',
newKey: 'data',
},
],
},
},
name: 'Rename', name: 'Rename',
type: 'test.set', type: 'test.set',
parameters: {
value1: 'data',
value2: 'initialName',
},
typeVersion: 1, typeVersion: 1,
id: 'uuid-3', id: 'uuid-3',
position: [460, 200], position: [460, 200],
}, },
{
name: 'End',
type: 'test.set',
parameters: {},
typeVersion: 1,
id: 'uuid-4',
position: [640, 200],
},
]; ];
const connections: IConnections = { const connections: IConnections = {
@ -66,11 +68,38 @@ describe('WorkflowDataProxy', () => {
], ],
], ],
}, },
Rename: {
main: [
[
{
node: 'End',
type: 'main',
index: 0,
},
],
],
},
}; };
const runExecutionData: IRunExecutionData = { const runExecutionData: IRunExecutionData = {
resultData: { resultData: {
runData: { runData: {
Start: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: {},
},
],
],
},
source: [],
},
],
Function: [ Function: [
{ {
startTime: 1, startTime: 1,
@ -79,24 +108,33 @@ describe('WorkflowDataProxy', () => {
main: [ main: [
[ [
{ {
json: { length: 105 }, json: { initialName: 105 },
pairedItem: { item: 0 },
}, },
{ {
json: { length: 160 }, json: { initialName: 160 },
pairedItem: { item: 0 },
}, },
{ {
json: { length: 121 }, json: { initialName: 121 },
pairedItem: { item: 0 },
}, },
{ {
json: { length: 275 }, json: { initialName: 275 },
pairedItem: { item: 0 },
}, },
{ {
json: { length: 950 }, json: { initialName: 950 },
pairedItem: { item: 0 },
}, },
], ],
], ],
}, },
source: [], source: [
{
previousNode: 'Start',
},
],
}, },
], ],
Rename: [ Rename: [
@ -108,51 +146,109 @@ describe('WorkflowDataProxy', () => {
[ [
{ {
json: { data: 105 }, json: { data: 105 },
pairedItem: { item: 0 },
}, },
{ {
json: { data: 160 }, json: { data: 160 },
pairedItem: { item: 1 },
}, },
{ {
json: { data: 121 }, json: { data: 121 },
pairedItem: { item: 2 },
}, },
{ {
json: { data: 275 }, json: { data: 275 },
pairedItem: { item: 3 },
}, },
{ {
json: { data: 950 }, json: { data: 950 },
pairedItem: { item: 4 },
}, },
], ],
], ],
}, },
source: [], source: [
{
previousNode: 'Function',
},
],
},
],
End: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: { data: 105 },
pairedItem: { item: 0 },
},
{
json: { data: 160 },
pairedItem: { item: 1 },
},
{
json: { data: 121 },
pairedItem: { item: 2 },
},
{
json: { data: 275 },
pairedItem: { item: 3 },
},
{
json: { data: 950 },
pairedItem: { item: 4 },
},
],
],
},
source: [
{
previousNode: 'Rename',
},
],
}, },
], ],
}, },
}, },
}; };
const renameNodeConnectionInputData: INodeExecutionData[] = [
{ json: { length: 105 } },
{ json: { length: 160 } },
{ json: { length: 121 } },
{ json: { length: 275 } },
{ json: { length: 950 } },
];
const nodeTypes = Helpers.NodeTypes(); const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({ nodes, connections, active: false, nodeTypes }); const workflow = new Workflow({
id: '123',
name: 'test workflow',
nodes,
connections,
active: false,
nodeTypes,
});
const nameLastNode = 'End';
const lastNodeConnectionInputData =
runExecutionData.resultData.runData[nameLastNode][0].data!.main[0];
const executeData: IExecuteData = {
data: runExecutionData.resultData.runData[nameLastNode][0].data!,
node: nodes.find((node) => node.name === nameLastNode) as INode,
source: {
main: runExecutionData.resultData.runData[nameLastNode][0].source!,
},
};
const dataProxy = new WorkflowDataProxy( const dataProxy = new WorkflowDataProxy(
workflow, workflow,
runExecutionData, runExecutionData,
0, 0,
0, 0,
'Rename', nameLastNode,
renameNodeConnectionInputData || [], lastNodeConnectionInputData || [],
{}, {},
'manual', 'manual',
'America/New_York', 'America/New_York',
{}, {},
executeData,
); );
const proxy = dataProxy.getDataProxy(); const proxy = dataProxy.getDataProxy();
@ -162,11 +258,17 @@ describe('WorkflowDataProxy', () => {
test('test $("NodeName").all() length', () => { test('test $("NodeName").all() length', () => {
expect(proxy.$('Rename').all().length).toEqual(5); expect(proxy.$('Rename').all().length).toEqual(5);
}); });
test('test $("NodeName").item()', () => { test('test $("NodeName").item', () => {
expect(proxy.$('Rename').item().json.data).toEqual(105); expect(proxy.$('Rename').item).toEqual({ json: { data: 105 }, pairedItem: { item: 0 } });
}); });
test('test $("NodeName").item(2)', () => { test('test $("NodeNameEarlier").item', () => {
expect(proxy.$('Rename').item(2).json.data).toEqual(121); expect(proxy.$('Function').item).toEqual({
json: { initialName: 105 },
pairedItem: { item: 0 },
});
});
test('test $("NodeName").itemMatching(2)', () => {
expect(proxy.$('Rename').itemMatching(2).json.data).toEqual(121);
}); });
test('test $("NodeName").first()', () => { test('test $("NodeName").first()', () => {
expect(proxy.$('Rename').first().json.data).toEqual(105); expect(proxy.$('Rename').first().json.data).toEqual(105);
@ -175,26 +277,55 @@ describe('WorkflowDataProxy', () => {
expect(proxy.$('Rename').last().json.data).toEqual(950); expect(proxy.$('Rename').last().json.data).toEqual(950);
}); });
test('test $("NodeName").params', () => {
expect(proxy.$('Rename').params).toEqual({ value1: 'data', value2: 'initialName' });
});
test('test $input.all()', () => { test('test $input.all()', () => {
expect(proxy.$input.all()[1].json.length).toEqual(160); expect(proxy.$input.all()[1].json.data).toEqual(160);
}); });
test('test $input.all() length', () => { test('test $input.all() length', () => {
expect(proxy.$input.all().length).toEqual(5); expect(proxy.$input.all().length).toEqual(5);
}); });
test('test $input.item()', () => {
expect(proxy.$input.item().json.length).toEqual(105);
});
test('test $thisItem', () => {
expect(proxy.$thisItem.json.length).toEqual(105);
});
test('test $input.item(2)', () => {
expect(proxy.$input.item(2).json.length).toEqual(121);
});
test('test $input.first()', () => { test('test $input.first()', () => {
expect(proxy.$input.first().json.length).toEqual(105); expect(proxy.$input.first().json.data).toEqual(105);
}); });
test('test $input.last()', () => { test('test $input.last()', () => {
expect(proxy.$input.last().json.length).toEqual(950); expect(proxy.$input.last().json.data).toEqual(950);
});
test('test $input.item', () => {
expect(proxy.$input.item.json.data).toEqual(105);
});
test('test $thisItem', () => {
expect(proxy.$thisItem.json.data).toEqual(105);
});
test('test $binary', () => {
expect(proxy.$binary).toEqual({});
});
test('test $json', () => {
expect(proxy.$json).toEqual({ data: 105 });
});
test('test $itemIndex', () => {
expect(proxy.$itemIndex).toEqual(0);
});
test('test $prevNode', () => {
expect(proxy.$prevNode).toEqual({ name: 'Rename', outputIndex: 0, runIndex: 0 });
});
test('test $runIndex', () => {
expect(proxy.$runIndex).toEqual(0);
});
test('test $workflow', () => {
expect(proxy.$workflow).toEqual({
active: false,
id: '123',
name: 'test workflow',
});
}); });
}); });
}); });