refactor(core): Remove NodeExecutionOutput. Add execution hints directly to the context (#13111)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-02-10 16:51:01 +01:00 committed by GitHub
parent 5dddf772cf
commit dbb9475b7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 103 additions and 171 deletions

View file

@ -36,7 +36,6 @@ import {
ApplicationError, ApplicationError,
createDeferredPromise, createDeferredPromise,
NodeConnectionType, NodeConnectionType,
NodeExecutionOutput,
NodeHelpers, NodeHelpers,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -246,18 +245,6 @@ describe('WorkflowExecute', () => {
} }
}); });
test('WorkflowExecute, NodeExecutionOutput type test', () => {
//TODO Add more tests here when execution hints are added to some node types
const nodeExecutionOutput = new NodeExecutionOutput(
[[{ json: { data: 123 } }]],
[{ message: 'TEXT HINT' }],
);
expect(nodeExecutionOutput).toBeInstanceOf(NodeExecutionOutput);
expect(nodeExecutionOutput[0][0].json.data).toEqual(123);
expect(nodeExecutionOutput.getHints()[0].message).toEqual('TEXT HINT');
});
describe('runPartialWorkflow2', () => { describe('runPartialWorkflow2', () => {
// Dirty ► // Dirty ►
// ┌───────┐1 ┌─────┐1 ┌─────┐ // ┌───────┐1 ┌─────┐1 ┌─────┐

View file

@ -11,6 +11,7 @@ import type {
IRunExecutionData, IRunExecutionData,
ITaskDataConnections, ITaskDataConnections,
IWorkflowExecuteAdditionalData, IWorkflowExecuteAdditionalData,
NodeExecutionHint,
Result, Result,
Workflow, Workflow,
WorkflowExecuteMode, WorkflowExecuteMode,
@ -51,6 +52,8 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti
readonly getNodeParameter: IExecuteFunctions['getNodeParameter']; readonly getNodeParameter: IExecuteFunctions['getNodeParameter'];
readonly hints: NodeExecutionHint[] = [];
constructor( constructor(
workflow: Workflow, workflow: Workflow,
node: INode, node: INode,
@ -210,4 +213,8 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti
getParentCallbackManager(): CallbackManager | undefined { getParentCallbackManager(): CallbackManager | undefined {
return this.additionalData.parentCallbackManager; return this.additionalData.parentCallbackManager;
} }
addExecutionHints(...hints: NodeExecutionHint[]) {
this.hints.push(...hints);
}
} }

View file

@ -47,7 +47,6 @@ import {
NodeHelpers, NodeHelpers,
NodeConnectionType, NodeConnectionType,
ApplicationError, ApplicationError,
NodeExecutionOutput,
sleep, sleep,
ExecutionCancelledError, ExecutionCancelledError,
Node, Node,
@ -1101,7 +1100,7 @@ export class WorkflowExecute {
}); });
} }
return { data }; return { data, hints: context.hints };
} else if (nodeType.poll) { } else if (nodeType.poll) {
if (mode === 'manual') { if (mode === 'manual') {
// In manual mode run the poll function // In manual mode run the poll function
@ -1507,10 +1506,8 @@ export class WorkflowExecute {
tryIndex++; tryIndex++;
} }
if (nodeSuccessData instanceof NodeExecutionOutput) { if (runNodeData.hints?.length) {
const hints = (nodeSuccessData as NodeExecutionOutput).getHints(); executionHints.push(...runNodeData.hints);
executionHints.push(...hints);
} }
if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') { if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') {

View file

@ -165,7 +165,8 @@ export class Code implements INodeType {
standardizeOutput(item.json); standardizeOutput(item.json);
} }
return addPostExecutionWarning(items, inputDataItems?.length); addPostExecutionWarning(this, items, inputDataItems?.length);
return [items];
} }
// ---------------------------------- // ----------------------------------
@ -201,6 +202,7 @@ export class Code implements INodeType {
} }
} }
return addPostExecutionWarning(returnData, inputDataItems?.length); addPostExecutionWarning(this, returnData, inputDataItems?.length);
return [returnData];
} }
} }

View file

