Skip to content

Commit

Permalink
feat(Summarize Node): Turns error when field not found in items into …
Browse files Browse the repository at this point in the history
…warning (#11889)

Co-authored-by: Dana Lee <[email protected]>
Co-authored-by: Elias Meire <[email protected]>
  • Loading branch information
3 people authored Jan 28, 2025
1 parent 69a97bd commit d7dda3f
Show file tree
Hide file tree
Showing 5 changed files with 237 additions and 27 deletions.
47 changes: 27 additions & 20 deletions packages/nodes-base/nodes/Transform/Summarize/Summarize.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
NodeExecutionOutput,
type NodeExecutionHint,
} from 'n8n-workflow';

import {
Expand All @@ -16,7 +18,6 @@ import {
fieldValueGetter,
splitData,
} from './utils';
import { generatePairedItemData } from '../../../utils/utilities';

export class Summarize implements INodeType {
description: INodeTypeDescription = {
Expand All @@ -25,7 +26,7 @@ export class Summarize implements INodeType {
icon: 'file:summarize.svg',
group: ['transform'],
subtitle: '',
version: 1,
version: [1, 1.1],
description: 'Sum, count, max, etc. across items',
defaults: {
name: 'Summarize',
Expand Down Expand Up @@ -248,7 +249,12 @@ export class Summarize implements INodeType {
type: 'boolean',
default: false,
description:
"Whether to continue if field to summarize can't be found in any items and return single empty item, owerwise an error would be thrown",
"Whether to continue if field to summarize can't be found in any items and return single empty item, otherwise an error would be thrown",
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'Disable Dot Notation',
Expand Down Expand Up @@ -314,20 +320,6 @@ export class Summarize implements INodeType {

const nodeVersion = this.getNode().typeVersion;

if (nodeVersion < 2.1) {
try {
checkIfFieldExists.call(this, newItems, fieldsToSummarize, getValue);
} catch (error) {
if (options.continueIfFieldNotFound) {
const itemData = generatePairedItemData(items.length);

return [[{ json: {}, pairedItem: itemData }]];
} else {
throw error;
}
}
}

const aggregationResult = splitData(
fieldsToSplitBy,
newItems,
Expand All @@ -336,14 +328,29 @@ export class Summarize implements INodeType {
getValue,
);

const fieldsNotFound: NodeExecutionHint[] = [];
try {
checkIfFieldExists.call(this, newItems, fieldsToSummarize, getValue);
} catch (error) {
if (nodeVersion > 1 || options.continueIfFieldNotFound) {
const fieldNotFoundHint: NodeExecutionHint = {
message: error instanceof Error ? error.message : String(error),
location: 'outputPane',
};
fieldsNotFound.push(fieldNotFoundHint);
} else {
throw error;
}
}

if (options.outputFormat === 'singleItem') {
const executionData: INodeExecutionData = {
json: aggregationResult,
pairedItem: newItems.map((_v, index) => ({
item: index,
})),
};
return [[executionData]];
return new NodeExecutionOutput([[executionData]], fieldsNotFound);
} else {
if (!fieldsToSplitBy.length) {
const { pairedItems, ...json } = aggregationResult;
Expand All @@ -353,7 +360,7 @@ export class Summarize implements INodeType {
item: index,
})),
};
return [[executionData]];
return new NodeExecutionOutput([[executionData]], fieldsNotFound);
}
const returnData = aggregationToArray(aggregationResult, fieldsToSplitBy);
const executionData = returnData.map((item) => {
Expand All @@ -365,7 +372,7 @@ export class Summarize implements INodeType {
})),
};
});
return [executionData];
return new NodeExecutionOutput([executionData], fieldsNotFound);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NodeOperationError, type IExecuteFunctions, type IDataObject } from 'n8n-workflow';

import { checkIfFieldExists, type Aggregations } from '../../utils';

describe('Test Summarize Node, checkIfFieldExists', () => {
let mockExecuteFunctions: IExecuteFunctions;

beforeEach(() => {
mockExecuteFunctions = {
getNode: jest.fn().mockReturnValue({ name: 'test-node' }),
} as unknown as IExecuteFunctions;
});

const items = [{ a: 1 }, { b: 2 }, { c: 3 }];

it('should not throw error if all fields exist', () => {
const aggregations: Aggregations = [
{ aggregation: 'sum', field: 'a' },
{ aggregation: 'count', field: 'c' },
];
const getValue = (item: IDataObject, field: string) => item[field];
expect(() => {
checkIfFieldExists.call(mockExecuteFunctions, items, aggregations, getValue);
}).not.toThrow();
});

it('should throw NodeOperationError if any field does not exist', () => {
const aggregations: Aggregations = [
{ aggregation: 'sum', field: 'b' },
{ aggregation: 'count', field: 'd' },
];
const getValue = (item: IDataObject, field: string) => item[field];
expect(() => {
checkIfFieldExists.call(mockExecuteFunctions, items, aggregations, getValue);
}).toThrow(NodeOperationError);
});

it("should throw NodeOperationError with error message containing the field name that doesn't exist", () => {
const aggregations: Aggregations = [{ aggregation: 'count', field: 'D' }];
const getValue = (item: IDataObject, field: string) => item[field];
expect(() => {
checkIfFieldExists.call(mockExecuteFunctions, items, aggregations, getValue);
}).toThrow("The field 'D' does not exist in any items");
});

it('should not throw error if field is empty string', () => {
const aggregations: Aggregations = [{ aggregation: 'count', field: '' }];
const getValue = (item: IDataObject, field: string) => item[field];
expect(() => {
checkIfFieldExists.call(mockExecuteFunctions, items, aggregations, getValue);
}).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeExecutionOutput, NodeOperationError } from 'n8n-workflow';

import { Summarize } from '../../Summarize.node';
import type { Aggregations } from '../../utils';

let summarizeNode: Summarize;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;

describe('Test Summarize Node, execute', () => {
beforeEach(() => {
summarizeNode = new Summarize();
mockExecuteFunctions = mock<IExecuteFunctions>({
getNode: jest.fn().mockReturnValue({ name: 'test-node' }),
getNodeParameter: jest.fn(),
getInputData: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockReturnValue([]),
},
});
});

afterEach(() => {
jest.clearAllMocks();
});

it('should handle field not found with hints if version > 1', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: { someField: 1 } }]);
mockExecuteFunctions.getNode.mockReturnValue({
id: '1',
name: 'test-node',
type: 'test-type',
position: [0, 0],
parameters: {},
typeVersion: 1.1,
});
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('') // fieldsToSplitBy
.mockReturnValueOnce([{ field: 'nonexistentField', aggregation: 'sum' }]); // fieldsToSummarize

const result = await summarizeNode.execute.call(mockExecuteFunctions);

expect(result).toBeInstanceOf(NodeExecutionOutput);
expect(result).toEqual([[{ json: { sum_nonexistentField: 0 }, pairedItem: [{ item: 0 }] }]]);
expect((result as NodeExecutionOutput).getHints()).toEqual([
{
location: 'outputPane',
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 () => {
const items = [{ json: { a: 1, b: 2, c: 3 } }];
const aggregations: Aggregations = [
{ aggregation: 'sum', field: 'b' },
{ aggregation: 'count', field: 'd' },
];

mockExecuteFunctions.getNode.mockReturnValue({
id: '1',
name: 'test-node',
type: 'test-type',
position: [0, 0],
parameters: {},
typeVersion: 1,
});
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('') // fieldsToSplitBy
.mockReturnValueOnce(aggregations); // fieldsToSummarize
await expect(async () => {
await summarizeNode.execute.bind(mockExecuteFunctions)();
}).rejects.toThrow(NodeOperationError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [520, -80],
"id": "9ff1d8e0-ebe8-4c52-931a-e482e14ac911",
"name": "When clicking ‘Test workflow’"
},
{
"parameters": {
"category": "randomData",
"randomDataSeed": "ria",
"randomDataCount": 5
},
"type": "n8n-nodes-base.debugHelper",
"typeVersion": 1,
"position": [700, -80],
"id": "50cea846-f8ed-4b5a-a14a-c11bf4ba0f5d",
"name": "DebugHelper2"
},
{
"parameters": {
"fieldsToSummarize": {
"values": [
{
"field": "passwor"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.summarize",
"typeVersion": 1.1,
"position": [900, -80],
"id": "d39fedc9-62e1-45bc-8c9a-d20f2b63b543",
"name": "Summarize1"
}
],
"connections": {
"When clicking ‘Test workflow’": {
"main": [
[
{
"node": "DebugHelper2",
"type": "main",
"index": 0
}
]
]
},
"DebugHelper2": {
"main": [
[
{
"node": "Summarize1",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "ee90fdf8d57662f949e6c691dc07fa0fd2f66e1eee28ed82ef06658223e67255"
}
}
14 changes: 7 additions & 7 deletions packages/nodes-base/nodes/Transform/Summarize/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,20 +231,20 @@ export function splitData(
const [firstSplitKey, ...restSplitKeys] = splitKeys;

const groupedData = data.reduce((acc, item) => {
let keyValuee = getValue(item, firstSplitKey) as string;
let keyValue = getValue(item, firstSplitKey) as string;

if (typeof keyValuee === 'object') {
keyValuee = JSON.stringify(keyValuee);
if (typeof keyValue === 'object') {
keyValue = JSON.stringify(keyValue);
}

if (options.skipEmptySplitFields && typeof keyValuee !== 'number' && !keyValuee) {
if (options.skipEmptySplitFields && typeof keyValue !== 'number' && !keyValue) {
return acc;
}

if (acc[keyValuee] === undefined) {
acc[keyValuee] = [item];
if (acc[keyValue] === undefined) {
acc[keyValue] = [item];
} else {
(acc[keyValuee] as IDataObject[]).push(item);
(acc[keyValue] as IDataObject[]).push(item);
}
return acc;
}, {} as IDataObject);
Expand Down

0 comments on commit d7dda3f

Please sign in to comment.