fix(Summarize Node): Fix type casting of strings and numbers (#14259)

This commit is contained in:
Elias Meire
2025-03-28 17:39:29 +01:00
committed by GitHub
parent 24fad512da
commit 4443a5f532
4 changed files with 476 additions and 167 deletions

View File

@@ -1,23 +1,22 @@
import { import {
NodeOperationError,
NodeConnectionTypes,
type IExecuteFunctions, type IExecuteFunctions,
type INodeExecutionData, type INodeExecutionData,
type INodeType, type INodeType,
type INodeTypeDescription, type INodeTypeDescription,
NodeConnectionTypes,
type NodeExecutionHint, type NodeExecutionHint,
type IDataObject, NodeOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
type Aggregations, type Aggregations,
NUMERICAL_AGGREGATIONS, NUMERICAL_AGGREGATIONS,
type SummarizeOptions, type SummarizeOptions,
aggregationToArray, aggregateAndSplitData,
aggregationToArrayWithOriginalTypes,
checkIfFieldExists, checkIfFieldExists,
fieldValueGetter, fieldValueGetter,
splitData, flattenAggregationResultToArray,
flattenAggregationResultToObject,
} from './utils'; } from './utils';
export class Summarize implements INodeType { export class Summarize implements INodeType {
@@ -321,13 +320,14 @@ export class Summarize implements INodeType {
const nodeVersion = this.getNode().typeVersion; const nodeVersion = this.getNode().typeVersion;
const aggregationResult = splitData( const aggregationResult = aggregateAndSplitData({
fieldsToSplitBy, splitKeys: fieldsToSplitBy,
newItems, inputItems: newItems,
fieldsToSummarize, fieldsToSummarize,
options, options,
getValue, getValue,
); convertKeysToString: nodeVersion === 1,
});
const fieldsNotFound: NodeExecutionHint[] = []; const fieldsNotFound: NodeExecutionHint[] = [];
try { try {
@@ -350,36 +350,27 @@ export class Summarize implements INodeType {
if (options.outputFormat === 'singleItem') { if (options.outputFormat === 'singleItem') {
const executionData: INodeExecutionData = { const executionData: INodeExecutionData = {
json: aggregationResult, json: flattenAggregationResultToObject(aggregationResult),
pairedItem: newItems.map((_v, index) => ({ pairedItem: newItems.map((_v, index) => ({
item: index, item: index,
})), })),
}; };
return [[executionData]]; return [[executionData]];
} else { } else {
if (!fieldsToSplitBy.length) { if (!fieldsToSplitBy.length && 'pairedItems' in aggregationResult) {
const { pairedItems, ...json } = aggregationResult; const { pairedItems, returnData } = aggregationResult;
const executionData: INodeExecutionData = { const executionData: INodeExecutionData = {
json, json: returnData,
pairedItem: ((pairedItems as number[]) || []).map((index: number) => ({ pairedItem: (pairedItems ?? []).map((index) => ({ item: index })),
item: index,
})),
}; };
return [[executionData]]; return [[executionData]];
} }
let returnData: IDataObject[] = []; const flatAggregationResults = flattenAggregationResultToArray(aggregationResult);
if (nodeVersion > 1) { const executionData = flatAggregationResults.map((item) => {
returnData = aggregationToArrayWithOriginalTypes(aggregationResult, fieldsToSplitBy); const { pairedItems, returnData } = item;
} else {
returnData = aggregationToArray(aggregationResult, fieldsToSplitBy);
}
const executionData = returnData.map((item) => {
const { pairedItems, ...json } = item;
return { return {
json, json: returnData,
pairedItem: ((pairedItems as number[]) || []).map((index: number) => ({ pairedItem: (pairedItems ?? []).map((index) => ({ item: index })),
item: index,
})),
}; };
}); });
return [executionData]; return [executionData];

View File

@@ -0,0 +1,228 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Test Summarize Node, aggregateAndSplitData should not convert numbers to strings: array 1`] = `
[
{
"pairedItems": [
0,
],
"returnData": {
"Qty": 1,
"Sku": 12345,
"appended_Warehouse": [
"BER_0G",
],
},
},
{
"pairedItems": [
1,
],
"returnData": {
"Qty": 2,
"Sku": 12345,
"appended_Warehouse": [
"BER_0L",
],
},
},
{
"pairedItems": [
2,
],
"returnData": {
"Qty": 1,
"Sku": 6534563534,
"appended_Warehouse": [
"BER_0L",
],
},
},
]
`;
exports[`Test Summarize Node, aggregateAndSplitData should not convert numbers to strings: result 1`] = `
{
"fieldName": "Sku",
"splits": Map {
12345 => {
"fieldName": "Qty",
"splits": Map {
1 => {
"pairedItems": [
0,
],
"returnData": {
"appended_Warehouse": [
"BER_0G",
],
},
},
2 => {
"pairedItems": [
1,
],
"returnData": {
"appended_Warehouse": [
"BER_0L",
],
},
},
},
},
6534563534 => {
"fieldName": "Qty",
"splits": Map {
1 => {
"pairedItems": [
2,
],
"returnData": {
"appended_Warehouse": [
"BER_0L",
],
},
},
},
},
},
}
`;
exports[`Test Summarize Node, aggregateAndSplitData should not convert strings to numbers: array 1`] = `
[
{
"pairedItems": [
0,
],
"returnData": {
"Qty": "1",
"Sku": "012345",
"appended_Warehouse": [
"BER_0G",
],
},
},
{
"pairedItems": [
1,
],
"returnData": {
"Qty": "2",
"Sku": "012345",
"appended_Warehouse": [
"BER_0L",
],
},
},
{
"pairedItems": [
2,
],
"returnData": {
"Qty": "1",
"Sku": "06534563534",
"appended_Warehouse": [
"BER_0L",
],
},
},
]
`;
exports[`Test Summarize Node, aggregateAndSplitData should not convert strings to numbers: result 1`] = `
{
"fieldName": "Sku",
"splits": Map {
"012345" => {
"fieldName": "Qty",
"splits": Map {
"1" => {
"pairedItems": [
0,
],
"returnData": {
"appended_Warehouse": [
"BER_0G",
],
},
},
"2" => {
"pairedItems": [
1,
],
"returnData": {
"appended_Warehouse": [
"BER_0L",
],
},
},
},
},
"06534563534" => {
"fieldName": "Qty",
"splits": Map {
"1" => {
"pairedItems": [
2,
],
"returnData": {
"appended_Warehouse": [
"BER_0L",
],
},
},
},
},
},
}
`;
exports[`Test Summarize Node, aggregateAndSplitData with skipEmptySplitFields=true should skip empty split fields: array 1`] = `
[
{
"pairedItems": [
0,
3,
],
"returnData": {
"Sku": 12345,
"concatenated_Warehouse": "BER_0G//{"name":"BER_0G3"}",
},
},
{
"pairedItems": [
2,
],
"returnData": {
"Sku": "{}",
"concatenated_Warehouse": "BER_0L",
},
},
]
`;
exports[`Test Summarize Node, aggregateAndSplitData with skipEmptySplitFields=true should skip empty split fields: result 1`] = `
{
"fieldName": "Sku",
"splits": Map {
12345 => {
"pairedItems": [
0,
3,
],
"returnData": {
"concatenated_Warehouse": "BER_0G//{"name":"BER_0G3"}",
},
},
"{}" => {
"pairedItems": [
2,
],
"returnData": {
"concatenated_Warehouse": "BER_0L",
},
},
},
}
`;

View File

@@ -0,0 +1,135 @@
import {
fieldValueGetter,
aggregateAndSplitData,
flattenAggregationResultToArray,
type Aggregations,
} from '../../utils';
describe('Test Summarize Node, aggregateAndSplitData', () => {
test('should not convert strings to numbers', () => {
const data = [
{
Sku: '012345',
Warehouse: 'BER_0G',
Qty: '1',
_itemIndex: 0,
},
{
Sku: '012345',
Warehouse: 'BER_0L',
Qty: '2',
_itemIndex: 1,
},
{
Sku: '06534563534',
Warehouse: 'BER_0L',
Qty: '1',
_itemIndex: 2,
},
];
const aggregations: Aggregations = [
{
aggregation: 'append',
field: 'Warehouse',
includeEmpty: true,
},
];
const result = aggregateAndSplitData({
splitKeys: ['Sku', 'Qty'],
inputItems: data,
fieldsToSummarize: aggregations,
options: { continueIfFieldNotFound: true },
getValue: fieldValueGetter(),
});
expect(result).toMatchSnapshot('result');
expect(flattenAggregationResultToArray(result)).toMatchSnapshot('array');
});
test('should not convert numbers to strings', () => {
const data = [
{
Sku: 12345,
Warehouse: 'BER_0G',
Qty: 1,
_itemIndex: 0,
},
{
Sku: 12345,
Warehouse: 'BER_0L',
Qty: 2,
_itemIndex: 1,
},
{
Sku: 6534563534,
Warehouse: 'BER_0L',
Qty: 1,
_itemIndex: 2,
},
];
const aggregations: Aggregations = [
{
aggregation: 'append',
field: 'Warehouse',
includeEmpty: true,
},
];
const result = aggregateAndSplitData({
splitKeys: ['Sku', 'Qty'],
inputItems: data,
fieldsToSummarize: aggregations,
options: { continueIfFieldNotFound: true },
getValue: fieldValueGetter(),
});
expect(result).toMatchSnapshot('result');
expect(flattenAggregationResultToArray(result)).toMatchSnapshot('array');
});
describe('with skipEmptySplitFields=true', () => {
test('should skip empty split fields', () => {
const data = [
{
Sku: 12345,
Warehouse: 'BER_0G',
_itemIndex: 0,
},
{
Warehouse: 'BER_0L',
_itemIndex: 1,
},
{
Sku: {},
Warehouse: 'BER_0L',
_itemIndex: 2,
},
{
Sku: 12345,
Warehouse: { name: 'BER_0G3' },
_itemIndex: 3,
},
];
const aggregations: Aggregations = [
{
aggregation: 'concatenate',
field: 'Warehouse',
separateBy: 'other',
customSeparator: '//',
},
];
const result = aggregateAndSplitData({
splitKeys: ['Sku'],
inputItems: data,
fieldsToSummarize: aggregations,
options: { continueIfFieldNotFound: true, skipEmptySplitFields: true },
getValue: fieldValueGetter(),
});
expect(result).toMatchSnapshot('result');
expect(flattenAggregationResultToArray(result)).toMatchSnapshot('array');
});
});
});

View File

@@ -1,8 +1,7 @@
import { isNaN } from 'lodash';
import get from 'lodash/get'; import get from 'lodash/get';
import { import {
type IDataObject,
type GenericValue, type GenericValue,
type IDataObject,
type IExecuteFunctions, type IExecuteFunctions,
NodeOperationError, NodeOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
@@ -56,42 +55,13 @@ function isEmpty<T>(value: T) {
return value === undefined || value === null || value === ''; return value === undefined || value === null || value === '';
} }
function parseReturnData(returnData: IDataObject) { function normalizeFieldName(fieldName: string) {
const regexBrackets = /[\]\["]/g; return fieldName.replace(/[\]\["]/g, '').replace(/[ .]/g, '');
const regexSpaces = /[ .]/g;
for (const key of Object.keys(returnData)) {
if (key.match(regexBrackets)) {
const newKey = key.replace(regexBrackets, '');
returnData[newKey] = returnData[key];
delete returnData[key];
}
}
for (const key of Object.keys(returnData)) {
if (key.match(regexSpaces)) {
const newKey = key.replace(regexSpaces, '_');
returnData[newKey] = returnData[key];
delete returnData[key];
}
}
}
function parseFieldName(fieldName: string[]) {
const regexBrackets = /[\]\["]/g;
const regexSpaces = /[ .]/g;
fieldName = fieldName.map((field) => {
field = field.replace(regexBrackets, '');
field = field.replace(regexSpaces, '_');
return field;
});
return fieldName;
} }
export const fieldValueGetter = (disableDotNotation?: boolean) => { export const fieldValueGetter = (disableDotNotation?: boolean) => {
if (disableDotNotation) { return (item: IDataObject, field: string) =>
return (item: IDataObject, field: string) => item[field]; disableDotNotation ? item[field] : get(item, field);
} else {
return (item: IDataObject, field: string) => get(item, field);
}
}; };
export function checkIfFieldExists( export function checkIfFieldExists(
@@ -207,126 +177,111 @@ function aggregateData(
fieldsToSummarize: Aggregations, fieldsToSummarize: Aggregations,
options: SummarizeOptions, options: SummarizeOptions,
getValue: ValueGetterFn, getValue: ValueGetterFn,
) { ): { returnData: IDataObject; pairedItems?: number[] } {
const returnData = fieldsToSummarize.reduce((acc, aggregation) => { const returnData = Object.fromEntries(
acc[`${AggregationDisplayNames[aggregation.aggregation]}${aggregation.field}`] = aggregate( fieldsToSummarize.map((aggregation) => {
data, const key = normalizeFieldName(
aggregation, `${AggregationDisplayNames[aggregation.aggregation]}${aggregation.field}`,
getValue, );
); const result = aggregate(data, aggregation, getValue);
return acc; return [key, result];
}, {} as IDataObject); }),
parseReturnData(returnData); );
if (options.outputFormat === 'singleItem') { if (options.outputFormat === 'singleItem') {
parseReturnData(returnData); return { returnData };
return returnData;
} else {
return { ...returnData, pairedItems: data.map((item) => item._itemIndex as number) };
} }
return { returnData, pairedItems: data.map((item) => item._itemIndex as number) };
} }
export function splitData( type AggregationResult = { returnData: IDataObject; pairedItems?: number[] };
splitKeys: string[], type NestedAggregationResult =
data: IDataObject[], | AggregationResult
fieldsToSummarize: Aggregations, | { fieldName: string; splits: Map<unknown, NestedAggregationResult> };
options: SummarizeOptions,
getValue: ValueGetterFn, // Using Map to preserve types
) { // With a plain JS object, keys are converted to string
if (!splitKeys || splitKeys.length === 0) { export function aggregateAndSplitData({
return aggregateData(data, fieldsToSummarize, options, getValue); splitKeys,
inputItems,
fieldsToSummarize,
options,
getValue,
convertKeysToString = false,
}: {
splitKeys: string[] | undefined;
inputItems: IDataObject[];
fieldsToSummarize: Aggregations;
options: SummarizeOptions;
getValue: ValueGetterFn;
convertKeysToString?: boolean; // Legacy option for v1
}): NestedAggregationResult {
if (!splitKeys?.length) {
return aggregateData(inputItems, fieldsToSummarize, options, getValue);
} }
const [firstSplitKey, ...restSplitKeys] = splitKeys; const [firstSplitKey, ...restSplitKeys] = splitKeys;
const groupedData = data.reduce((acc, item) => { const groupedItems = new Map<unknown, IDataObject[]>();
let keyValue = getValue(item, firstSplitKey) as string; for (const item of inputItems) {
let key = getValue(item, firstSplitKey);
if (typeof keyValue === 'object') { if (key && typeof key === 'object') {
keyValue = JSON.stringify(keyValue); key = JSON.stringify(key);
} }
if (options.skipEmptySplitFields && typeof keyValue !== 'number' && !keyValue) { if (convertKeysToString) {
return acc; key = normalizeFieldName(String(key));
} }
if (acc[keyValue] === undefined) { if (options.skipEmptySplitFields && typeof key !== 'number' && !key) {
acc[keyValue] = [item]; continue;
} else {
(acc[keyValue] as IDataObject[]).push(item);
} }
return acc;
}, {} as IDataObject);
return Object.keys(groupedData).reduce((acc, key) => { const group = groupedItems.get(key) ?? [];
const value = groupedData[key] as IDataObject[]; groupedItems.set(key, group.concat([item]));
acc[key] = splitData(restSplitKeys, value, fieldsToSummarize, options, getValue);
return acc;
}, {} as IDataObject);
}
export function aggregationToArray(
aggregationResult: IDataObject,
fieldsToSplitBy: string[],
previousStage: IDataObject = {},
) {
const returnData: IDataObject[] = [];
fieldsToSplitBy = parseFieldName(fieldsToSplitBy);
const splitFieldName = fieldsToSplitBy[0];
const isNext = fieldsToSplitBy[1];
if (isNext === undefined) {
for (const fieldName of Object.keys(aggregationResult)) {
returnData.push({
...previousStage,
[splitFieldName]: fieldName,
...(aggregationResult[fieldName] as IDataObject),
});
}
return returnData;
} else {
for (const key of Object.keys(aggregationResult)) {
returnData.push(
...aggregationToArray(aggregationResult[key] as IDataObject, fieldsToSplitBy.slice(1), {
...previousStage,
[splitFieldName]: key,
}),
);
}
return returnData;
} }
const splits = new Map(
Array.from(groupedItems.entries()).map(([groupKey, items]) => [
groupKey,
aggregateAndSplitData({
splitKeys: restSplitKeys,
inputItems: items,
fieldsToSummarize,
options,
getValue,
}),
]),
);
return { fieldName: firstSplitKey, splits };
} }
const getOriginalFieldValue = (field: string | number) => export function flattenAggregationResultToObject(result: NestedAggregationResult): IDataObject {
field === 'null' ? null : isNaN(Number(field)) ? field : Number(field); if ('splits' in result) {
return Object.fromEntries(
export function aggregationToArrayWithOriginalTypes( Array.from(result.splits.entries()).map(([key, value]) => [
aggregationResult: IDataObject, key,
fieldsToSplitBy: string[], flattenAggregationResultToObject(value),
previousStage: IDataObject = {}, ]),
) { );
const returnData: IDataObject[] = [];
fieldsToSplitBy = parseFieldName(fieldsToSplitBy);
const splitFieldName = fieldsToSplitBy[0];
const isNext = fieldsToSplitBy[1];
if (isNext === undefined) {
for (const fieldName of Object.keys(aggregationResult)) {
returnData.push({
...previousStage,
[splitFieldName]: getOriginalFieldValue(fieldName),
...(aggregationResult[fieldName] as IDataObject),
});
}
return returnData;
} else {
for (const key of Object.keys(aggregationResult)) {
returnData.push(
...aggregationToArray(aggregationResult[key] as IDataObject, fieldsToSplitBy.slice(1), {
...previousStage,
[splitFieldName]: getOriginalFieldValue(key),
}),
);
}
return returnData;
} }
return result.returnData;
}
export function flattenAggregationResultToArray(
result: NestedAggregationResult,
): AggregationResult[] {
if ('splits' in result) {
return Array.from(result.splits.entries()).flatMap(([value, innerResult]) =>
flattenAggregationResultToArray(innerResult).map((v) => {
v.returnData[normalizeFieldName(result.fieldName)] = value as IDataObject;
return v;
}),
);
}
return [result];
} }