@ -1,49 +1,46 @@
import type { INodeExecutionData } from 'n8n-workflow'; import { mock } from 'jest-mock-extended';
import { NodeExecutionOutput } from 'n8n-workflow'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { addPostExecutionWarning } from '../utils'; import { addPostExecutionWarning } from '../utils';
describe('addPostExecutionWarning', () => { describe('addPostExecutionWarning', () => {
const context = mock<IExecuteFunctions>();
const inputItemsLength = 2; const inputItemsLength = 2;
it('should return a NodeExecutionOutput warning when returnData length differs from inputItemsLength', () => { beforeEach(() => jest.resetAllMocks());
it('should add execution hints when returnData length differs from inputItemsLength', () => {
const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }]; const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }];
const result = addPostExecutionWarning(returnData, inputItemsLength); addPostExecutionWarning(context, returnData, inputItemsLength);
expect(result).toBeInstanceOf(NodeExecutionOutput); expect(context.addExecutionHints).toHaveBeenCalledWith({
expect((result as NodeExecutionOutput)?.getHints()).toEqual([ message:
{ 'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
message: location: 'outputPane',
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>', });
location: 'outputPane',
},
]);
}); });
it('should return a NodeExecutionOutput warning when any item has undefined pairedItem', () => { it('should add execution hints when any item has undefined pairedItem', () => {
const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }, { json: {} }]; const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }, { json: {} }];
const result = addPostExecutionWarning(returnData, inputItemsLength); addPostExecutionWarning(context, returnData, inputItemsLength);
expect(result).toBeInstanceOf(NodeExecutionOutput); expect(context.addExecutionHints).toHaveBeenCalledWith({
expect((result as NodeExecutionOutput)?.getHints()).toEqual([ message:
{ 'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
message: location: 'outputPane',
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>', });
location: 'outputPane',
},
]);
}); });
it('should return returnData array when all items match inputItemsLength and have defined pairedItem', () => { it('should not add execution hints when all items match inputItemsLength and have defined pairedItem', () => {
const returnData: INodeExecutionData[] = [ const returnData: INodeExecutionData[] = [
{ json: {}, pairedItem: 0 }, { json: {}, pairedItem: 0 },
{ json: {}, pairedItem: 1 }, { json: {}, pairedItem: 1 },
]; ];
const result = addPostExecutionWarning(returnData, inputItemsLength); addPostExecutionWarning(context, returnData, inputItemsLength);
expect(result).toEqual([returnData]); expect(context.addExecutionHints).not.toHaveBeenCalled();
}); });
}); });

View file

@ -1,5 +1,4 @@
import type { INodeExecutionData, IDataObject } from 'n8n-workflow'; import type { INodeExecutionData, IDataObject, IExecuteFunctions } from 'n8n-workflow';
import { NodeExecutionOutput } from 'n8n-workflow';
export function isObject(maybe: unknown): maybe is { [key: string]: unknown } { export function isObject(maybe: unknown): maybe is { [key: string]: unknown } {
return ( return (
@ -39,24 +38,18 @@ export function standardizeOutput(output: IDataObject) {
} }
export const addPostExecutionWarning = ( export const addPostExecutionWarning = (
context: IExecuteFunctions,
returnData: INodeExecutionData[], returnData: INodeExecutionData[],
inputItemsLength: number, inputItemsLength: number,
) => { ): void => {
if ( if (
returnData.length !== inputItemsLength || returnData.length !== inputItemsLength ||
returnData.some((item) => item.pairedItem === undefined) returnData.some((item) => item.pairedItem === undefined)
) { ) {
return new NodeExecutionOutput( context.addExecutionHints({
[returnData], message:
[ 'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
{ location: 'outputPane',
message: });
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
location: 'outputPane',
},
],
);
} }
return [returnData];
}; };

View file

@ -10,12 +10,7 @@ import type {
JsonObject, JsonObject,
NodeExecutionHint, NodeExecutionHint,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow';
NodeConnectionType,
NodeApiError,
NodeOperationError,
NodeExecutionOutput,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { calendarFields, calendarOperations } from './CalendarDescription'; import { calendarFields, calendarOperations } from './CalendarDescription';
@ -811,7 +806,7 @@ export class GoogleCalendar implements INodeType {
} }
if (hints.length) { if (hints.length) {
return new NodeExecutionOutput([nodeExecutionData], hints); this.addExecutionHints(...hints);
} }
return [nodeExecutionData]; return [nodeExecutionData];

View file

@ -1,6 +1,6 @@
import type { MockProxy } from 'jest-mock-extended'; import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions, IDataObject, NodeExecutionOutput } from 'n8n-workflow'; import type { INode, IExecuteFunctions, IDataObject } from 'n8n-workflow';
import * as genericFunctions from '../../GenericFunctions'; import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node'; import { GoogleCalendar } from '../../GoogleCalendar.node';
@ -207,15 +207,13 @@ describe('Google Calendar Node', () => {
}, },
]; ];
const result = await googleCalendar.execute.call(mockExecuteFunctions); await googleCalendar.execute.call(mockExecuteFunctions);
expect((result as NodeExecutionOutput).getHints()).toEqual([ expect(mockExecuteFunctions.addExecutionHints).toHaveBeenCalledWith({
{ message:
message: "Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.", location: 'outputPane',
location: 'outputPane', });
},
]);
}); });
}); });
}); });

