fix(Split Out Node): Support dot notation in destination fields (#39661)

This commit is contained in:
RomanDavydchuk 2026-09-29 14:44:00 +00:00 committed by GitHub
parent ce295b6080
commit f9dbd2c8d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 205 additions and 19 deletions

View File

@ -1,6 +1,14 @@
import get from 'lodash/get';
import set from 'lodash/set';
import toPath from 'lodash/toPath';
import unset from 'lodash/unset';
import { NodeOperationError, deepCopy, NodeConnectionTypes } from 'n8n-workflow';
import {
NodeOperationError,
deepCopy,
isSafeObjectProperty,
NodeConnectionTypes,
setSafeObjectProperty,
} from 'n8n-workflow';
import type {
IBinaryData,
IDataObject,
@ -21,7 +29,7 @@ export class SplitOut implements INodeType {
iconColor: 'violet',
group: ['transform'],
subtitle: '',
version: 1,
version: [1, 1.1],
description: 'Turn a list inside item(s) into separate items',
defaults: {
name: 'Split Out',
@ -127,6 +135,7 @@ export class SplitOut implements INodeType {
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
const fieldsTracker = new FieldsTracker();
const nodeVersion = this.getNode().typeVersion;
for (let i = 0; i < items.length; i++) {
const fieldsToSplitOut = prepareFieldsArray(
@ -142,6 +151,8 @@ export class SplitOut implements INodeType {
.split(',')
.filter((field) => field.trim() !== '')
.map((field) => field.trim());
const supportsDestinationPaths =
nodeVersion >= 1.1 && !disableDotNotation && destinationFields.length > 0;
if (destinationFields.length && destinationFields.length !== fieldsToSplitOut.length) {
throw new NodeOperationError(
@ -159,8 +170,37 @@ export class SplitOut implements INodeType {
const item = { ...items[i].json };
const splited: INodeExecutionData[] = [];
const pendingDestinationWrites: Array<Array<[field: string, value: IDataObject[string]]>> =
[];
const setOutputField = (
target: IDataObject,
elementIndex: number,
field: string,
value: IDataObject[string],
) => {
if (supportsDestinationPaths) {
pendingDestinationWrites[elementIndex] ??= [];
pendingDestinationWrites[elementIndex].push([field, deepCopy(value)]);
} else {
setSafeObjectProperty(target, field, value);
}
};
const assertSafeOutputField = (field: string) => {
const path = supportsDestinationPaths ? toPath(field) : [field];
const reservedProperty = path.find((segment) => !isSafeObjectProperty(segment));
if (reservedProperty !== undefined) {
throw new NodeOperationError(
this.getNode(),
`The output field "${field}" contains the reserved property "${reservedProperty}"`,
{ description: 'Change the field name and try again.' },
);
}
};
for (const [entryIndex, fieldToSplitOut] of fieldsToSplitOut.entries()) {
const destinationFieldName = destinationFields[entryIndex] || '';
const fieldName = destinationFieldName || fieldToSplitOut;
if (fieldToSplitOut !== '$binary') assertSafeOutputField(fieldName);
let entityToSplit: IDataObject[] = [];
@ -199,8 +239,6 @@ export class SplitOut implements INodeType {
splited[elementIndex] = { json: {}, pairedItem: { item: i } };
}
const fieldName = destinationFieldName || fieldToSplitOut;
if (fieldToSplitOut === '$binary') {
if (splited[elementIndex].binary === undefined) {
splited[elementIndex].binary = {};
@ -219,15 +257,15 @@ export class SplitOut implements INodeType {
pairedItem: { item: i },
};
} else {
splited[elementIndex].json[fieldName] = element;
setOutputField(splited[elementIndex].json, elementIndex, fieldName, element);
}
} else {
splited[elementIndex].json[fieldName] = element;
setOutputField(splited[elementIndex].json, elementIndex, fieldName, element);
}
}
}
for (const splitEntry of splited) {
for (const [elementIndex, splitEntry] of splited.entries()) {
let newItem: INodeExecutionData = splitEntry;
if (include === 'allOtherFields') {
@ -265,6 +303,14 @@ export class SplitOut implements INodeType {
newItem = splitEntry;
}
const destinationWrites = pendingDestinationWrites[elementIndex] ?? [];
if (destinationWrites.length > 0) {
newItem.json = deepCopy(newItem.json);
}
for (const [field, value] of destinationWrites) {
set(newItem.json, field, value);
}
const includeBinary = options.includeBinary as boolean;
if (includeBinary) {

View File

@ -1,5 +1,5 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import type { IExecuteFunctions } from 'n8n-workflow';
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { SplitOut } from '../SplitOut.node';
@ -9,20 +9,33 @@ describe('Test Split Out Node', () => {
});
describe('SplitOut', () => {
const execute = async (fieldToSplitOut: string | string[] | number) => {
const executeFunctions = mock<IExecuteFunctions>();
executeFunctions.getInputData.mockReturnValue([
{
json: {
data: [{ id: 1 }, { id: 2 }],
metadata: [{ name: 'first' }, { name: 'second' }],
},
const execute = async (
fieldToSplitOut: string | string[] | number,
{
inputData = {
data: [{ id: 1 }, { id: 2 }],
metadata: [{ name: 'first' }, { name: 'second' }],
},
]);
options = {},
include = 'noOtherFields',
fieldsToInclude = '',
typeVersion = 1.1,
}: {
inputData?: IDataObject;
options?: IDataObject;
include?: 'selectedOtherFields' | 'allOtherFields' | 'noOtherFields';
fieldsToInclude?: string;
typeVersion?: number;
} = {},
) => {
const executeFunctions = mock<IExecuteFunctions>();
executeFunctions.getInputData.mockReturnValue([{ json: inputData }]);
executeFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion }));
executeFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
if (parameterName === 'fieldToSplitOut') return fieldToSplitOut;
if (parameterName === 'options') return {};
if (parameterName === 'include') return 'noOtherFields';
if (parameterName === 'options') return options;
if (parameterName === 'include') return include;
if (parameterName === 'fieldsToInclude') return fieldsToInclude;
return undefined;
});
@ -64,4 +77,131 @@ describe('SplitOut', () => {
"The 'Fields To Split Out' parameter must be a string of fields separated by commas or an array of strings.",
);
});
it('should use dot notation for the destination field', async () => {
await expect(
execute('data', { options: { destinationFieldName: 'output.item' } }),
).resolves.toEqual([
[
{ json: { output: { item: { id: 1 } } }, pairedItem: { item: 0 } },
{ json: { output: { item: { id: 2 } } }, pairedItem: { item: 0 } },
],
]);
});
it('should keep the destination field literal when dot notation is disabled', async () => {
await expect(
execute('data', {
options: { destinationFieldName: 'output.item', disableDotNotation: true },
}),
).resolves.toEqual([
[
{ json: { 'output.item': { id: 1 } }, pairedItem: { item: 0 } },
{ json: { 'output.item': { id: 2 } }, pairedItem: { item: 0 } },
],
]);
});
it.each(['__proto__', 'constructor', 'prototype'])(
'should reject the reserved destination path segment %s',
async (reservedProperty) => {
const destinationFieldName = `output.${reservedProperty}.value`;
await expect(
execute('data', {
options: { destinationFieldName },
}),
).rejects.toThrow(
`The output field "${destinationFieldName}" contains the reserved property "${reservedProperty}"`,
);
},
);
it.each([
['version 1', { typeVersion: 1, options: {} }],
['disabled dot notation', { typeVersion: 1.1, options: { disableDotNotation: true } }],
] as const)('should reject a reserved literal destination in %s', async (_, config) => {
await expect(
execute('data', {
...config,
options: { ...config.options, destinationFieldName: 'constructor' },
}),
).rejects.toThrow(
'The output field "constructor" contains the reserved property "constructor"',
);
});
it('should preserve literal destination fields in version 1', async () => {
await expect(
execute('data', {
options: { destinationFieldName: 'output.item' },
typeVersion: 1,
}),
).resolves.toEqual([
[
{ json: { 'output.item': { id: 1 } }, pairedItem: { item: 0 } },
{ json: { 'output.item': { id: 2 } }, pairedItem: { item: 0 } },
],
]);
});
it('should preserve included fields under the destination parent', async () => {
await expect(
execute('data.items', {
inputData: { data: { items: [1, 2], keep: 'value' } },
options: { destinationFieldName: 'data.item' },
include: 'allOtherFields',
}),
).resolves.toEqual([
[
{ json: { data: { keep: 'value', item: 1 } }, pairedItem: { item: 0 } },
{ json: { data: { keep: 'value', item: 2 } }, pairedItem: { item: 0 } },
],
]);
});
it('should not mutate input data when destination paths overlap', async () => {
const inputData = {
left: [{ keep: true }],
right: [1],
};
await expect(
execute('left, right', {
inputData,
options: { destinationFieldName: 'output, output.value' },
}),
).resolves.toEqual([
[
{
json: { output: { keep: true, value: 1 } },
pairedItem: { item: 0 },
},
],
]);
expect(inputData.left).toEqual([{ keep: true }]);
});
it('should not mutate selected fields under the destination parent', async () => {
const inputData = {
items: [1],
output: { keep: true },
};
await expect(
execute('items', {
inputData,
options: { destinationFieldName: 'output.value' },
include: 'selectedOtherFields',
fieldsToInclude: 'output',
}),
).resolves.toEqual([
[
{
json: { output: { keep: true, value: 1 } },
pairedItem: { item: 0 },
},
],
]);
expect(inputData.output).toEqual({ keep: true });
});
});