Implerment depth-first pre-order workflow graph walker with plug-in node visitors
[MAILPOET-4629]
This commit is contained in:
@ -23,6 +23,7 @@ class Exceptions {
|
|||||||
private const SUBJECT_LOAD_FAILED = 'mailpoet_automation_workflow_subject_load_failed';
|
private const SUBJECT_LOAD_FAILED = 'mailpoet_automation_workflow_subject_load_failed';
|
||||||
private const MULTIPLE_SUBJECTS_FOUND = 'mailpoet_automation_multiple_subjects_found';
|
private const MULTIPLE_SUBJECTS_FOUND = 'mailpoet_automation_multiple_subjects_found';
|
||||||
private const WORKFLOW_STRUCTURE_MODIFICATION_NOT_SUPPORTED = 'mailpoet_automation_workflow_structure_modification_not_supported';
|
private const WORKFLOW_STRUCTURE_MODIFICATION_NOT_SUPPORTED = 'mailpoet_automation_workflow_structure_modification_not_supported';
|
||||||
|
private const WORKFLOW_STRUCTURE_NOT_VALID = 'mailpoet_automation_workflow_structure_not_valid';
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
throw new InvalidStateException(
|
throw new InvalidStateException(
|
||||||
@ -139,4 +140,11 @@ class Exceptions {
|
|||||||
->withErrorCode(self::WORKFLOW_STRUCTURE_MODIFICATION_NOT_SUPPORTED)
|
->withErrorCode(self::WORKFLOW_STRUCTURE_MODIFICATION_NOT_SUPPORTED)
|
||||||
->withMessage(__("Workflow structure modification not supported.", 'mailpoet'));
|
->withMessage(__("Workflow structure modification not supported.", 'mailpoet'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function workflowStructureNotValid(string $detail): UnexpectedValueException {
|
||||||
|
return UnexpectedValueException::create()
|
||||||
|
->withErrorCode(self::WORKFLOW_STRUCTURE_NOT_VALID)
|
||||||
|
// translators: %s is a detailed information
|
||||||
|
->withMessage(sprintf(__("Invalid workflow structure: %s", 'mailpoet'), $detail));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
<?php declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace MailPoet\Automation\Engine\Validation\WorkflowGraph;
|
||||||
|
|
||||||
|
use MailPoet\Automation\Engine\Data\Step;
|
||||||
|
|
||||||
|
class WorkflowNode {
|
||||||
|
/** @var Step */
|
||||||
|
private $step;
|
||||||
|
|
||||||
|
/** @var array */
|
||||||
|
private $parents;
|
||||||
|
|
||||||
|
/* @param Step[] $parents */
|
||||||
|
public function __construct(
|
||||||
|
Step $step,
|
||||||
|
array $parents
|
||||||
|
) {
|
||||||
|
$this->step = $step;
|
||||||
|
$this->parents = $parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStep(): Step {
|
||||||
|
return $this->step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Step[] */
|
||||||
|
public function getParents(): array {
|
||||||
|
return $this->parents;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
<?php declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace MailPoet\Automation\Engine\Validation\WorkflowGraph;
|
||||||
|
|
||||||
|
use MailPoet\Automation\Engine\Data\Workflow;
|
||||||
|
|
||||||
|
interface WorkflowNodeVisitor {
|
||||||
|
public function initialize(Workflow $workflow): void;
|
||||||
|
|
||||||
|
public function visitNode(Workflow $workflow, WorkflowNode $node): void;
|
||||||
|
|
||||||
|
public function complete(Workflow $workflow): void;
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
<?php declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace MailPoet\Automation\Engine\Validation\WorkflowGraph;
|
||||||
|
|
||||||
|
use Generator;
|
||||||
|
use MailPoet\Automation\Engine\Data\Step;
|
||||||
|
use MailPoet\Automation\Engine\Data\Workflow;
|
||||||
|
use MailPoet\Automation\Engine\Exceptions;
|
||||||
|
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
|
||||||
|
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
|
||||||
|
|
||||||
|
class WorkflowWalker {
|
||||||
|
/** @param WorkflowNodeVisitor[] $visitors */
|
||||||
|
public function walk(Workflow $workflow, array $visitors = []): void {
|
||||||
|
$steps = $workflow->getSteps();
|
||||||
|
$root = $steps['root'] ?? null;
|
||||||
|
if (!$root) {
|
||||||
|
throw Exceptions::workflowStructureNotValid(__("Workflow must contain a 'root' step", 'mailpoet'));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($visitors as $visitor) {
|
||||||
|
$visitor->initialize($workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->walkStepsDepthFirstPreOrder($steps, $root) as $record) {
|
||||||
|
[$step, $parents] = $record;
|
||||||
|
foreach ($visitors as $visitor) {
|
||||||
|
$visitor->visitNode($workflow, new WorkflowNode($step, array_values($parents)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($visitors as $visitor) {
|
||||||
|
$visitor->complete($workflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, Step> $steps
|
||||||
|
* @return Generator<array{0: Step, 1: array<string, Step>}>
|
||||||
|
*/
|
||||||
|
private function walkStepsDepthFirstPreOrder(array $steps, Step $root): Generator {
|
||||||
|
/** @var array{0: Step, 1: array<string, Step>}[] $stack */
|
||||||
|
$stack = [
|
||||||
|
[$root, []],
|
||||||
|
];
|
||||||
|
|
||||||
|
do {
|
||||||
|
$record = array_pop($stack);
|
||||||
|
if (!$record) {
|
||||||
|
throw new InvalidStateException();
|
||||||
|
}
|
||||||
|
yield $record;
|
||||||
|
[$step, $parents] = $record;
|
||||||
|
|
||||||
|
foreach (array_reverse($step->getNextSteps()) as $nextStepData) {
|
||||||
|
$nextStepId = $nextStepData->getId();
|
||||||
|
$nextStep = $steps[$nextStepId] ?? null;
|
||||||
|
if (!$nextStep) {
|
||||||
|
throw $this->createStepNotFoundException($nextStepId, $step->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextStepParents = array_merge($parents, [$step->getId() => $step]);
|
||||||
|
if (isset($nextStepParents[$nextStepId])) {
|
||||||
|
continue; // cycle detected, do not enter the path again
|
||||||
|
}
|
||||||
|
array_push($stack, [$nextStep, $nextStepParents]);
|
||||||
|
}
|
||||||
|
} while (count($stack) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createStepNotFoundException(string $stepId, string $parentStepId): UnexpectedValueException {
|
||||||
|
return Exceptions::workflowStructureNotValid(
|
||||||
|
// translators: %1$s is ID of the step not found, %2$s is ID of the step that references it
|
||||||
|
sprintf(
|
||||||
|
__("Step with ID '%1\$s' not found (referenced from '%2\$s')", 'mailpoet'),
|
||||||
|
$stepId,
|
||||||
|
$parentStepId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -128,6 +128,7 @@ class ContainerConfigurator implements IContainerConfigurator {
|
|||||||
$container->autowire(\MailPoet\Automation\Engine\Storage\WorkflowRunLogStorage::class)->setPublic(true);
|
$container->autowire(\MailPoet\Automation\Engine\Storage\WorkflowRunLogStorage::class)->setPublic(true);
|
||||||
$container->autowire(\MailPoet\Automation\Engine\Storage\WorkflowTemplateStorage::class)->setPublic(true);
|
$container->autowire(\MailPoet\Automation\Engine\Storage\WorkflowTemplateStorage::class)->setPublic(true);
|
||||||
$container->autowire(\MailPoet\Automation\Engine\Storage\WorkflowStorage::class)->setPublic(true);
|
$container->autowire(\MailPoet\Automation\Engine\Storage\WorkflowStorage::class)->setPublic(true);
|
||||||
|
$container->autowire(\MailPoet\Automation\Engine\Validation\WorkflowGraph\WorkflowWalker::class)->setPublic(true);
|
||||||
$container->autowire(\MailPoet\Automation\Engine\WordPress::class)->setPublic(true);
|
$container->autowire(\MailPoet\Automation\Engine\WordPress::class)->setPublic(true);
|
||||||
// Automation - API endpoints
|
// Automation - API endpoints
|
||||||
$container->autowire(\MailPoet\Automation\Engine\Endpoints\Workflows\WorkflowsGetEndpoint::class)->setPublic(true);
|
$container->autowire(\MailPoet\Automation\Engine\Endpoints\Workflows\WorkflowsGetEndpoint::class)->setPublic(true);
|
||||||
|
@ -0,0 +1,130 @@
|
|||||||
|
<?php declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace MailPoet\Automation\Engine\Validation\WorkflowGraph;
|
||||||
|
|
||||||
|
use MailPoet\Automation\Engine\Data\NextStep;
|
||||||
|
use MailPoet\Automation\Engine\Data\Step;
|
||||||
|
use MailPoet\Automation\Engine\Data\Workflow;
|
||||||
|
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
|
||||||
|
use MailPoetUnitTest;
|
||||||
|
|
||||||
|
class WorkflowWalkerTest extends MailPoetUnitTest {
|
||||||
|
public function testRootStepMissing(): void {
|
||||||
|
$workflow = $this->createWorkflow([]);
|
||||||
|
|
||||||
|
$this->expectException(UnexpectedValueException::class);
|
||||||
|
$this->expectExceptionMessage("Invalid workflow structure: Workflow must contain a 'root' step");
|
||||||
|
$this->walkWorkflow($workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonRootStepMissing(): void {
|
||||||
|
$workflow = $this->createWorkflow(['root' => ['a']]);
|
||||||
|
|
||||||
|
$this->expectException(UnexpectedValueException::class);
|
||||||
|
$this->expectExceptionMessage("Invalid workflow structure: Step with ID 'a' not found (referenced from 'root')");
|
||||||
|
$this->walkWorkflow($workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSimpleWorkflow(): void {
|
||||||
|
$workflow = $this->createWorkflow([
|
||||||
|
'root' => ['a'],
|
||||||
|
'a' => ['b'],
|
||||||
|
'b' => ['c'],
|
||||||
|
'c' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = $this->walkWorkflow($workflow);
|
||||||
|
$this->assertSame([
|
||||||
|
['root', []],
|
||||||
|
['a', ['root']],
|
||||||
|
['b', ['root', 'a']],
|
||||||
|
['c', ['root', 'a', 'b']],
|
||||||
|
], $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultiBranchWorkflow(): void {
|
||||||
|
$workflow = $this->createWorkflow([
|
||||||
|
'root' => ['a1', 'a2'],
|
||||||
|
'a1' => ['b1', 'b2'],
|
||||||
|
'a2' => ['c'],
|
||||||
|
'b1' => ['d'],
|
||||||
|
'b2' => [],
|
||||||
|
'c' => [],
|
||||||
|
'd' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = $this->walkWorkflow($workflow);
|
||||||
|
$this->assertSame([
|
||||||
|
['root', []],
|
||||||
|
['a1', ['root']],
|
||||||
|
['b1', ['root', 'a1']],
|
||||||
|
['d', ['root', 'a1', 'b1']],
|
||||||
|
['b2', ['root', 'a1']],
|
||||||
|
['a2', ['root']],
|
||||||
|
['c', ['root', 'a2']],
|
||||||
|
], $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function testCyclicWorkflow(): void {
|
||||||
|
$workflow = $this->createWorkflow([
|
||||||
|
'root' => ['a', 'root'],
|
||||||
|
'a' => ['b'],
|
||||||
|
'b' => ['c'],
|
||||||
|
'c' => ['a', 'd'],
|
||||||
|
'd' => ['d'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = $this->walkWorkflow($workflow);
|
||||||
|
$this->assertSame([
|
||||||
|
['root', []],
|
||||||
|
['a', ['root']],
|
||||||
|
['b', ['root', 'a']],
|
||||||
|
['c', ['root', 'a', 'b']],
|
||||||
|
['d', ['root', 'a', 'b', 'c']],
|
||||||
|
], $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createStep(string $id, array $nextStepIds): Step {
|
||||||
|
return new Step(
|
||||||
|
$id,
|
||||||
|
'test-type',
|
||||||
|
'test-key',
|
||||||
|
[],
|
||||||
|
array_map(function (string $id) {
|
||||||
|
return new NextStep($id);
|
||||||
|
}, $nextStepIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createWorkflow(array $steps): Workflow {
|
||||||
|
$stepMap = [];
|
||||||
|
foreach ($steps as $id => $nextStepIds) {
|
||||||
|
$stepMap[$id] = $this->createStep($id, $nextStepIds);
|
||||||
|
}
|
||||||
|
return $this->make(Workflow::class, ['getSteps' => $stepMap]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function walkWorkflow(Workflow $workflow): array {
|
||||||
|
$visitor = new class implements WorkflowNodeVisitor {
|
||||||
|
public $nodes = [];
|
||||||
|
|
||||||
|
public function initialize(Workflow $workflow): void {
|
||||||
|
$this->nodes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visitNode(Workflow $workflow, WorkflowNode $node): void {
|
||||||
|
$this->nodes[] = $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Workflow $workflow): void {}
|
||||||
|
};
|
||||||
|
|
||||||
|
$walker = new WorkflowWalker();
|
||||||
|
$walker->walk($workflow, [$visitor]);
|
||||||
|
return array_map(function (WorkflowNode $node) {
|
||||||
|
return [$node->getStep()->getId(), array_map(function (Step $parent) {
|
||||||
|
return $parent->getId();
|
||||||
|
}, $node->getParents())];
|
||||||
|
}, $visitor->nodes);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user