View file

@ -16,7 +16,6 @@ import type {
import { import {
BINARY_ENCODING, BINARY_ENCODING,
NodeApiError, NodeApiError,
NodeExecutionOutput,
NodeConnectionType, NodeConnectionType,
NodeOperationError, NodeOperationError,
jsonParse, jsonParse,
@ -1002,16 +1001,11 @@ export class HttpRequestV3 implements INodeType {
returnItems[0].json.data && returnItems[0].json.data &&
Array.isArray(returnItems[0].json.data) Array.isArray(returnItems[0].json.data)
) { ) {
return new NodeExecutionOutput( this.addExecutionHints({
[returnItems], message:
[ 'To split the contents of data into separate items for easier processing, add a Split Out node after this one',
{ location: 'outputPane',
message: });
'To split the contents of data into separate items for easier processing, add a Split Out node after this one',
location: 'outputPane',
},
],
);
} }
return [returnItems]; return [returnItems];

View file

@ -1,10 +1,9 @@
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import { import type {
NodeExecutionOutput, IExecuteFunctions,
type IExecuteFunctions, INodeExecutionData,
type INodeExecutionData, INodeProperties,
type INodeProperties, IPairedItemData,
type IPairedItemData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities'; import { updateDisplayOptions } from '@utils/utilities';
@ -82,15 +81,10 @@ export async function execute(
} else { } else {
numEntries = Math.min(...inputsData.map((input) => input.length), preferred.length); numEntries = Math.min(...inputsData.map((input) => input.length), preferred.length);
if (numEntries === 0) { if (numEntries === 0) {
return new NodeExecutionOutput( this.addExecutionHints({
[returnData], message: 'Consider enabling "Include Any Unpaired Items" in options or check your inputs',
[ });
{ return [returnData];
message:
'Consider enabling "Include Any Unpaired Items" in options or check your inputs',
},
],
);
} }
} }

View file

@ -1,5 +1,5 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeExecutionOutput, NodeOperationError } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow';
import * as database from './database/Database.resource'; import * as database from './database/Database.resource';
import type { PostgresType } from './node.type'; import type { PostgresType } from './node.type';
@ -54,15 +54,10 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
} }
if (operation === 'select' && items.length > 1 && !node.executeOnce) { if (operation === 'select' && items.length > 1 && !node.executeOnce) {
return new NodeExecutionOutput( this.addExecutionHints({
[returnData], message: `This node ran ${items.length} times, once for each input item. To run for the first item only, enable 'execute once' in the node settings`,
[ location: 'outputPane',
{ });
message: `This node ran ${items.length} times, once for each input item. To run for the first item only, enable 'execute once' in the node settings`,
location: 'outputPane',
},
],
);
} }
return [returnData]; return [returnData];

View file

