mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(Summarize Node): Turns error when field not found in items into warning (#11889)
Co-authored-by: Dana Lee <dana@n8n.io> Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
parent
69a97bd32d
commit
d7dda3f5de
|
@ -5,6 +5,8 @@ import {
|
||||||
type INodeExecutionData,
|
type INodeExecutionData,
|
||||||
type INodeType,
|
type INodeType,
|
||||||
type INodeTypeDescription,
|
type INodeTypeDescription,
|
||||||
|
NodeExecutionOutput,
|
||||||
|
type NodeExecutionHint,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -16,7 +18,6 @@ import {
|
||||||
fieldValueGetter,
|
fieldValueGetter,
|
||||||
splitData,
|
splitData,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { generatePairedItemData } from '../../../utils/utilities';
|
|
||||||
|
|
||||||
export class Summarize implements INodeType {
|
export class Summarize implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -25,7 +26,7 @@ export class Summarize implements INodeType {
|
||||||
icon: 'file:summarize.svg',
|
icon: 'file:summarize.svg',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
version: 1,
|
version: [1, 1.1],
|
||||||
description: 'Sum, count, max, etc. across items',
|
description: 'Sum, count, max, etc. across items',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Summarize',
|
name: 'Summarize',
|
||||||
|
@ -248,7 +249,12 @@ export class Summarize implements INodeType {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
description:
|
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',
|
displayName: 'Disable Dot Notation',
|
||||||
|
@ -314,20 +320,6 @@ export class Summarize implements INodeType {
|
||||||
|
|
||||||
const nodeVersion = this.getNode().typeVersion;
|
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(
|
const aggregationResult = splitData(
|
||||||
fieldsToSplitBy,
|
fieldsToSplitBy,
|
||||||
newItems,
|
newItems,
|
||||||
|
@ -336,6 +328,21 @@ export class Summarize implements INodeType {
|
||||||
getValue,
|
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') {
|
if (options.outputFormat === 'singleItem') {
|
||||||
const executionData: INodeExecutionData = {
|
const executionData: INodeExecutionData = {
|
||||||
json: aggregationResult,
|
json: aggregationResult,
|
||||||
|
@ -343,7 +350,7 @@ export class Summarize implements INodeType {
|
||||||
item: index,
|
item: index,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return [[executionData]];
|
return new NodeExecutionOutput([[executionData]], fieldsNotFound);
|
||||||
} else {
|
} else {
|
||||||
if (!fieldsToSplitBy.length) {
|
if (!fieldsToSplitBy.length) {
|
||||||
const { pairedItems, ...json } = aggregationResult;
|
const { pairedItems, ...json } = aggregationResult;
|
||||||
|
@ -353,7 +360,7 @@ export class Summarize implements INodeType {
|
||||||
item: index,
|
item: index,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return [[executionData]];
|
return new NodeExecutionOutput([[executionData]], fieldsNotFound);
|
||||||
}
|
}
|
||||||
const returnData = aggregationToArray(aggregationResult, fieldsToSplitBy);
|
const returnData = aggregationToArray(aggregationResult, fieldsToSplitBy);
|
||||||
const executionData = returnData.map((item) => {
|
const executionData = returnData.map((item) => {
|
||||||
|
@ -365,7 +372,7 @@ export class Summarize implements INodeType {
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return [executionData];
|
return new NodeExecutionOutput([executionData], fieldsNotFound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -231,20 +231,20 @@ export function splitData(
|
||||||
const [firstSplitKey, ...restSplitKeys] = splitKeys;
|
const [firstSplitKey, ...restSplitKeys] = splitKeys;
|
||||||
|
|
||||||
const groupedData = data.reduce((acc, item) => {
|
const groupedData = data.reduce((acc, item) => {
|
||||||
let keyValuee = getValue(item, firstSplitKey) as string;
|
let keyValue = getValue(item, firstSplitKey) as string;
|
||||||
|
|
||||||
if (typeof keyValuee === 'object') {
|
if (typeof keyValue === 'object') {
|
||||||
keyValuee = JSON.stringify(keyValuee);
|
keyValue = JSON.stringify(keyValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.skipEmptySplitFields && typeof keyValuee !== 'number' && !keyValuee) {
|
if (options.skipEmptySplitFields && typeof keyValue !== 'number' && !keyValue) {
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (acc[keyValuee] === undefined) {
|
if (acc[keyValue] === undefined) {
|
||||||
acc[keyValuee] = [item];
|
acc[keyValue] = [item];
|
||||||
} else {
|
} else {
|
||||||
(acc[keyValuee] as IDataObject[]).push(item);
|
(acc[keyValue] as IDataObject[]).push(item);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as IDataObject);
|
}, {} as IDataObject);
|
||||||
|
|
Loading…
Reference in a new issue