mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(Split Out Node): Support dot notation in destination fields (#39661)
This commit is contained in:
parent
ce295b6080
commit
f9dbd2c8d6
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user