@ -11,7 +11,6 @@ import {
type IPairedItemData, type IPairedItemData,
NodeConnectionType, NodeConnectionType,
type NodeExecutionHint, type NodeExecutionHint,
NodeExecutionOutput,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { addBinariesToItem } from './utils'; import { addBinariesToItem } from './utils';
@ -432,7 +431,9 @@ export class Aggregate implements INodeType {
} }
} }
if (hints.length) return new NodeExecutionOutput([[returnData]], hints); if (hints.length) {
this.addExecutionHints(...hints);
}
} }
return [[returnData]]; return [[returnData]];

View file

@ -1,9 +1,4 @@
import { import { NodeConnectionType, NodeOperationError, tryToParseDateTime } from 'n8n-workflow';
NodeConnectionType,
NodeExecutionOutput,
NodeOperationError,
tryToParseDateTime,
} from 'n8n-workflow';
import type { import type {
INodeTypeBaseDescription, INodeTypeBaseDescription,
IExecuteFunctions, IExecuteFunctions,
@ -126,13 +121,12 @@ export class RemoveDuplicatesV2 implements INodeType {
); );
if (maxEntriesNum > 0 && processedDataCount / maxEntriesNum > 0.5) { if (maxEntriesNum > 0 && processedDataCount / maxEntriesNum > 0.5) {
return new NodeExecutionOutput(returnData, [ this.addExecutionHints({
{ message: `Some duplicates may be not be removed since you're approaching the maximum history size (${maxEntriesNum} items). You can raise this limit using the history size option.`,
message: `Some duplicates may be not be removed since you're approaching the maximum history size (${maxEntriesNum} items). You can raise this limit using the history size option.`, location: 'outputPane',
location: 'outputPane', });
}, }
]); return returnData;
} else return returnData;
} else if (logic === 'removeItemsUpToStoredIncrementalKey') { } else if (logic === 'removeItemsUpToStoredIncrementalKey') {
if (!['node', 'workflow'].includes(scope)) { if (!['node', 'workflow'].includes(scope)) {
throw new NodeOperationError( throw new NodeOperationError(

View file

@ -1,11 +1,6 @@
import get from 'lodash/get'; import get from 'lodash/get';
import unset from 'lodash/unset'; import unset from 'lodash/unset';
import { import { NodeOperationError, deepCopy, NodeConnectionType } from 'n8n-workflow';
NodeOperationError,
deepCopy,
NodeExecutionOutput,
NodeConnectionType,
} from 'n8n-workflow';
import type { import type {
IBinaryData, IBinaryData,
IDataObject, IDataObject,
@ -281,7 +276,9 @@ export class SplitOut implements INodeType {
} }
} }
if (hints.length) return new NodeExecutionOutput([returnData], hints); if (hints.length) {
this.addExecutionHints(...hints);
}
} }
return [returnData]; return [returnData];

View file

@ -5,7 +5,6 @@ import {
type INodeExecutionData, type INodeExecutionData,
type INodeType, type INodeType,
type INodeTypeDescription, type INodeTypeDescription,
NodeExecutionOutput,
type NodeExecutionHint, type NodeExecutionHint,
type IDataObject, type IDataObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -345,6 +344,10 @@ export class Summarize implements INodeType {
} }
} }
if (fieldsNotFound.length) {
this.addExecutionHints(...fieldsNotFound);
}
if (options.outputFormat === 'singleItem') { if (options.outputFormat === 'singleItem') {
const executionData: INodeExecutionData = { const executionData: INodeExecutionData = {
json: aggregationResult, json: aggregationResult,
@ -352,7 +355,7 @@ export class Summarize implements INodeType {
item: index, item: index,
})), })),
}; };
return new NodeExecutionOutput([[executionData]], fieldsNotFound); return [[executionData]];
} else { } else {
if (!fieldsToSplitBy.length) { if (!fieldsToSplitBy.length) {
const { pairedItems, ...json } = aggregationResult; const { pairedItems, ...json } = aggregationResult;
@ -362,7 +365,7 @@ export class Summarize implements INodeType {
item: index, item: index,
})), })),
}; };
return new NodeExecutionOutput([[executionData]], fieldsNotFound); return [[executionData]];
} }
let returnData: IDataObject[] = []; let returnData: IDataObject[] = [];
if (nodeVersion > 1) { if (nodeVersion > 1) {
@ -379,7 +382,7 @@ export class Summarize implements INodeType {
})), })),
}; };
}); });
return new NodeExecutionOutput([executionData], fieldsNotFound); return [executionData];
} }
} }
} }

