diff --git a/.changeset/pink-roses-admire.md b/.changeset/pink-roses-admire.md new file mode 100644 index 00000000..46670ab7 --- /dev/null +++ b/.changeset/pink-roses-admire.md @@ -0,0 +1,12 @@ +--- +"@tokens-studio/graph-engine": minor +--- + +Added new array operation nodes: +- `unique`: Removes duplicate elements from an array +- `intersection`: Returns common elements between two arrays +- `union`: Combines arrays and removes duplicates +- `difference`: Returns elements in first array not in second array +- `shuffle`: Randomly reorders array elements using Fisher-Yates algorithm + +Each node includes comprehensive tests and supports both primitive and object arrays while preserving object references. \ No newline at end of file diff --git a/packages/graph-engine/src/nodes/array/difference.ts b/packages/graph-engine/src/nodes/array/difference.ts new file mode 100644 index 00000000..3c075a49 --- /dev/null +++ b/packages/graph-engine/src/nodes/array/difference.ts @@ -0,0 +1,47 @@ +import { AnyArraySchema } from '../../schemas/index.js'; +import { INodeDefinition, Node } from '../../programmatic/node.js'; +import { ToInput, ToOutput } from '../../programmatic/index.js'; + +export default class NodeDefinition extends Node { + static title = 'Array Difference'; + static type = 'studio.tokens.array.difference'; + static description = + 'Returns elements in first array that are not in second array'; + + declare inputs: ToInput<{ + a: T[]; + b: T[]; + }>; + + declare outputs: ToOutput<{ + value: T[]; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('a', { + type: AnyArraySchema + }); + this.addInput('b', { + type: AnyArraySchema + }); + this.addOutput('value', { + type: AnyArraySchema + }); + } + + execute(): void | Promise { + const { a, b } = this.getAllInputs(); + + //Verify types + if (this.inputs.a.type.$id !== this.inputs.b.type.$id) { + throw new Error('Array types must match'); + } + + // Create set from second array for efficient lookup + const setB = new Set(b); + const difference = a.filter(item => !setB.has(item)); + + this.outputs.value.set(difference, this.inputs.a.type); + } +} diff --git a/packages/graph-engine/src/nodes/array/index.ts b/packages/graph-engine/src/nodes/array/index.ts index 38db97a9..fac74f6a 100644 --- a/packages/graph-engine/src/nodes/array/index.ts +++ b/packages/graph-engine/src/nodes/array/index.ts @@ -1,33 +1,43 @@ import arraySubgraph from './arraySubgraph.js'; import arrify from './arrify.js'; import concat from './concat.js'; +import difference from './difference.js'; import filter from './filter.js'; import find from './find.js'; import flatten from './flatten.js'; import indexArray from './indexArray.js'; import inject from './inject.js'; +import intersection from './intersection.js'; import length from './length.js'; import push from './push.js'; import remove from './remove.js'; import replace from './replace.js'; import reverse from './reverse.js'; +import shuffle from './shuffle.js'; import slice from './slice.js'; import sort from './sort.js'; +import union from './union.js'; +import unique from './unique.js'; export const nodes = [ arraySubgraph, arrify, concat, + difference, filter, find, flatten, indexArray, inject, + intersection, length, push, remove, replace, reverse, + shuffle, slice, - sort + sort, + union, + unique ]; diff --git a/packages/graph-engine/src/nodes/array/intersection.ts b/packages/graph-engine/src/nodes/array/intersection.ts new file mode 100644 index 00000000..8f7a1860 --- /dev/null +++ b/packages/graph-engine/src/nodes/array/intersection.ts @@ -0,0 +1,46 @@ +import { AnyArraySchema } from '../../schemas/index.js'; +import { INodeDefinition, Node } from '../../programmatic/node.js'; +import { ToInput, ToOutput } from '../../programmatic/index.js'; + +export default class NodeDefinition extends Node { + static title = 'Array Intersection'; + static type = 'studio.tokens.array.intersection'; + static description = 'Returns common elements between two arrays'; + + declare inputs: ToInput<{ + a: T[]; + b: T[]; + }>; + + declare outputs: ToOutput<{ + value: T[]; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('a', { + type: AnyArraySchema + }); + this.addInput('b', { + type: AnyArraySchema + }); + this.addOutput('value', { + type: AnyArraySchema + }); + } + + execute(): void | Promise { + const { a, b } = this.getAllInputs(); + + //Verify types + if (this.inputs.a.type.$id !== this.inputs.b.type.$id) { + throw new Error('Array types must match'); + } + + // Create sets for efficient lookup + const setB = new Set(b); + const intersection = a.filter(item => setB.has(item)); + + this.outputs.value.set(intersection, this.inputs.a.type); + } +} diff --git a/packages/graph-engine/src/nodes/array/shuffle.ts b/packages/graph-engine/src/nodes/array/shuffle.ts new file mode 100644 index 00000000..97f4316c --- /dev/null +++ b/packages/graph-engine/src/nodes/array/shuffle.ts @@ -0,0 +1,43 @@ +import { AnyArraySchema } from '../../schemas/index.js'; +import { INodeDefinition, Node } from '../../programmatic/node.js'; +import { ToInput, ToOutput } from '../../programmatic/index.js'; + +export default class NodeDefinition extends Node { + static title = 'Shuffle Array'; + static type = 'studio.tokens.array.shuffle'; + static description = + 'Randomly reorders elements in an array using Fisher-Yates algorithm'; + + declare inputs: ToInput<{ + array: T[]; + }>; + + declare outputs: ToOutput<{ + value: T[]; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('array', { + type: AnyArraySchema + }); + this.addOutput('value', { + type: AnyArraySchema + }); + } + + execute(): void | Promise { + const { array } = this.getAllInputs(); + + // Create a copy of the array to avoid mutating the input + const shuffled = [...array]; + + // Fisher-Yates shuffle algorithm + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + this.outputs.value.set(shuffled, this.inputs.array.type); + } +} diff --git a/packages/graph-engine/src/nodes/array/union.ts b/packages/graph-engine/src/nodes/array/union.ts new file mode 100644 index 00000000..65774973 --- /dev/null +++ b/packages/graph-engine/src/nodes/array/union.ts @@ -0,0 +1,45 @@ +import { AnyArraySchema } from '../../schemas/index.js'; +import { INodeDefinition, Node } from '../../programmatic/node.js'; +import { ToInput, ToOutput } from '../../programmatic/index.js'; + +export default class NodeDefinition extends Node { + static title = 'Array Union'; + static type = 'studio.tokens.array.union'; + static description = 'Combines two arrays and removes duplicates'; + + declare inputs: ToInput<{ + a: T[]; + b: T[]; + }>; + + declare outputs: ToOutput<{ + value: T[]; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('a', { + type: AnyArraySchema + }); + this.addInput('b', { + type: AnyArraySchema + }); + this.addOutput('value', { + type: AnyArraySchema + }); + } + + execute(): void | Promise { + const { a, b } = this.getAllInputs(); + + //Verify types + if (this.inputs.a.type.$id !== this.inputs.b.type.$id) { + throw new Error('Array types must match'); + } + + // Combine arrays and remove duplicates using Set + const union = [...new Set([...a, ...b])]; + + this.outputs.value.set(union, this.inputs.a.type); + } +} diff --git a/packages/graph-engine/src/nodes/array/unique.ts b/packages/graph-engine/src/nodes/array/unique.ts new file mode 100644 index 00000000..8eb99e1f --- /dev/null +++ b/packages/graph-engine/src/nodes/array/unique.ts @@ -0,0 +1,34 @@ +import { AnyArraySchema } from '../../schemas/index.js'; +import { INodeDefinition, Node } from '../../programmatic/node.js'; +import { ToInput, ToOutput } from '../../programmatic/index.js'; + +export default class NodeDefinition extends Node { + static title = 'Unique Array'; + static type = 'studio.tokens.array.unique'; + static description = 'Removes duplicate elements from an array'; + + declare inputs: ToInput<{ + array: T[]; + }>; + + declare outputs: ToOutput<{ + value: T[]; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('array', { + type: AnyArraySchema + }); + this.addOutput('value', { + type: AnyArraySchema + }); + } + + execute(): void | Promise { + const { array } = this.getAllInputs(); + // Use Set to remove duplicates and convert back to array + const uniqueArray = [...new Set(array)]; + this.outputs.value.set(uniqueArray, this.inputs.array.type); + } +} diff --git a/packages/graph-engine/tests/suites/nodes/array/difference.test.ts b/packages/graph-engine/tests/suites/nodes/array/difference.test.ts new file mode 100644 index 00000000..1bedd131 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/array/difference.test.ts @@ -0,0 +1,77 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node from '../../../../src/nodes/array/difference.js'; + +describe('array/difference', () => { + test('returns elements in first array not in second', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const a = [1, 2, 3, 4]; + const b = [3, 4, 5, 6]; + + node.inputs.a.setValue(a); + node.inputs.b.setValue(b); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.eql([1, 2]); + // Ensure original arrays weren't modified + expect(a).to.eql([1, 2, 3, 4]); + expect(b).to.eql([3, 4, 5, 6]); + }); + + test('handles arrays with objects', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + + const a = [obj1, obj2, obj3]; + const b = [obj2, obj3]; + + node.inputs.a.setValue(a); + node.inputs.b.setValue(b); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.have.length(1); + expect(actual[0]).to.equal(obj1); + }); + + test('handles empty arrays', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.a.setValue([1, 2, 3]); + node.inputs.b.setValue([]); + + await node.execute(); + + expect(node.outputs.value.value).to.eql([1, 2, 3]); + + // Test with empty first array + node.inputs.a.setValue([]); + node.inputs.b.setValue([1, 2, 3]); + + await node.execute(); + + expect(node.outputs.value.value).to.eql([]); + }); + + test('throws error when array types do not match', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.a.setValue([1, 2, 3]); + node.inputs.b.setValue(['a', 'b', 'c']); + + await expect(node.execute()).rejects.toThrow('Array types must match'); + }); +}); diff --git a/packages/graph-engine/tests/suites/nodes/array/intersection.test.ts b/packages/graph-engine/tests/suites/nodes/array/intersection.test.ts new file mode 100644 index 00000000..0425c655 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/array/intersection.test.ts @@ -0,0 +1,70 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node from '../../../../src/nodes/array/intersection.js'; + +describe('array/intersection', () => { + test('finds common elements between arrays', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const a = [1, 2, 3, 4]; + const b = [3, 4, 5, 6]; + + node.inputs.a.setValue(a); + node.inputs.b.setValue(b); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.eql([3, 4]); + // Ensure original arrays weren't modified + expect(a).to.eql([1, 2, 3, 4]); + expect(b).to.eql([3, 4, 5, 6]); + }); + + test('handles arrays with objects', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + + const a = [obj1, obj2, obj3]; + const b = [obj2, obj3]; + + node.inputs.a.setValue(a); + node.inputs.b.setValue(b); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.have.length(2); + expect(actual).to.include(obj2); + expect(actual).to.include(obj3); + }); + + test('handles empty arrays', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.a.setValue([1, 2, 3]); + node.inputs.b.setValue([]); + + await node.execute(); + + expect(node.outputs.value.value).to.eql([]); + }); + + test('throws error when array types do not match', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.a.setValue([1, 2, 3]); + node.inputs.b.setValue(['a', 'b', 'c']); + + await expect(node.execute()).rejects.toThrow('Array types must match'); + }); +}); diff --git a/packages/graph-engine/tests/suites/nodes/array/shuffle.test.ts b/packages/graph-engine/tests/suites/nodes/array/shuffle.test.ts new file mode 100644 index 00000000..c8925fa4 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/array/shuffle.test.ts @@ -0,0 +1,73 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node from '../../../../src/nodes/array/shuffle.js'; + +describe('array/shuffle', () => { + test('shuffles array elements', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + node.inputs.array.setValue(input); + + await node.execute(); + + const actual = node.outputs.value.value; + + // Length should be the same + expect(actual).to.have.length(input.length); + // All elements should still be present + expect(actual.sort()).to.eql(input.sort()); + // Array should be shuffled (this could theoretically fail but is extremely unlikely) + expect(actual).to.not.eql(input); + // Original array should not be modified + expect(input).to.eql([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + }); + + test('handles array with objects', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + const input = [obj1, obj2, obj3]; + + node.inputs.array.setValue(input); + + await node.execute(); + + const actual = node.outputs.value.value as typeof input; + + // Length should be the same + expect(actual).to.have.length(input.length); + // Should contain all original objects + expect(actual).to.include(obj1); + expect(actual).to.include(obj2); + expect(actual).to.include(obj3); + // References should be preserved + expect(actual.find(o => o.id === 1)).to.equal(obj1); + }); + + test('handles empty array', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.array.setValue([]); + + await node.execute(); + + expect(node.outputs.value.value).to.eql([]); + }); + + test('handles single element array', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.array.setValue([1]); + + await node.execute(); + + expect(node.outputs.value.value).to.eql([1]); + }); +}); diff --git a/packages/graph-engine/tests/suites/nodes/array/union.test.ts b/packages/graph-engine/tests/suites/nodes/array/union.test.ts new file mode 100644 index 00000000..f1e41460 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/array/union.test.ts @@ -0,0 +1,71 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node from '../../../../src/nodes/array/union.js'; + +describe('array/union', () => { + test('combines arrays and removes duplicates', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const a = [1, 2, 3, 4]; + const b = [3, 4, 5, 6]; + + node.inputs.a.setValue(a); + node.inputs.b.setValue(b); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.eql([1, 2, 3, 4, 5, 6]); + // Ensure original arrays weren't modified + expect(a).to.eql([1, 2, 3, 4]); + expect(b).to.eql([3, 4, 5, 6]); + }); + + test('handles arrays with objects', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + + const a = [obj1, obj2]; + const b = [obj2, obj3]; + + node.inputs.a.setValue(a); + node.inputs.b.setValue(b); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.have.length(3); + expect(actual).to.include(obj1); + expect(actual).to.include(obj2); + expect(actual).to.include(obj3); + }); + + test('handles empty arrays', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.a.setValue([1, 2, 3]); + node.inputs.b.setValue([]); + + await node.execute(); + + expect(node.outputs.value.value).to.eql([1, 2, 3]); + }); + + test('throws error when array types do not match', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.a.setValue([1, 2, 3]); + node.inputs.b.setValue(['a', 'b', 'c']); + + await expect(node.execute()).rejects.toThrow('Array types must match'); + }); +}); diff --git a/packages/graph-engine/tests/suites/nodes/array/unique.test.ts b/packages/graph-engine/tests/suites/nodes/array/unique.test.ts new file mode 100644 index 00000000..5ef2a248 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/array/unique.test.ts @@ -0,0 +1,52 @@ +import { Graph } from '../../../../src/graph/graph.js'; +import { describe, expect, test } from 'vitest'; +import Node from '../../../../src/nodes/array/unique.js'; + +describe('array/unique', () => { + test('removes duplicates from array', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const input = [1, 2, 2, 3, 3, 4, 4, 5]; + node.inputs.array.setValue(input); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.eql([1, 2, 3, 4, 5]); + // Ensure original array wasn't modified + expect(input).to.eql([1, 2, 2, 3, 3, 4, 4, 5]); + }); + + test('handles array with objects', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const input = [obj1, obj2, obj1, obj2]; + node.inputs.array.setValue(input); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.have.length(2); + expect(actual[0]).to.equal(obj1); + expect(actual[1]).to.equal(obj2); + }); + + test('handles empty array', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.array.setValue([]); + + await node.execute(); + + const actual = node.outputs.value.value; + + expect(actual).to.eql([]); + }); +});