Skip to content

Commit c855c22

Browse files
authored
Merge pull request #275 from SnakeO/feature/workflow-string-keys
feature: Add string-based node keys support to Workflow system
2 parents b1d55a9 + f6f5e66 commit c855c22

File tree

3 files changed

+274
-9
lines changed

3 files changed

+274
-9
lines changed

src/Workflow/Exporter/MermaidExporter.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ public function export(Workflow $graph): string
2525

2626
private function getShortClassName(string $class): string
2727
{
28-
$reflection = new ReflectionClass($class);
29-
return $reflection->getShortName();
28+
// Check if it's a class name (contains namespace separator) and class exists
29+
if (strpos($class, '\\') !== false && class_exists($class)) {
30+
$reflection = new ReflectionClass($class);
31+
return $reflection->getShortName();
32+
}
33+
34+
// Otherwise, it's a custom string key, use it directly
35+
return $class;
3036
}
3137
}

src/Workflow/Workflow.php

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,19 +216,31 @@ protected function edges(): array
216216
return [];
217217
}
218218

219-
public function addNode(NodeInterface $node): self
219+
public function addNode(NodeInterface $node, ?string $key = null): self
220220
{
221-
$this->nodes[$node::class] = $node;
221+
$nodeKey = $key ?? $node::class;
222+
$this->nodes[$nodeKey] = $node;
222223
return $this;
223224
}
224225

225226
/**
226-
* @param NodeInterface[] $nodes
227+
* @param NodeInterface[]|array<string, NodeInterface> $nodes
227228
*/
228229
public function addNodes(array $nodes): Workflow
229230
{
230-
foreach ($nodes as $node) {
231-
$this->addNode($node);
231+
// Check if it's an associative array
232+
$isAssociative = count(array_filter(array_keys($nodes), 'is_string')) > 0;
233+
234+
if ($isAssociative) {
235+
// If associative, use the keys
236+
foreach ($nodes as $key => $node) {
237+
$this->addNode($node, $key);
238+
}
239+
} else {
240+
// If indexed, use class names as keys
241+
foreach ($nodes as $node) {
242+
$this->addNode($node);
243+
}
232244
}
233245
return $this;
234246
}
@@ -239,8 +251,20 @@ public function addNodes(array $nodes): Workflow
239251
public function getNodes(): array
240252
{
241253
if ($this->nodes === []) {
242-
foreach ($this->nodes() as $node) {
243-
$this->addNode($node);
254+
$nodeDefinitions = $this->nodes();
255+
256+
// Check if it's an associative array (has string keys)
257+
// An associative array has string keys or non-sequential numeric keys
258+
$isAssociative = count(array_filter(array_keys($nodeDefinitions), 'is_string')) > 0;
259+
260+
if ($isAssociative) {
261+
// New behavior: use provided keys directly
262+
$this->nodes = $nodeDefinitions;
263+
} else {
264+
// Old behavior: use class names as keys
265+
foreach ($nodeDefinitions as $node) {
266+
$this->addNode($node);
267+
}
244268
}
245269
}
246270

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace NeuronAI\Tests\Workflow;
6+
7+
use NeuronAI\Workflow\Edge;
8+
use NeuronAI\Workflow\Node;
9+
use NeuronAI\Workflow\Workflow;
10+
use NeuronAI\Workflow\WorkflowState;
11+
use PHPUnit\Framework\TestCase;
12+
13+
// Calculator nodes that can be reused with different values
14+
class AddNode extends Node
15+
{
16+
public function __construct(private int $value)
17+
{
18+
}
19+
20+
public function run(WorkflowState $state): WorkflowState
21+
{
22+
$current = $state->get('value', 0);
23+
$state->set('value', $current + $this->value);
24+
$history = $state->get('history', []);
25+
$history[] = "Added {$this->value}";
26+
$state->set('history', $history);
27+
return $state;
28+
}
29+
}
30+
31+
class MultiplyNode extends Node
32+
{
33+
public function __construct(private int $value)
34+
{
35+
}
36+
37+
public function run(WorkflowState $state): WorkflowState
38+
{
39+
$current = $state->get('value', 0);
40+
$state->set('value', $current * $this->value);
41+
$history = $state->get('history', []);
42+
$history[] = "Multiplied by {$this->value}";
43+
$state->set('history', $history);
44+
return $state;
45+
}
46+
}
47+
48+
class SubtractNode extends Node
49+
{
50+
public function __construct(private int $value)
51+
{
52+
}
53+
54+
public function run(WorkflowState $state): WorkflowState
55+
{
56+
$current = $state->get('value', 0);
57+
$state->set('value', $current - $this->value);
58+
$history = $state->get('history', []);
59+
$history[] = "Subtracted {$this->value}";
60+
$state->set('history', $history);
61+
return $state;
62+
}
63+
}
64+
65+
class FinishEvenNode extends Node
66+
{
67+
public function run(WorkflowState $state): WorkflowState
68+
{
69+
$state->set('result_type', 'even');
70+
return $state;
71+
}
72+
}
73+
74+
class FinishOddNode extends Node
75+
{
76+
public function run(WorkflowState $state): WorkflowState
77+
{
78+
$state->set('result_type', 'odd');
79+
return $state;
80+
}
81+
}
82+
83+
// Test workflow that uses string keys
84+
class CalculatorWorkflow extends Workflow
85+
{
86+
public function nodes(): array
87+
{
88+
return [
89+
'add1' => new AddNode(1),
90+
'multiply3_first' => new MultiplyNode(3),
91+
'multiply3_second' => new MultiplyNode(3),
92+
'sub1' => new SubtractNode(1),
93+
'finish_even' => new FinishEvenNode(),
94+
'finish_odd' => new FinishOddNode()
95+
];
96+
}
97+
98+
public function edges(): array
99+
{
100+
return [
101+
// ((startingValue + 1) * 3) * 3) - 1
102+
new Edge('add1', 'multiply3_first'),
103+
new Edge('multiply3_first', 'multiply3_second'),
104+
new Edge('multiply3_second', 'sub1'),
105+
106+
// Branch based on even/odd
107+
new Edge('sub1', 'finish_even', fn($state) => $state->get('value') % 2 === 0),
108+
new Edge('sub1', 'finish_odd', fn($state) => $state->get('value') % 2 !== 0)
109+
];
110+
}
111+
112+
protected function start(): string
113+
{
114+
return 'add1';
115+
}
116+
117+
protected function end(): array
118+
{
119+
return ['finish_even', 'finish_odd'];
120+
}
121+
}
122+
123+
class WorkflowStringKeysTest extends TestCase
124+
{
125+
public function test_workflow_with_string_keys(): void
126+
{
127+
$workflow = new CalculatorWorkflow();
128+
129+
// Test with initial value 2: ((2 + 1) * 3) * 3) - 1 = 26 (even)
130+
$initialState = new WorkflowState(['value' => 2]);
131+
$result = $workflow->run($initialState);
132+
133+
$this->assertEquals(26, $result->get('value'));
134+
$this->assertEquals('even', $result->get('result_type'));
135+
$this->assertContains('Added 1', $result->get('history'));
136+
$this->assertContains('Multiplied by 3', $result->get('history'));
137+
$this->assertContains('Subtracted 1', $result->get('history'));
138+
}
139+
140+
public function test_workflow_with_string_keys_odd_result(): void
141+
{
142+
$workflow = new CalculatorWorkflow();
143+
144+
// Test with initial value 1: ((1 + 1) * 3) * 3) - 1 = 17 (odd)
145+
$initialState = new WorkflowState(['value' => 1]);
146+
$result = $workflow->run($initialState);
147+
148+
$this->assertEquals(17, $result->get('value'));
149+
$this->assertEquals('odd', $result->get('result_type'));
150+
}
151+
152+
public function test_programmatic_workflow_with_string_keys(): void
153+
{
154+
$workflow = new Workflow();
155+
$workflow->addNodes([
156+
'add1' => new AddNode(1),
157+
'multiply2' => new MultiplyNode(2),
158+
'finish_even' => new FinishEvenNode(),
159+
'finish_odd' => new FinishOddNode()
160+
])
161+
->addEdges([
162+
new Edge('add1', 'multiply2'),
163+
new Edge('multiply2', 'finish_even', fn($state) => $state->get('value') % 2 === 0),
164+
new Edge('multiply2', 'finish_odd', fn($state) => $state->get('value') % 2 !== 0)
165+
])
166+
->setStart('add1')
167+
->setEnd('finish_even')
168+
->setEnd('finish_odd');
169+
170+
// Test with initial value 3: (3 + 1) * 2 = 8 (even)
171+
$initialState = new WorkflowState(['value' => 3]);
172+
$result = $workflow->run($initialState);
173+
174+
$this->assertEquals(8, $result->get('value'));
175+
$this->assertEquals('even', $result->get('result_type'));
176+
}
177+
178+
public function test_mermaid_export_with_string_keys(): void
179+
{
180+
$workflow = new Workflow();
181+
$workflow->addNodes([
182+
'start' => new AddNode(1),
183+
'middle' => new MultiplyNode(2),
184+
'finish' => new FinishEvenNode()
185+
])
186+
->addEdges([
187+
new Edge('start', 'middle'),
188+
new Edge('middle', 'finish')
189+
])
190+
->setStart('start')
191+
->setEnd('finish');
192+
193+
$export = $workflow->export();
194+
195+
$this->assertStringContainsString('start --> middle', $export);
196+
$this->assertStringContainsString('middle --> finish', $export);
197+
}
198+
199+
public function test_backward_compatibility_with_class_names(): void
200+
{
201+
// This test ensures the old behavior still works
202+
$workflow = new Workflow();
203+
$workflow->addNode(new StartNode())
204+
->addNode(new FinishNode())
205+
->addEdge(new Edge(StartNode::class, FinishNode::class))
206+
->setStart(StartNode::class)
207+
->setEnd(FinishNode::class);
208+
209+
$result = $workflow->run();
210+
211+
$this->assertEquals('end', $result->get('step'));
212+
}
213+
214+
public function test_mixed_mode_nodes_and_edges(): void
215+
{
216+
// Test mixing both approaches - indexed array with class name edges
217+
$workflow = new Workflow();
218+
$workflow->addNodes([
219+
new StartNode(),
220+
new MiddleNode(),
221+
new FinishNode()
222+
])
223+
->addEdges([
224+
new Edge(StartNode::class, MiddleNode::class),
225+
new Edge(MiddleNode::class, FinishNode::class)
226+
])
227+
->setStart(StartNode::class)
228+
->setEnd(FinishNode::class);
229+
230+
$result = $workflow->run();
231+
232+
$this->assertEquals('end', $result->get('step'));
233+
$this->assertEquals(1, $result->get('counter'));
234+
}
235+
}

0 commit comments

Comments
 (0)