View file

@ -1,7 +1,7 @@
import type { MockProxy } from 'jest-mock-extended'; import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow'; import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeExecutionOutput, NodeOperationError } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow';
import { Summarize } from '../../Summarize.node'; import { Summarize } from '../../Summarize.node';
import type { Aggregations } from '../../utils'; import type { Aggregations } from '../../utils';
@ -43,14 +43,11 @@ describe('Test Summarize Node, execute', () => {
const result = await summarizeNode.execute.call(mockExecuteFunctions); const result = await summarizeNode.execute.call(mockExecuteFunctions);
expect(result).toBeInstanceOf(NodeExecutionOutput);
expect(result).toEqual([[{ json: { sum_nonexistentField: 0 }, pairedItem: [{ item: 0 }] }]]); expect(result).toEqual([[{ json: { sum_nonexistentField: 0 }, pairedItem: [{ item: 0 }] }]]);
expect((result as NodeExecutionOutput).getHints()).toEqual([ expect(mockExecuteFunctions.addExecutionHints).toHaveBeenCalledWith({
{ location: 'outputPane',
location: 'outputPane', message: "The field 'nonexistentField' does not exist in any items",
message: "The field 'nonexistentField' does not exist in any items", });
},
]);
}); });
it('should throw error if node version is < 1.1 and fields not found', async () => { it('should throw error if node version is < 1.1 and fields not found', async () => {

View file

@ -14,7 +14,7 @@ import type { URLSearchParams } from 'url';
import type { CODE_EXECUTION_MODES, CODE_LANGUAGES, LOG_LEVELS } from './Constants'; import type { CODE_EXECUTION_MODES, CODE_LANGUAGES, LOG_LEVELS } from './Constants';
import type { IDeferredPromise } from './DeferredPromise'; import type { IDeferredPromise } from './DeferredPromise';
import { ApplicationError, type ExecutionCancelledError } from './errors'; import type { ExecutionCancelledError } from './errors';
import type { ExpressionError } from './errors/expression.error'; import type { ExpressionError } from './errors/expression.error';
import type { NodeApiError } from './errors/node-api.error'; import type { NodeApiError } from './errors/node-api.error';
import type { NodeOperationError } from './errors/node-operation.error'; import type { NodeOperationError } from './errors/node-operation.error';
@ -398,7 +398,8 @@ export interface INodeTypeNameVersion {
} }
export interface IRunNodeResponse { export interface IRunNodeResponse {
data: INodeExecutionData[][] | NodeExecutionOutput | null | undefined; data: INodeExecutionData[][] | null | undefined;
hints?: NodeExecutionHint[];
closeFunction?: CloseFunction; closeFunction?: CloseFunction;
} }
@ -918,6 +919,8 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
metadata?: ITaskMetadata, metadata?: ITaskMetadata,
): void; ): void;
addExecutionHints(...hints: NodeExecutionHint[]): void;
nodeHelpers: NodeHelperFunctions; nodeHelpers: NodeHelperFunctions;
helpers: RequestHelperFunctions & helpers: RequestHelperFunctions &
BaseHelperFunctions & BaseHelperFunctions &
@ -1553,28 +1556,6 @@ export interface SupplyData {
closeFunction?: CloseFunction; closeFunction?: CloseFunction;
} }
export class NodeExecutionOutput extends Array<INodeExecutionData[]> {
constructor(data: INodeExecutionData[][], hints: NodeExecutionHint[] = []) {
super();
// TODO: This is a temporary solution for NODE-1740, until we move away from extending native Array class
Object.defineProperty(data, 'getHints', {
value: () => hints,
enumerable: false,
writable: false,
configurable: false,
});
return data as NodeExecutionOutput;
}
static [Symbol.hasInstance](instance: unknown) {
return Array.isArray(instance) && 'getHints' in instance;
}
getHints(): NodeExecutionHint[] {
throw new ApplicationError('This should not have been called');
}
}
export interface INodeType { export interface INodeType {
description: INodeTypeDescription; description: INodeTypeDescription;
supplyData?(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData>; supplyData?(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData>;