feat(core): Add support for pairedItem (beta) (#3012)

*  Add pairedItem support

* 👕 Fix lint issue

* 🐛 Fix resolution in frontend

* 🐛 Fix resolution issue

* 🐛 Fix resolution in frontend

* 🐛 Fix another resolution issue in frontend

*  Try to automatically add pairedItem data if possible

*  Cleanup

*  Display expression errors in editor UI

* 🐛 Fix issue that it did not display errors in production

* 🐛 Fix auto-fix of missing pairedItem data

* 🐛 Fix frontend resolution for not executed nodes

*  Fail execution on pairedItem resolve issue and display information
about itemIndex and runIndex

*  Allow that pairedItem is only set to number if runIndex is 0

*  Improve Expression Errors

*  Remove no longer needed code

*  Make errors more helpful

*  Add additional errors

* 👕 Fix lint issue

*  Add pairedItem support to core nodes

*  Improve support in Merge-Node

*  Fix issue with not correctly converted incoming pairedItem data

* 🐛 Fix frontend resolve issue

* 🐛 Fix frontend parameter name display issue

*  Improve errors

* 👕 Fix lint issue

*  Improve errors

*  Make it possible to display parameter name in error messages

*  Improve error messages

*  Fix error message

*  Improve error messages

*  Add another error message

*  Simplify
This commit is contained in:
Jan Oberhauser
2022-06-03 17:25:07 +02:00
committed by GitHub
parent 450a9aafea
commit bdb84130d6
52 changed files with 1317 additions and 152 deletions

View File

@@ -4,6 +4,8 @@ import { DateTime, Duration, Interval } from 'luxon';
// eslint-disable-next-line import/no-cycle
import {
ExpressionError,
IExecuteData,
INode,
INodeExecutionData,
INodeParameters,
@@ -21,10 +23,11 @@ import {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
tmpl.brackets.set('{{ }}');
// Make sure that it does not always print an error when it could not resolve
// a variable
// Make sure that error get forwarded
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
tmpl.tmpl.errorHandler = () => {};
tmpl.tmpl.errorHandler = (error: Error) => {
throw error;
};
export class Expression {
workflow: Workflow;
@@ -71,6 +74,7 @@ export class Expression {
mode: WorkflowExecuteMode,
timezone: string,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
executeData?: IExecuteData,
returnObjectAsString = false,
selfData = {},
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] {
@@ -98,6 +102,7 @@ export class Expression {
mode,
timezone,
additionalKeys,
executeData,
-1,
selfData,
);
@@ -148,27 +153,35 @@ export class Expression {
data.constructor = {};
// Execute the expression
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let returnValue;
try {
if (/([^a-zA-Z0-9"']window[^a-zA-Z0-9"'])/g.test(parameterValue)) {
throw new Error(`window is not allowed`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const returnValue = tmpl.tmpl(parameterValue, data);
if (typeof returnValue === 'function') {
throw new Error('Expression resolved to a function. Please add "()"');
} else if (returnValue !== null && typeof returnValue === 'object') {
if (returnObjectAsString) {
return this.convertObjectValueToString(returnValue);
returnValue = tmpl.tmpl(parameterValue, data);
} catch (error) {
if (error instanceof ExpressionError) {
// Ignore all errors except if they are ExpressionErrors and they are supposed
// to fail the execution
if (error.context.failExecution) {
throw error;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return returnValue;
} catch (e) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
throw new Error(`Expression is not valid: ${e.message}`);
}
if (typeof returnValue === 'function') {
throw new Error('Expression resolved to a function. Please add "()"');
} else if (returnValue !== null && typeof returnValue === 'object') {
if (returnObjectAsString) {
return this.convertObjectValueToString(returnValue);
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return returnValue;
}
/**
@@ -186,6 +199,7 @@ export class Expression {
mode: WorkflowExecuteMode,
timezone: string,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
executeData?: IExecuteData,
defaultValue?: boolean | number | string,
): boolean | number | string | undefined {
if (parameterValue === undefined) {
@@ -213,6 +227,7 @@ export class Expression {
mode,
timezone,
additionalKeys,
executeData,
) as boolean | number | string | undefined;
}
@@ -231,6 +246,7 @@ export class Expression {
mode: WorkflowExecuteMode,
timezone: string,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
executeData?: IExecuteData,
defaultValue:
| NodeParameterValue
| INodeParameters
@@ -265,6 +281,7 @@ export class Expression {
mode,
timezone,
additionalKeys,
executeData,
false,
selfData,
);
@@ -280,6 +297,7 @@ export class Expression {
mode,
timezone,
additionalKeys,
executeData,
false,
selfData,
);
@@ -310,6 +328,7 @@ export class Expression {
mode: WorkflowExecuteMode,
timezone: string,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
executeData?: IExecuteData,
returnObjectAsString = false,
selfData = {},
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] {
@@ -336,6 +355,7 @@ export class Expression {
mode,
timezone,
additionalKeys,
executeData,
returnObjectAsString,
selfData,
);
@@ -351,6 +371,7 @@ export class Expression {
mode,
timezone,
additionalKeys,
executeData,
returnObjectAsString,
selfData,
);
@@ -369,6 +390,7 @@ export class Expression {
mode,
timezone,
additionalKeys,
executeData,
returnObjectAsString,
selfData,
);

View File

@@ -0,0 +1,48 @@
// eslint-disable-next-line import/no-cycle
import { ExecutionBaseError } from './NodeErrors';
/**
* Class for instantiating an expression error
*/
export class ExpressionError extends ExecutionBaseError {
constructor(
message: string,
options?: {
causeDetailed?: string;
description?: string;
runIndex?: number;
itemIndex?: number;
messageTemplate?: string;
parameter?: string;
failExecution?: boolean;
},
) {
super(new Error(message));
if (options?.description !== undefined) {
this.description = options.description;
}
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;
}
}

View File

@@ -306,6 +306,11 @@ export interface ICredentialDataDecryptedObject {
// Second array index: The different connections (if one node is connected to multiple nodes)
export type NodeInputConnections = IConnection[][];
export interface INodeConnection {
sourceIndex: number;
destinationIndex: number;
}
export interface INodeConnections {
// Input name
[key: string]: NodeInputConnections;
@@ -363,6 +368,7 @@ export interface IGetExecuteFunctions {
inputData: ITaskDataConnections,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
executeData: IExecuteData,
mode: WorkflowExecuteMode,
): IExecuteFunctions;
}
@@ -377,6 +383,7 @@ export interface IGetExecuteSingleFunctions {
node: INode,
itemIndex: number,
additionalData: IWorkflowExecuteAdditionalData,
executeData: IExecuteData,
mode: WorkflowExecuteMode,
): IExecuteSingleFunctions;
}
@@ -403,9 +410,17 @@ export interface IGetExecuteWebhookFunctions {
): IWebhookFunctions;
}
export interface ISourceDataConnections {
// Key for each input type and because there can be multiple inputs of the same type it is an array
// null is also allowed because if we still need data for a later while executing the workflow set teompoary to null
// the nodes get as input TaskDataConnections which is identical to this one except that no null is allowed.
[key: string]: Array<ISourceData[] | null>;
}
export interface IExecuteData {
data: ITaskDataConnections;
node: INode;
source: ITaskDataConnectionsSource | null;
}
export type IContextObject = {
@@ -514,6 +529,7 @@ export interface IExecuteFunctions {
getWorkflowStaticData(type: string): IDataObject;
getRestApiUrl(): string;
getTimezone(): string;
getExecuteData(): IExecuteData;
getWorkflow(): IWorkflowMetadata;
prepareOutputData(
outputData: INodeExecutionData[],
@@ -553,6 +569,7 @@ export interface IExecuteSingleFunctions {
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object;
getRestApiUrl(): string;
getTimezone(): string;
getExecuteData(): IExecuteData;
getWorkflow(): IWorkflowMetadata;
getWorkflowDataProxy(): IWorkflowDataProxyData;
getWorkflowStaticData(type: string): IDataObject;
@@ -801,11 +818,25 @@ export interface IBinaryKeyData {
[key: string]: IBinaryData;
}
export interface IPairedItemData {
item: number;
input?: number; // If undefined "0" gets used
}
export interface INodeExecutionData {
[key: string]: IDataObject | IBinaryKeyData | NodeApiError | NodeOperationError | undefined;
[key: string]:
| IDataObject
| IBinaryKeyData
| IPairedItemData
| IPairedItemData[]
| NodeApiError
| NodeOperationError
| number
| undefined;
json: IDataObject;
binary?: IBinaryKeyData;
error?: NodeApiError | NodeOperationError;
pairedItem?: IPairedItemData | IPairedItemData[] | number;
}
export interface INodeExecuteFunctions {
@@ -1262,6 +1293,7 @@ export interface IRunExecutionData {
contextData: IExecuteContextData;
nodeExecutionStack: IExecuteData[];
waitingExecution: IWaitingForExecution;
waitingExecutionSource: IWaitingForExecutionSource | null;
};
waitTill?: Date;
}
@@ -1277,9 +1309,16 @@ export interface ITaskData {
executionTime: number;
data?: ITaskDataConnections;
error?: ExecutionError;
source: Array<ISourceData | null>; // Is an array as nodes have multiple inputs
}
// The data for al the different kind of connectons (like main) and all the indexes
export interface ISourceData {
previousNode: string;
previousNodeOutput?: number; // If undefined "0" gets used
previousNodeRun?: number; // If undefined "0" gets used
}
// The data for all the different kind of connectons (like main) and all the indexes
export interface ITaskDataConnections {
// Key for each input type and because there can be multiple inputs of the same type it is an array
// null is also allowed because if we still need data for a later while executing the workflow set teompoary to null
@@ -1296,6 +1335,21 @@ export interface IWaitingForExecution {
};
}
export interface ITaskDataConnectionsSource {
// Key for each input type and because there can be multiple inputs of the same type it is an array
// null is also allowed because if we still need data for a later while executing the workflow set teompoary to null
// the nodes get as input TaskDataConnections which is identical to this one except that no null is allowed.
[key: string]: Array<ISourceData | null>;
}
export interface IWaitingForExecutionSource {
// Node name
[key: string]: {
// Run index
[key: number]: ITaskDataConnectionsSource;
};
}
export interface IWorkflowBase {
id?: number | string | any;
name: string;

View File

@@ -7,7 +7,7 @@
// eslint-disable-next-line max-classes-per-file
import { parseString } from 'xml2js';
// eslint-disable-next-line import/no-cycle
import { INode, IStatusCodeMessages, JsonObject } from '.';
import { IDataObject, INode, IStatusCodeMessages, JsonObject } from '.';
/**
* Top-level properties where an error message can be found in an API response.
@@ -56,29 +56,42 @@ const ERROR_STATUS_PROPERTIES = [
*/
const ERROR_NESTING_PROPERTIES = ['error', 'err', 'response', 'body', 'data'];
/**
* Base class for specific NodeError-types, with functionality for finding
* a value recursively inside an error object.
*/
abstract class NodeError extends Error {
export abstract class ExecutionBaseError extends Error {
description: string | null | undefined;
cause: Error | JsonObject;
node: INode;
timestamp: number;
constructor(node: INode, error: Error | JsonObject) {
context: IDataObject = {};
constructor(error: Error | ExecutionBaseError | JsonObject) {
super();
this.name = this.constructor.name;
this.cause = error;
this.node = node;
this.timestamp = Date.now();
if (error.message) {
this.message = error.message as string;
}
if (Object.prototype.hasOwnProperty.call(error, 'context')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.context = (error as any).context;
}
}
}
/**
* Base class for specific NodeError-types, with functionality for finding
* a value recursively inside an error object.
*/
abstract class NodeError extends ExecutionBaseError {
node: INode;
constructor(node: INode, error: Error | JsonObject) {
super(error);
this.node = node;
}
/**
@@ -203,7 +216,11 @@ abstract class NodeError extends Error {
* Class for instantiating an operational error, e.g. an invalid credentials error.
*/
export class NodeOperationError extends NodeError {
constructor(node: INode, error: Error | string, options?: { description: string }) {
constructor(
node: INode,
error: Error | string,
options?: { description?: string; runIndex?: number; itemIndex?: number },
) {
if (typeof error === 'string') {
error = new Error(error);
}
@@ -212,6 +229,14 @@ export class NodeOperationError extends NodeError {
if (options?.description) {
this.description = options.description;
}
if (options?.runIndex !== undefined) {
this.context.runIndex = options.runIndex;
}
if (options?.itemIndex !== undefined) {
this.context.itemIndex = options.itemIndex;
}
}
}
@@ -249,7 +274,16 @@ export class NodeApiError extends NodeError {
description,
httpCode,
parseXml,
}: { message?: string; description?: string; httpCode?: string; parseXml?: boolean } = {},
runIndex,
itemIndex,
}: {
message?: string;
description?: string;
httpCode?: string;
parseXml?: boolean;
runIndex?: number;
itemIndex?: number;
} = {},
) {
super(node, error);
if (error.error) {
@@ -272,6 +306,9 @@ export class NodeApiError extends NodeError {
}
this.description = this.findProperty(error, ERROR_MESSAGE_PROPERTIES, ERROR_NESTING_PROPERTIES);
if (runIndex !== undefined) this.context.runIndex = runIndex;
if (itemIndex !== undefined) this.context.itemIndex = itemIndex;
}
private setDescriptionFromXml(xml: string) {

View File

@@ -939,6 +939,7 @@ export function getNodeWebhooks(
'internal',
additionalData.timezone,
{},
undefined,
false,
) as boolean;
const restartWebhook: boolean = workflow.expression.getSimpleParameterValue(
@@ -947,6 +948,7 @@ export function getNodeWebhooks(
'internal',
additionalData.timezone,
{},
undefined,
false,
) as boolean;
const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath, isFullPath, restartWebhook);
@@ -957,6 +959,7 @@ export function getNodeWebhooks(
mode,
additionalData.timezone,
{},
undefined,
'GET',
);

View File

@@ -29,7 +29,9 @@ import {
ITaskDataConnections,
IWorkflowDataProxyAdditionalKeys,
IWorkflowExecuteAdditionalData,
NodeApiError,
NodeHelpers,
NodeOperationError,
NodeParameterValue,
Workflow,
WorkflowExecuteMode,
@@ -37,6 +39,7 @@ import {
import {
IDataObject,
IExecuteData,
IExecuteSingleFunctions,
IN8nRequestOperations,
INodeProperties,
@@ -77,6 +80,7 @@ export class RoutingNode {
inputData: ITaskDataConnections,
runIndex: number,
nodeType: INodeType,
executeData: IExecuteData,
nodeExecuteFunctions: INodeExecuteFunctions,
credentialsDecrypted?: ICredentialsDecrypted,
): Promise<INodeExecutionData[][] | null | undefined> {
@@ -97,6 +101,7 @@ export class RoutingNode {
inputData,
this.node,
this.additionalData,
executeData,
this.mode,
);
@@ -119,6 +124,7 @@ export class RoutingNode {
this.node,
i,
this.additionalData,
executeData,
this.mode,
);
@@ -145,6 +151,7 @@ export class RoutingNode {
value,
i,
runIndex,
executeData,
{ $credentials: credentials },
true,
) as string;
@@ -160,6 +167,7 @@ export class RoutingNode {
value,
i,
runIndex,
executeData,
{ $credentials: credentials },
true,
) as string | NodeParameterValue;
@@ -198,7 +206,7 @@ export class RoutingNode {
returnData.push({ json: {}, error: error.message });
continue;
}
throw error;
throw new NodeApiError(this.node, error, { runIndex, itemIndex: i });
}
}
@@ -254,9 +262,10 @@ export class RoutingNode {
});
});
} catch (e) {
throw new Error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new NodeOperationError(
this.node,
`The rootProperty "${action.properties.property}" could not be found on item.`,
{ runIndex, itemIndex },
);
}
}
@@ -269,6 +278,7 @@ export class RoutingNode {
value,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
{ $response: responseData, $value: parameterValue },
false,
) as IDataObject,
@@ -315,6 +325,7 @@ export class RoutingNode {
propertyValue,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
{
$response: responseData,
$responseItem: item.json,
@@ -338,6 +349,7 @@ export class RoutingNode {
destinationProperty,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
{ $response: responseData, $value: parameterValue },
false,
) as string;
@@ -512,8 +524,10 @@ export class RoutingNode {
| IDataObject[]
| undefined;
if (tempResponseValue === undefined) {
throw new Error(
throw new NodeOperationError(
this.node,
`The rootProperty "${properties.rootProperty}" could not be found on item.`,
{ runIndex, itemIndex },
);
}
@@ -546,6 +560,7 @@ export class RoutingNode {
parameterValue: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
itemIndex: number,
runIndex: number,
executeData: IExecuteData,
additionalKeys?: IWorkflowDataProxyAdditionalKeys,
returnObjectAsString = false,
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | string {
@@ -560,6 +575,7 @@ export class RoutingNode {
this.mode,
this.additionalData.timezone,
additionalKeys ?? {},
executeData,
returnObjectAsString,
);
}
@@ -617,6 +633,7 @@ export class RoutingNode {
propertyValue,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
{ ...additionalKeys, $value: parameterValue },
true,
) as string;
@@ -633,6 +650,7 @@ export class RoutingNode {
propertyName,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
additionalKeys,
true,
) as string;
@@ -647,6 +665,7 @@ export class RoutingNode {
valueString,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
{ ...additionalKeys, $value: value },
true,
) as string;
@@ -680,6 +699,7 @@ export class RoutingNode {
paginateValue,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
{ ...additionalKeys, $value: parameterValue },
true,
) as string;
@@ -701,6 +721,7 @@ export class RoutingNode {
maxResultsValue,
itemIndex,
runIndex,
executeSingleFunctions.getExecuteData(),
{ ...additionalKeys, $value: parameterValue },
true,
) as string;

View File

@@ -48,8 +48,10 @@ import {
import {
IConnection,
IDataObject,
IConnectedNode,
IDataObject,
IExecuteData,
INodeConnection,
IObservableObject,
IRun,
IRunNodeResponse,
@@ -805,34 +807,28 @@ export class Workflow {
}
/**
* Returns via which output of the parent-node the node
* is connected to.
* Returns via which output of the parent-node and index the current node
* they are connected
*
* @param {string} nodeName The node to check how it is connected with parent node
* @param {string} parentNodeName The parent node to get the output index of
* @param {string} [type='main']
* @param {*} [depth=-1]
* @param {string[]} [checkedNodes]
* @returns {(number | undefined)}
* @returns {(INodeConnection | undefined)}
* @memberof Workflow
*/
getNodeConnectionOutputIndex(
getNodeConnectionIndexes(
nodeName: string,
parentNodeName: string,
type = 'main',
depth = -1,
checkedNodes?: string[],
): number | undefined {
): INodeConnection | undefined {
const node = this.getNode(parentNodeName);
if (node === null) {
return undefined;
}
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType;
if (nodeType.description.outputs.length === 1) {
// If the parent node has only one output, it can only be connected
// to that one. So no further checking is required.
return 0;
}
depth = depth === -1 ? -1 : depth;
const newDepth = depth === -1 ? depth : depth - 1;
@@ -860,11 +856,19 @@ export class Workflow {
checkedNodes.push(nodeName);
let outputIndex: number | undefined;
let outputIndex: INodeConnection | undefined;
for (const connectionsByIndex of this.connectionsByDestinationNode[nodeName][type]) {
for (const connection of connectionsByIndex) {
for (
let destinationIndex = 0;
destinationIndex < connectionsByIndex.length;
destinationIndex++
) {
const connection = connectionsByIndex[destinationIndex];
if (parentNodeName === connection.node) {
return connection.index;
return {
sourceIndex: connection.index,
destinationIndex,
};
}
if (checkedNodes.includes(connection.node)) {
@@ -872,7 +876,7 @@ export class Workflow {
continue;
}
outputIndex = this.getNodeConnectionOutputIndex(
outputIndex = this.getNodeConnectionIndexes(
connection.node,
parentNodeName,
type,
@@ -1157,8 +1161,7 @@ export class Workflow {
/**
* Executes the given node.
*
* @param {INode} node
* @param {ITaskDataConnections} inputData
* @param {IExecuteData} executionData
* @param {IRunExecutionData} runExecutionData
* @param {number} runIndex
* @param {IWorkflowExecuteAdditionalData} additionalData
@@ -1168,14 +1171,16 @@ export class Workflow {
* @memberof Workflow
*/
async runNode(
node: INode,
inputData: ITaskDataConnections,
executionData: IExecuteData,
runExecutionData: IRunExecutionData,
runIndex: number,
additionalData: IWorkflowExecuteAdditionalData,
nodeExecuteFunctions: INodeExecuteFunctions,
mode: WorkflowExecuteMode,
): Promise<IRunNodeResponse> {
const { node } = executionData;
let inputData = executionData.data;
if (node.disabled === true) {
// If node is disabled simply pass the data through
// return NodeRunHelpers.
@@ -1254,6 +1259,7 @@ export class Workflow {
node,
itemIndex,
additionalData,
executionData,
mode,
);
@@ -1283,6 +1289,7 @@ export class Workflow {
inputData,
node,
additionalData,
executionData,
mode,
);
return { data: await nodeType.execute.call(thisArgs) };
@@ -1356,7 +1363,13 @@ export class Workflow {
);
return {
data: await routingNode.runNode(inputData, runIndex, nodeType, nodeExecuteFunctions),
data: await routingNode.runNode(
inputData,
runIndex,
nodeType,
executionData,
nodeExecuteFunctions,
),
};
}

View File

@@ -12,10 +12,15 @@ import * as jmespath from 'jmespath';
// eslint-disable-next-line import/no-cycle
import {
ExpressionError,
IDataObject,
IExecuteData,
INodeExecutionData,
INodeParameters,
IPairedItemData,
IRunExecutionData,
ISourceData,
ITaskData,
IWorkflowDataProxyAdditionalKeys,
IWorkflowDataProxyData,
NodeHelpers,
@@ -47,6 +52,8 @@ export class WorkflowDataProxy {
private additionalKeys: IWorkflowDataProxyAdditionalKeys;
private executeData: IExecuteData | undefined;
private defaultTimezone: string;
private timezone: string;
@@ -62,6 +69,7 @@ export class WorkflowDataProxy {
mode: WorkflowExecuteMode,
defaultTimezone: string,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
executeData?: IExecuteData,
defaultReturnRunIndex = -1,
selfData = {},
) {
@@ -78,7 +86,7 @@ export class WorkflowDataProxy {
this.timezone = (this.workflow.settings.timezone as string) || this.defaultTimezone;
this.selfData = selfData;
this.additionalKeys = additionalKeys;
this.executeData = executeData;
Settings.defaultZone = this.timezone;
}
@@ -202,6 +210,7 @@ export class WorkflowDataProxy {
that.mode,
that.timezone,
that.additionalKeys,
that.executeData,
);
}
@@ -234,17 +243,26 @@ export class WorkflowDataProxy {
// Long syntax got used to return data from node in path
if (that.runExecutionData === null) {
throw new Error(`Workflow did not run so do not have any execution-data.`);
throw new ExpressionError(`Workflow did not run so do not have any execution-data.`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
if (!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
if (that.workflow.getNode(nodeName)) {
throw new Error(
throw new ExpressionError(
`The node "${nodeName}" hasn't been executed yet, so you can't reference its output data`,
{
runIndex: that.runIndex,
itemIndex: that.itemIndex,
},
);
} else {
throw new Error(`No node called "${nodeName}" in this workflow`);
}
throw new ExpressionError(`No node called "${nodeName}" in this workflow`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
runIndex = runIndex === undefined ? that.defaultReturnRunIndex : runIndex;
@@ -252,32 +270,42 @@ export class WorkflowDataProxy {
runIndex === -1 ? that.runExecutionData.resultData.runData[nodeName].length - 1 : runIndex;
if (that.runExecutionData.resultData.runData[nodeName].length <= runIndex) {
throw new Error(`Run ${runIndex} of node "${nodeName}" not found`);
throw new ExpressionError(`Run ${runIndex} of node "${nodeName}" not found`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
const taskData = that.runExecutionData.resultData.runData[nodeName][runIndex].data!;
if (taskData.main === null || !taskData.main.length || taskData.main[0] === null) {
// throw new Error(`No data found for item-index: "${itemIndex}"`);
throw new Error(`No data found from "main" input.`);
throw new ExpressionError(`No data found from "main" input.`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
// Check from which output to read the data.
// Depends on how the nodes are connected.
// (example "IF" node. If node is connected to "true" or to "false" output)
if (outputIndex === undefined) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const outputIndex = that.workflow.getNodeConnectionOutputIndex(
const nodeConnection = that.workflow.getNodeConnectionIndexes(
that.activeNodeName,
nodeName,
'main',
);
if (outputIndex === undefined) {
throw new Error(
if (nodeConnection === undefined) {
throw new ExpressionError(
`The node "${that.activeNodeName}" is not connected with node "${nodeName}" so no data can get returned from it.`,
{
runIndex: that.runIndex,
itemIndex: that.itemIndex,
},
);
}
outputIndex = nodeConnection.sourceIndex;
}
if (outputIndex === undefined) {
@@ -285,7 +313,10 @@ export class WorkflowDataProxy {
}
if (taskData.main.length <= outputIndex) {
throw new Error(`Node "${nodeName}" has no branch with index ${outputIndex}.`);
throw new ExpressionError(`Node "${nodeName}" has no branch with index ${outputIndex}.`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
executionData = taskData.main[outputIndex] as INodeExecutionData[];
@@ -328,9 +359,11 @@ export class WorkflowDataProxy {
if (['binary', 'data', 'json'].includes(name)) {
const executionData = that.getNodeExecutionData(nodeName, shortSyntax, undefined);
if (executionData.length <= that.itemIndex) {
throw new Error(`No data found for item-index: "${that.itemIndex}"`);
throw new ExpressionError(`No data found for item-index: "${that.itemIndex}"`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
if (['data', 'json'].includes(name)) {
@@ -486,10 +519,177 @@ export class WorkflowDataProxy {
return jmespath.search(data, query);
};
const createExpressionError = (
message: string,
context?: {
messageTemplate?: string;
description?: string;
causeDetailed?: string;
},
) => {
return new ExpressionError(message, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
failExecution: true,
...context,
});
};
const getPairedItem = (
destinationNodeName: string,
incomingSourceData: ISourceData | null,
pairedItem: IPairedItemData,
): INodeExecutionData | null => {
let taskData: ITaskData;
let sourceData: ISourceData | null = incomingSourceData;
if (typeof pairedItem === 'number') {
pairedItem = {
item: pairedItem,
};
}
while (sourceData !== null && destinationNodeName !== sourceData.previousNode) {
taskData =
that.runExecutionData!.resultData.runData[sourceData.previousNode][
sourceData?.previousNodeRun || 0
];
const previousNodeOutput = sourceData.previousNodeOutput || 0;
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', {
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',
});
}
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', {
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)`,
});
}
const itemPreviousNode: INodeExecutionData =
taskData.data!.main[previousNodeOutput]![pairedItem.item];
if (itemPreviousNode.pairedItem === undefined) {
// `Could not resolve, as pairedItem data is missing on node '${sourceData.previousNode}'.`,
throw createExpressionError('Cant get data for expression', {
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)`,
});
}
if (Array.isArray(itemPreviousNode.pairedItem)) {
// Item is based on multiple items so check all of them
const results = itemPreviousNode.pairedItem
// eslint-disable-next-line @typescript-eslint/no-loop-func
.map((item) => {
try {
const itemInput = item.input || 0;
if (itemInput >= taskData.source.length) {
// `Could not resolve pairedItem as the defined node input '${itemInput}' does not exist on node '${sourceData!.previousNode}'.`
// Actual error does not matter as it gets caught below and `null` will be returned
throw new Error('Not found');
}
return getPairedItem(destinationNodeName, taskData.source[itemInput], item);
} catch (error) {
// Means pairedItem could not be found
return null;
}
})
.filter((result) => result !== null);
if (results.length !== 1) {
throw createExpressionError('Invalid expression', {
messageTemplate: 'Invalid expression under %%PARAMETER%%',
description: `The expression uses data in node ${destinationNodeName} but there is more than one matching item in that node`,
});
}
return results[0];
}
// pairedItem is not an array
if (typeof itemPreviousNode.pairedItem === 'number') {
pairedItem = {
item: itemPreviousNode.pairedItem,
};
} else {
pairedItem = itemPreviousNode.pairedItem;
}
const itemInput = pairedItem.input || 0;
if (itemInput >= taskData.source.length) {
if (taskData.source.length === 0) {
// A trigger node got reached, so looks like that that item can not be resolved
throw createExpressionError('Invalid expression', {
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).`,
});
}
// `Could not resolve pairedItem as the defined node input '${itemInput}' does not exist on node '${sourceData.previousNode}'.`
throw createExpressionError('Cant get data for expression', {
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)`,
});
}
sourceData = taskData.source[pairedItem.input || 0] || null;
}
if (sourceData === null) {
// 'Could not resolve, proably no pairedItem exists.'
throw createExpressionError('Cant get data for expression', {
messageTemplate: `Cant get data for expression under %%PARAMETER%%`,
description: `Could not resolve, proably no pairedItem exists`,
});
}
taskData =
that.runExecutionData!.resultData.runData[sourceData.previousNode][
sourceData?.previousNodeRun || 0
];
const previousNodeOutput = sourceData.previousNodeOutput || 0;
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', {
messageTemplate: `Cant get data for expression under %%PARAMETER%%`,
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)`,
});
}
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', {
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)`,
});
}
return taskData.data!.main[previousNodeOutput]![pairedItem.item];
};
const base = {
$: (nodeName: string) => {
if (!nodeName) {
throw new Error(`When calling $(), please specify a node`);
throw new ExpressionError('When calling $(), please specify a node', {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
failExecution: true,
});
}
return new Proxy(
@@ -497,12 +697,58 @@ export class WorkflowDataProxy {
{
get(target, property, receiver) {
if (property === 'pairedItem') {
return () => {
const executionData = getNodeOutput(nodeName, 0, that.runIndex);
if (executionData[that.itemIndex]) {
return executionData[that.itemIndex];
return (itemIndex?: number) => {
if (itemIndex === undefined) {
itemIndex = that.itemIndex;
}
return undefined;
const executionData = that.connectionInputData;
// As we operate on the incoming item we can be sure that pairedItem is not an
// array. After all can it only come from exactly one previous node via a certain
// input. For that reason do we not have to consider the array case.
const pairedItem = executionData[itemIndex].pairedItem as IPairedItemData;
if (pairedItem === undefined) {
throw new ExpressionError('Cant get data for expression', {
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 ${that.activeNodeName}`,
causeDetailed: `Missing pairedItem data (node ${that.activeNodeName} did probably not supply it)`,
runIndex: that.runIndex,
itemIndex,
failExecution: true,
});
}
if (!that.executeData?.source) {
throw new ExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%%',
description: `Apologies, this is an internal error. See details for more information`,
causeDetailed: `Missing sourceData (probably an internal error)`,
runIndex: that.runIndex,
itemIndex,
failExecution: true,
});
}
// Before resolving the pairedItem make sure that the requested node comes in the
// graph before the current one
const parentNodes = that.workflow.getParentNodes(that.activeNodeName);
if (!parentNodes.includes(nodeName)) {
throw new ExpressionError('Invalid expression', {
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).`,
runIndex: that.runIndex,
itemIndex,
failExecution: true,
});
}
const sourceData: ISourceData = that.executeData?.source.main![
pairedItem.input || 0
] as ISourceData;
return getPairedItem(nodeName, sourceData, pairedItem);
};
}
if (property === 'item') {
@@ -513,6 +759,7 @@ export class WorkflowDataProxy {
runIndex = that.runIndex;
}
const executionData = getNodeOutput(nodeName, branchIndex, runIndex);
if (executionData[itemIndex]) {
return executionData[itemIndex];
}
@@ -645,6 +892,7 @@ export class WorkflowDataProxy {
that.mode,
that.timezone,
that.additionalKeys,
that.executeData,
);
},
$item: (itemIndex: number, runIndex?: number) => {
@@ -660,6 +908,7 @@ export class WorkflowDataProxy {
that.mode,
that.defaultTimezone,
that.additionalKeys,
that.executeData,
defaultReturnRunIndex,
);
return dataProxy.getDataProxy();

View File

@@ -6,6 +6,7 @@ import * as ObservableObject from './ObservableObject';
export * from './DeferredPromise';
export * from './Interfaces';
export * from './Expression';
export * from './ExpressionError';
export * from './NodeErrors';
export * as TelemetryHelpers from './TelemetryHelpers';
export * from './RoutingNode';

View File

@@ -9,6 +9,7 @@ import {
ICredentialsEncrypted,
ICredentialsHelper,
IDataObject,
IExecuteData,
IExecuteFunctions,
IExecuteResponsePromiseData,
IExecuteSingleFunctions,
@@ -146,6 +147,7 @@ export function getNodeParameter(
mode: WorkflowExecuteMode,
timezone: string,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
executeData: IExecuteData,
fallbackValue?: any,
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object {
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
@@ -189,6 +191,7 @@ export function getExecuteFunctions(
node: INode,
itemIndex: number,
additionalData: IWorkflowExecuteAdditionalData,
executeData: IExecuteData,
mode: WorkflowExecuteMode,
): IExecuteFunctions {
return ((workflow, runExecutionData, connectionInputData, inputData, node) => {
@@ -272,6 +275,9 @@ export function getExecuteFunctions(
getTimezone: (): string => {
return additionalData.timezone;
},
getExecuteData: (): IExecuteData => {
return executeData;
},
getWorkflow: () => {
return {
id: workflow.id,
@@ -291,6 +297,7 @@ export function getExecuteFunctions(
mode,
additionalData.timezone,
{},
executeData,
);
return dataProxy.getDataProxy();
},
@@ -375,6 +382,7 @@ export function getExecuteSingleFunctions(
node: INode,
itemIndex: number,
additionalData: IWorkflowExecuteAdditionalData,
executeData: IExecuteData,
mode: WorkflowExecuteMode,
): IExecuteSingleFunctions {
return ((workflow, runExecutionData, connectionInputData, inputData, node, itemIndex) => {
@@ -431,6 +439,9 @@ export function getExecuteSingleFunctions(
getTimezone: (): string => {
return additionalData.timezone;
},
getExecuteData: (): IExecuteData => {
return executeData;
},
getNodeParameter: (
parameterName: string,
fallbackValue?: any,
@@ -473,6 +484,7 @@ export function getExecuteSingleFunctions(
mode,
additionalData.timezone,
{},
executeData,
);
return dataProxy.getDataProxy();
},

View File

@@ -15,6 +15,7 @@ import {
INodeExecuteFunctions,
IN8nRequestOperations,
INodeCredentialDescription,
IExecuteData,
} from '../src';
import * as Helpers from './Helpers';
@@ -657,6 +658,11 @@ describe('RoutingNode', () => {
node,
itemIndex,
additionalData,
{
node,
data: {},
source: null,
},
mode,
);
@@ -1636,6 +1642,12 @@ describe('RoutingNode', () => {
mode,
);
const executeData = {
data: {},
node,
source: null,
} as IExecuteData;
// @ts-ignore
const nodeExecuteFunctions: INodeExecuteFunctions = {
getExecuteFunctions: () => {
@@ -1648,6 +1660,7 @@ describe('RoutingNode', () => {
node,
itemIndex,
additionalData,
executeData,
mode,
);
},
@@ -1661,6 +1674,7 @@ describe('RoutingNode', () => {
node,
itemIndex,
additionalData,
executeData,
mode,
);
},
@@ -1670,6 +1684,7 @@ describe('RoutingNode', () => {
inputData,
runIndex,
nodeType,
executeData,
nodeExecuteFunctions,
);

View File

@@ -1243,6 +1243,7 @@ describe('Workflow', () => {
],
],
},
source: [],
},
],
},

View File

@@ -94,6 +94,7 @@ describe('WorkflowDataProxy', () => {
],
],
},
source: [],
},
],
Rename: [
@@ -122,6 +123,7 @@ describe('WorkflowDataProxy', () => {
],
],
},
source: [],
},
],
},