mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat(core): Augment data instead of copying it (#5487)
This commit is contained in:
parent
ca91d2b712
commit
0876c38aae
143
packages/workflow/src/AugmentObject.ts
Normal file
143
packages/workflow/src/AugmentObject.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import type { IDataObject } from './Interfaces';
|
||||
import util from 'util';
|
||||
|
||||
export function augmentArray<T>(data: T[]): T[] {
|
||||
let newData: unknown[] | undefined = undefined;
|
||||
|
||||
function getData(): unknown[] {
|
||||
if (newData === undefined) {
|
||||
newData = [...data];
|
||||
}
|
||||
return newData;
|
||||
}
|
||||
|
||||
return new Proxy(data, {
|
||||
deleteProperty(target, key: string) {
|
||||
return Reflect.deleteProperty(getData(), key);
|
||||
},
|
||||
get(target, key: string, receiver): unknown {
|
||||
const value = Reflect.get(newData !== undefined ? newData : target, key, receiver) as unknown;
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (value === null || util.types.isProxy(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
newData = getData();
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
Reflect.set(newData, key, augmentArray(value));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
Reflect.set(newData, key, augmentObject(value as IDataObject));
|
||||
}
|
||||
|
||||
return Reflect.get(newData, key);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (newData === undefined) {
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
}
|
||||
|
||||
if (key === 'length') {
|
||||
return Reflect.getOwnPropertyDescriptor(newData, key);
|
||||
}
|
||||
return { configurable: true, enumerable: true };
|
||||
},
|
||||
has(target, key) {
|
||||
return Reflect.has(newData !== undefined ? newData : target, key);
|
||||
},
|
||||
ownKeys(target) {
|
||||
return Reflect.ownKeys(newData !== undefined ? newData : target);
|
||||
},
|
||||
set(target, key: string, newValue: unknown) {
|
||||
if (newValue !== null && typeof newValue === 'object') {
|
||||
// Always proxy all objects. Like that we can check in get simply if it
|
||||
// is a proxy and it does then not matter if it was already there from the
|
||||
// beginning and it got proxied at some point or set later and so theoretically
|
||||
// does not have to get proxied
|
||||
newValue = new Proxy(newValue, {});
|
||||
}
|
||||
|
||||
return Reflect.set(getData(), key, newValue);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function augmentObject<T extends object>(data: T): T {
|
||||
const newData = {} as IDataObject;
|
||||
const deletedProperties: Array<string | symbol> = [];
|
||||
|
||||
return new Proxy(data, {
|
||||
get(target, key: string, receiver): unknown {
|
||||
if (deletedProperties.indexOf(key) !== -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (newData[key] !== undefined) {
|
||||
return newData[key];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const value = Reflect.get(target, key, receiver);
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
if (Array.isArray(value)) {
|
||||
newData[key] = augmentArray(value);
|
||||
} else {
|
||||
newData[key] = augmentObject(value as IDataObject);
|
||||
}
|
||||
|
||||
return newData[key];
|
||||
}
|
||||
|
||||
return value as string;
|
||||
},
|
||||
deleteProperty(target, key: string) {
|
||||
if (key in newData) {
|
||||
delete newData[key];
|
||||
}
|
||||
if (key in target) {
|
||||
deletedProperties.push(key);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
set(target, key: string, newValue: unknown) {
|
||||
if (newValue === undefined) {
|
||||
if (key in newData) {
|
||||
delete newData[key];
|
||||
}
|
||||
if (key in target) {
|
||||
deletedProperties.push(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
newData[key] = newValue as IDataObject;
|
||||
|
||||
const deleteIndex = deletedProperties.indexOf(key);
|
||||
if (deleteIndex !== -1) {
|
||||
deletedProperties.splice(deleteIndex, 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
ownKeys(target) {
|
||||
return [...new Set([...Reflect.ownKeys(target), ...Object.keys(newData)])].filter(
|
||||
(key) => deletedProperties.indexOf(key) === -1,
|
||||
);
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getOwnPropertyDescriptor(k) {
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
|
@ -28,7 +28,7 @@ import type {
|
|||
import * as NodeHelpers from './NodeHelpers';
|
||||
import { ExpressionError } from './ExpressionError';
|
||||
import type { Workflow } from './Workflow';
|
||||
import { deepCopy } from './utils';
|
||||
import { augmentArray, augmentObject } from './AugmentObject';
|
||||
|
||||
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
|
||||
return Boolean(
|
||||
|
@ -96,11 +96,13 @@ export class WorkflowDataProxy {
|
|||
this.workflow = workflow;
|
||||
|
||||
this.runExecutionData = isScriptingNode(activeNodeName, workflow)
|
||||
? deepCopy(runExecutionData)
|
||||
? runExecutionData !== null
|
||||
? augmentObject(runExecutionData)
|
||||
: null
|
||||
: runExecutionData;
|
||||
|
||||
this.connectionInputData = isScriptingNode(activeNodeName, workflow)
|
||||
? deepCopy(connectionInputData)
|
||||
? augmentArray(connectionInputData)
|
||||
: connectionInputData;
|
||||
|
||||
this.defaultReturnRunIndex = defaultReturnRunIndex;
|
||||
|
|
518
packages/workflow/test/AugmentObject.test.ts
Normal file
518
packages/workflow/test/AugmentObject.test.ts
Normal file
|
@ -0,0 +1,518 @@
|
|||
import type { IDataObject } from '@/Interfaces';
|
||||
import { augmentArray, augmentObject } from '@/AugmentObject';
|
||||
import { deepCopy } from '@/utils';
|
||||
|
||||
describe('AugmentObject', () => {
|
||||
describe('augmentArray', () => {
|
||||
test('should work with arrays', () => {
|
||||
const originalObject = [1, 2, 3, 4, null];
|
||||
const copyOriginal = JSON.parse(JSON.stringify(originalObject));
|
||||
|
||||
const augmentedObject = augmentArray(originalObject);
|
||||
|
||||
expect(augmentedObject.push(5)).toEqual(6);
|
||||
expect(augmentedObject).toEqual([1, 2, 3, 4, null, 5]);
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject.pop()).toEqual(5);
|
||||
expect(augmentedObject).toEqual([1, 2, 3, 4, null]);
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject.shift()).toEqual(1);
|
||||
expect(augmentedObject).toEqual([2, 3, 4, null]);
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject.unshift(1)).toEqual(5);
|
||||
expect(augmentedObject).toEqual([1, 2, 3, 4, null]);
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject.splice(1, 1)).toEqual([2]);
|
||||
expect(augmentedObject).toEqual([1, 3, 4, null]);
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject.slice(1)).toEqual([3, 4, null]);
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject.reverse()).toEqual([null, 4, 3, 1]);
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
});
|
||||
|
||||
test('should work with arrays on any level', () => {
|
||||
const originalObject = {
|
||||
a: {
|
||||
b: {
|
||||
c: [
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: '03' as string | null,
|
||||
},
|
||||
},
|
||||
aa3: '01',
|
||||
},
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: '13',
|
||||
},
|
||||
},
|
||||
aa3: '11',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
aa: [
|
||||
{
|
||||
a3: {
|
||||
b3: '2',
|
||||
},
|
||||
aa3: '1',
|
||||
},
|
||||
],
|
||||
};
|
||||
const copyOriginal = JSON.parse(JSON.stringify(originalObject));
|
||||
|
||||
const augmentedObject = augmentObject(originalObject);
|
||||
|
||||
// On first level
|
||||
augmentedObject.aa[0].a3.b3 = '22';
|
||||
expect(augmentedObject.aa[0].a3.b3).toEqual('22');
|
||||
expect(originalObject.aa[0].a3.b3).toEqual('2');
|
||||
|
||||
// Make sure that also array operations as push and length work as expected
|
||||
// On lower levels
|
||||
augmentedObject.a.b.c[0].a3!.b3.c3 = '033';
|
||||
expect(augmentedObject.a.b.c[0].a3!.b3.c3).toEqual('033');
|
||||
expect(originalObject.a.b.c[0].a3!.b3.c3).toEqual('03');
|
||||
|
||||
augmentedObject.a.b.c[1].a3!.b3.c3 = '133';
|
||||
expect(augmentedObject.a.b.c[1].a3!.b3.c3).toEqual('133');
|
||||
expect(originalObject.a.b.c[1].a3!.b3.c3).toEqual('13');
|
||||
|
||||
augmentedObject.a.b.c.push({
|
||||
a3: {
|
||||
b3: {
|
||||
c3: '23',
|
||||
},
|
||||
},
|
||||
aa3: '21',
|
||||
});
|
||||
augmentedObject.a.b.c[2].a3.b3.c3 = '233';
|
||||
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual('233');
|
||||
|
||||
augmentedObject.a.b.c[2].a3.b3.c3 = '2333';
|
||||
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual('2333');
|
||||
|
||||
augmentedObject.a.b.c[2].a3.b3.c3 = null;
|
||||
expect(augmentedObject.a.b.c[2].a3.b3.c3).toEqual(null);
|
||||
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject.a.b.c.length).toEqual(3);
|
||||
|
||||
expect(augmentedObject.aa).toEqual([
|
||||
{
|
||||
a3: {
|
||||
b3: '22',
|
||||
},
|
||||
aa3: '1',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(augmentedObject.a.b.c).toEqual([
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: '033',
|
||||
},
|
||||
},
|
||||
aa3: '01',
|
||||
},
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: '133',
|
||||
},
|
||||
},
|
||||
aa3: '11',
|
||||
},
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: null,
|
||||
},
|
||||
},
|
||||
aa3: '21',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
c: [
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: '033',
|
||||
},
|
||||
},
|
||||
aa3: '01',
|
||||
},
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: '133',
|
||||
},
|
||||
},
|
||||
aa3: '11',
|
||||
},
|
||||
{
|
||||
a3: {
|
||||
b3: {
|
||||
c3: null,
|
||||
},
|
||||
},
|
||||
aa3: '21',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
aa: [
|
||||
{
|
||||
a3: {
|
||||
b3: '22',
|
||||
},
|
||||
aa3: '1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('augmentObject', () => {
|
||||
test('should work with simple values on first level', () => {
|
||||
const originalObject: IDataObject = {
|
||||
1: 11,
|
||||
2: '22',
|
||||
a: 111,
|
||||
b: '222',
|
||||
};
|
||||
const copyOriginal = JSON.parse(JSON.stringify(originalObject));
|
||||
|
||||
const augmentedObject = augmentObject(originalObject);
|
||||
|
||||
augmentedObject[1] = 911;
|
||||
expect(originalObject[1]).toEqual(11);
|
||||
expect(augmentedObject[1]).toEqual(911);
|
||||
|
||||
augmentedObject[2] = '922';
|
||||
expect(originalObject[2]).toEqual('22');
|
||||
expect(augmentedObject[2]).toEqual('922');
|
||||
|
||||
augmentedObject.a = 9111;
|
||||
expect(originalObject.a).toEqual(111);
|
||||
expect(augmentedObject.a).toEqual(9111);
|
||||
|
||||
augmentedObject.b = '9222';
|
||||
expect(originalObject.b).toEqual('222');
|
||||
expect(augmentedObject.b).toEqual('9222');
|
||||
|
||||
augmentedObject.c = 3;
|
||||
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject).toEqual({
|
||||
1: 911,
|
||||
2: '922',
|
||||
a: 9111,
|
||||
b: '9222',
|
||||
c: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('should work with simple values on sub-level', () => {
|
||||
const originalObject = {
|
||||
a: {
|
||||
b: {
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
};
|
||||
const copyOriginal = JSON.parse(JSON.stringify(originalObject));
|
||||
|
||||
const augmentedObject = augmentObject(originalObject);
|
||||
|
||||
augmentedObject.a.bb = '92';
|
||||
expect(originalObject.a.bb).toEqual('2');
|
||||
expect(augmentedObject.a!.bb!).toEqual('92');
|
||||
|
||||
augmentedObject.a!.b!.cc = '93';
|
||||
expect(originalObject.a.b.cc).toEqual('3');
|
||||
expect(augmentedObject.a!.b!.cc).toEqual('93');
|
||||
|
||||
// @ts-ignore
|
||||
augmentedObject.a!.b!.ccc = {
|
||||
d: '4',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
expect(augmentedObject.a!.b!.ccc).toEqual({ d: '4' });
|
||||
|
||||
// @ts-ignore
|
||||
augmentedObject.a!.b!.ccc.d = '94';
|
||||
// @ts-ignore
|
||||
expect(augmentedObject.a!.b!.ccc.d).toEqual('94');
|
||||
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
cc: '93',
|
||||
ccc: {
|
||||
d: '94',
|
||||
},
|
||||
},
|
||||
bb: '92',
|
||||
},
|
||||
aa: '1',
|
||||
});
|
||||
});
|
||||
|
||||
test('should work with complex values on first level', () => {
|
||||
const originalObject = {
|
||||
a: {
|
||||
b: {
|
||||
cc: '3',
|
||||
c2: null,
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
};
|
||||
const copyOriginal = JSON.parse(JSON.stringify(originalObject));
|
||||
|
||||
const augmentedObject = augmentObject(originalObject);
|
||||
|
||||
augmentedObject.a = { new: 'NEW' };
|
||||
expect(originalObject.a).toEqual({
|
||||
b: {
|
||||
c2: null,
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
});
|
||||
expect(augmentedObject.a).toEqual({ new: 'NEW' });
|
||||
|
||||
augmentedObject.aa = '11';
|
||||
expect(originalObject.aa).toEqual('1');
|
||||
expect(augmentedObject.aa).toEqual('11');
|
||||
|
||||
augmentedObject.aaa = {
|
||||
bbb: {
|
||||
ccc: '333',
|
||||
},
|
||||
};
|
||||
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
new: 'NEW',
|
||||
},
|
||||
aa: '11',
|
||||
aaa: {
|
||||
bbb: {
|
||||
ccc: '333',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should work with delete and reset', () => {
|
||||
const originalObject = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: '4' as string | undefined,
|
||||
} as { d?: string; dd?: string } | undefined,
|
||||
cc: '3' as string | undefined,
|
||||
},
|
||||
bb: '2' as string | undefined,
|
||||
},
|
||||
aa: '1' as string | undefined,
|
||||
};
|
||||
const copyOriginal = JSON.parse(JSON.stringify(originalObject));
|
||||
|
||||
const augmentedObject = augmentObject(originalObject);
|
||||
|
||||
// Remove multiple values
|
||||
delete augmentedObject.a.b.c!.d;
|
||||
expect(augmentedObject.a.b.c!.d).toEqual(undefined);
|
||||
expect(originalObject.a.b.c!.d).toEqual('4');
|
||||
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
c: {},
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
});
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
delete augmentedObject.a.b.c;
|
||||
expect(augmentedObject.a.b.c).toEqual(undefined);
|
||||
expect(originalObject.a.b.c).toEqual({ d: '4' });
|
||||
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
});
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
// Set deleted values again
|
||||
augmentedObject.a.b.c = { dd: '444' };
|
||||
expect(augmentedObject.a.b.c).toEqual({ dd: '444' });
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
augmentedObject.a.b.c.d = '44';
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: '44',
|
||||
dd: '444',
|
||||
},
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
});
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
});
|
||||
|
||||
// Is almost identical to above test
|
||||
test('should work with setting to undefined and reset', () => {
|
||||
const originalObject = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: '4' as string | undefined,
|
||||
} as { d?: string; dd?: string } | undefined,
|
||||
cc: '3' as string | undefined,
|
||||
},
|
||||
bb: '2' as string | undefined,
|
||||
},
|
||||
aa: '1' as string | undefined,
|
||||
};
|
||||
const copyOriginal = JSON.parse(JSON.stringify(originalObject));
|
||||
|
||||
const augmentedObject = augmentObject(originalObject);
|
||||
|
||||
// Remove multiple values
|
||||
augmentedObject.a.b.c!.d = undefined;
|
||||
expect(augmentedObject.a.b.c!.d).toEqual(undefined);
|
||||
expect(originalObject.a.b.c!.d).toEqual('4');
|
||||
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
c: {},
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
});
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
augmentedObject.a.b.c = undefined;
|
||||
expect(augmentedObject.a.b.c).toEqual(undefined);
|
||||
expect(originalObject.a.b.c).toEqual({ d: '4' });
|
||||
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
});
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
// Set deleted values again
|
||||
augmentedObject.a.b.c = { dd: '444' };
|
||||
expect(augmentedObject.a.b.c).toEqual({ dd: '444' });
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
|
||||
augmentedObject.a.b.c.d = '44';
|
||||
expect(augmentedObject).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: '44',
|
||||
dd: '444',
|
||||
},
|
||||
cc: '3',
|
||||
},
|
||||
bb: '2',
|
||||
},
|
||||
aa: '1',
|
||||
});
|
||||
expect(originalObject).toEqual(copyOriginal);
|
||||
});
|
||||
|
||||
test('should be faster than doing a deepCopy', () => {
|
||||
const iterations = 100;
|
||||
const originalObject: IDataObject = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: {
|
||||
e: {
|
||||
f: 12345,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
for (let i = 0; i < 10; i++) {
|
||||
originalObject[i.toString()] = deepCopy(originalObject);
|
||||
}
|
||||
|
||||
let startTime = new Date().getTime();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const augmentedObject = augmentObject(originalObject);
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
augmentedObject.a!.b.c.d.e.f++;
|
||||
}
|
||||
}
|
||||
const timeAugmented = new Date().getTime() - startTime;
|
||||
|
||||
startTime = new Date().getTime();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const copiedObject = deepCopy(originalObject);
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
copiedObject.a!.b.c.d.e.f++;
|
||||
}
|
||||
}
|
||||
const timeCopied = new Date().getTime() - startTime;
|
||||
|
||||
expect(timeAugmented).toBeLessThan(timeCopied);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue