From ada6f7d31591bf35eda4b91557d467d9aeb1d11f Mon Sep 17 00:00:00 2001 From: eFrane Date: Thu, 11 Feb 2021 20:42:56 +0100 Subject: [PATCH] [Workflow] Add Mermaid.js dumper --- .../Command/WorkflowDumpCommand.php | 24 +- src/Symfony/Component/Workflow/CHANGELOG.md | 1 + .../Workflow/Dumper/MermaidDumper.php | 288 ++++++++++++++++++ .../Tests/Dumper/MermaidDumperTest.php | 226 ++++++++++++++ 4 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 src/Symfony/Component/Workflow/Dumper/MermaidDumper.php create mode 100644 src/Symfony/Component/Workflow/Tests/Dumper/MermaidDumperTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php index 4f62e9092a7b6..1249406f79188 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php @@ -18,6 +18,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Workflow\Dumper\GraphvizDumper; +use Symfony\Component\Workflow\Dumper\MermaidDumper; use Symfony\Component\Workflow\Dumper\PlantUmlDumper; use Symfony\Component\Workflow\Dumper\StateMachineGraphvizDumper; use Symfony\Component\Workflow\Marking; @@ -51,7 +52,7 @@ protected function configure() DOT: %command.full_name% | dot -Tpng > workflow.png PUML: %command.full_name% --dump-format=puml | java -jar plantuml.jar -p > workflow.png - +MERMAID: %command.full_name% --dump-format=mermaid | mmdc -o workflow.svg EOF ) ; @@ -75,13 +76,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int throw new InvalidArgumentException(sprintf('No service found for "workflow.%1$s" nor "state_machine.%1$s".', $serviceId)); } - if ('puml' === $input->getOption('dump-format')) { - $transitionType = 'workflow' === $type ? PlantUmlDumper::WORKFLOW_TRANSITION : PlantUmlDumper::STATEMACHINE_TRANSITION; - $dumper = new PlantUmlDumper($transitionType); - } elseif ('workflow' === $type) { - $dumper = new GraphvizDumper(); - } else { - $dumper = new StateMachineGraphvizDumper(); + switch ($input->getOption('dump-format')) { + case 'puml': + $transitionType = 'workflow' === $type ? PlantUmlDumper::WORKFLOW_TRANSITION : PlantUmlDumper::STATEMACHINE_TRANSITION; + $dumper = new PlantUmlDumper($transitionType); + break; + + case 'mermaid': + $transitionType = 'workflow' === $type ? MermaidDumper::TRANSITION_TYPE_WORKFLOW : MermaidDumper::TRANSITION_TYPE_STATEMACHINE; + $dumper = new MermaidDumper($transitionType); + break; + + case 'dot': + default: + $dumper = ('workflow' === $type) ? new GraphvizDumper() : new StateMachineGraphvizDumper(); } $marking = new Marking(); diff --git a/src/Symfony/Component/Workflow/CHANGELOG.md b/src/Symfony/Component/Workflow/CHANGELOG.md index 553acdb13b8e5..b62bacf97c939 100644 --- a/src/Symfony/Component/Workflow/CHANGELOG.md +++ b/src/Symfony/Component/Workflow/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Deprecate `InvalidTokenConfigurationException` + * Added `MermaidDumper` to dump Workflow graphs in the Mermaid.js flowchart format 5.2.0 ----- diff --git a/src/Symfony/Component/Workflow/Dumper/MermaidDumper.php b/src/Symfony/Component/Workflow/Dumper/MermaidDumper.php new file mode 100644 index 0000000000000..15ec8c65c5ffa --- /dev/null +++ b/src/Symfony/Component/Workflow/Dumper/MermaidDumper.php @@ -0,0 +1,288 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Dumper; + +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Exception\InvalidArgumentException; +use Symfony\Component\Workflow\Marking; + +class MermaidDumper implements DumperInterface +{ + public const DIRECTION_TOP_TO_BOTTOM = 'TB'; + public const DIRECTION_TOP_DOWN = 'TD'; + public const DIRECTION_BOTTOM_TO_TOP = 'BT'; + public const DIRECTION_RIGHT_TO_LEFT = 'RL'; + public const DIRECTION_LEFT_TO_RIGHT = 'LR'; + + private const VALID_DIRECTIONS = [ + self::DIRECTION_TOP_TO_BOTTOM, + self::DIRECTION_TOP_DOWN, + self::DIRECTION_BOTTOM_TO_TOP, + self::DIRECTION_RIGHT_TO_LEFT, + self::DIRECTION_LEFT_TO_RIGHT, + ]; + + public const TRANSITION_TYPE_STATEMACHINE = 'statemachine'; + public const TRANSITION_TYPE_WORKFLOW = 'workflow'; + + private const VALID_TRANSITION_TYPES = [ + self::TRANSITION_TYPE_STATEMACHINE, + self::TRANSITION_TYPE_WORKFLOW, + ]; + + /** + * @var string + */ + private $direction; + + /** + * @var string + */ + private $transitionType; + + /** + * Just tracking the transition id is in some cases inaccurate to + * get the link's number for styling purposes. + * + * @var int + */ + private $linkCount; + + public function __construct(string $transitionType, string $direction = self::DIRECTION_LEFT_TO_RIGHT) + { + $this->validateDirection($direction); + $this->validateTransitionType($transitionType); + + $this->direction = $direction; + $this->transitionType = $transitionType; + } + + public function dump(Definition $definition, Marking $marking = null, array $options = []): string + { + $this->linkCount = 0; + $placeNameMap = []; + $placeId = 0; + + $output = ['graph '.$this->direction]; + + $meta = $definition->getMetadataStore(); + + foreach ($definition->getPlaces() as $place) { + [$placeNode, $placeStyle] = $this->preparePlace( + $placeId, + $place, + $meta->getPlaceMetadata($place), + \in_array($place, $definition->getInitialPlaces()), + null !== $marking && $marking->has($place) + ); + + $output[] = $placeNode; + + if ('' !== $placeStyle) { + $output[] = $placeStyle; + } + + $placeNameMap[$place] = $place.$placeId; + + ++$placeId; + } + + foreach ($definition->getTransitions() as $transitionId => $transition) { + $transitionMeta = $meta->getTransitionMetadata($transition); + + $transitionLabel = $transition->getName(); + if (\array_key_exists('label', $transitionMeta)) { + $transitionLabel = $transitionMeta['label']; + } + + foreach ($transition->getFroms() as $from) { + $from = $placeNameMap[$from]; + + foreach ($transition->getTos() as $to) { + $to = $placeNameMap[$to]; + + if (self::TRANSITION_TYPE_STATEMACHINE === $this->transitionType) { + $transitionOutput = $this->styleStatemachineTransition( + $from, + $to, + $transitionId, + $transitionLabel, + $transitionMeta + ); + } else { + $transitionOutput = $this->styleWorkflowTransition( + $from, + $to, + $transitionId, + $transitionLabel, + $transitionMeta + ); + } + + foreach ($transitionOutput as $line) { + if (\in_array($line, $output)) { + // additional links must be decremented again to align the styling + if (0 < strpos($line, '-->')) { + --$this->linkCount; + } + + continue; + } + + $output[] = $line; + } + } + } + } + + return implode("\n", $output); + } + + private function preparePlace(int $placeId, string $placeName, array $meta, bool $isInitial, bool $hasMarking): array + { + $placeLabel = $placeName; + if (\array_key_exists('label', $meta)) { + $placeLabel = $meta['label']; + } + + $placeLabel = $this->escape($placeLabel); + + $labelShape = '((%s))'; + if ($isInitial) { + $labelShape = '([%s])'; + } + + $placeNodeName = $placeName.$placeId; + $placeNodeFormat = '%s'.$labelShape; + $placeNode = sprintf($placeNodeFormat, $placeNodeName, $placeLabel); + + $placeStyle = $this->styleNode($meta, $placeNodeName, $hasMarking); + + return [$placeNode, $placeStyle]; + } + + private function styleNode(array $meta, string $nodeName, bool $hasMarking = false): string + { + $nodeStyles = []; + + if (\array_key_exists('bg_color', $meta)) { + $nodeStyles[] = sprintf( + 'fill:%s', + $meta['bg_color'] + ); + } + + if ($hasMarking) { + $nodeStyles[] = 'stroke-width:4px'; + } + + if (0 === \count($nodeStyles)) { + return ''; + } + + return sprintf('style %s %s', $nodeName, implode(',', $nodeStyles)); + } + + /** + * Replace double quotes with the mermaid escape syntax and + * ensure all other characters are properly escaped. + */ + private function escape(string $label) + { + $label = str_replace('"', '#quot;', $label); + + return sprintf('"%s"', $label); + } + + public function validateDirection(string $direction): void + { + if (!\in_array($direction, self::VALID_DIRECTIONS, true)) { + throw new InvalidArgumentException(sprintf('Direction "%s" is not valid, valid directions are: "%s".', $direction, implode(', ', self::VALID_DIRECTIONS))); + } + } + + private function validateTransitionType(string $transitionType): void + { + if (!\in_array($transitionType, self::VALID_TRANSITION_TYPES, true)) { + throw new InvalidArgumentException(sprintf('Transition type "%s" is not valid, valid types are: "%s".', $transitionType, implode(', ', self::VALID_TRANSITION_TYPES))); + } + } + + private function styleStatemachineTransition( + string $from, + string $to, + int $transitionId, + string $transitionLabel, + array $transitionMeta + ): array { + $transitionOutput = [sprintf('%s-->|%s|%s', $from, $this->escape($transitionLabel), $to)]; + + $linkStyle = $this->styleLink($transitionMeta); + if ('' !== $linkStyle) { + $transitionOutput[] = $linkStyle; + } + + ++$this->linkCount; + + return $transitionOutput; + } + + private function styleWorkflowTransition( + string $from, + string $to, + int $transitionId, + string $transitionLabel, + array $transitionMeta + ) { + $transitionOutput = []; + + $transitionLabel = $this->escape($transitionLabel); + $transitionNodeName = 'transition'.$transitionId; + + $transitionOutput[] = sprintf('%s[%s]', $transitionNodeName, $transitionLabel); + + $transitionNodeStyle = $this->styleNode($transitionMeta, $transitionNodeName); + if ('' !== $transitionNodeStyle) { + $transitionOutput[] = $transitionNodeStyle; + } + + $connectionStyle = '%s-->%s'; + $transitionOutput[] = sprintf($connectionStyle, $from, $transitionNodeName); + + $linkStyle = $this->styleLink($transitionMeta); + if ('' !== $linkStyle) { + $transitionOutput[] = $linkStyle; + } + + ++$this->linkCount; + + $transitionOutput[] = sprintf($connectionStyle, $transitionNodeName, $to); + + $linkStyle = $this->styleLink($transitionMeta); + if ('' !== $linkStyle) { + $transitionOutput[] = $linkStyle; + } + + ++$this->linkCount; + + return $transitionOutput; + } + + private function styleLink(array $transitionMeta): string + { + if (\array_key_exists('color', $transitionMeta)) { + return sprintf('linkStyle %d stroke:%s', $this->linkCount, $transitionMeta['color']); + } + + return ''; + } +} diff --git a/src/Symfony/Component/Workflow/Tests/Dumper/MermaidDumperTest.php b/src/Symfony/Component/Workflow/Tests/Dumper/MermaidDumperTest.php new file mode 100644 index 0000000000000..4eaebe1e452b3 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/Dumper/MermaidDumperTest.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\Dumper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\DefinitionBuilder; +use Symfony\Component\Workflow\Dumper\MermaidDumper; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait; +use Symfony\Component\Workflow\Transition; + +class MermaidDumperTest extends TestCase +{ + use WorkflowBuilderTrait; + + /** + * @dataProvider provideWorkflowDefinitionWithoutMarking + */ + public function testDumpWithoutMarking(Definition $definition, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); + + $dump = $dumper->dump($definition); + + $this->assertEquals($expected, $dump); + } + + /** + * @dataProvider provideWorkflowWithReservedWords + */ + public function testDumpWithReservedWordsAsPlacenames(Definition $definition, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); + + $dump = $dumper->dump($definition); + + $this->assertEquals($expected, $dump); + } + + /** + * @dataProvider provideStatemachine + */ + public function testDumpAsStatemachine(Definition $definition, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_STATEMACHINE); + + $dump = $dumper->dump($definition); + + $this->assertEquals($expected, $dump); + } + + /** + * @dataProvider provideWorkflowWithMarking + */ + public function testDumpWorkflowWithMarking(Definition $definition, Marking $marking, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); + + $dump = $dumper->dump($definition, $marking); + + $this->assertEquals($expected, $dump); + } + + public function provideWorkflowDefinitionWithoutMarking(): array + { + return [ + [ + $this->createComplexWorkflowDefinition(), + "graph LR\n" + ."a0([\"a\"])\n" + ."b1((\"b\"))\n" + ."c2((\"c\"))\n" + ."d3((\"d\"))\n" + ."e4((\"e\"))\n" + ."f5((\"f\"))\n" + ."g6((\"g\"))\n" + ."transition0[\"t1\"]\n" + ."a0-->transition0\n" + ."transition0-->b1\n" + ."transition0-->c2\n" + ."transition1[\"t2\"]\n" + ."b1-->transition1\n" + ."transition1-->d3\n" + ."c2-->transition1\n" + ."transition2[\"My custom transition label 1\"]\n" + ."d3-->transition2\n" + ."linkStyle 6 stroke:Red\n" + ."transition2-->e4\n" + ."linkStyle 7 stroke:Red\n" + ."transition3[\"t4\"]\n" + ."d3-->transition3\n" + ."transition3-->f5\n" + ."transition4[\"t5\"]\n" + ."e4-->transition4\n" + ."transition4-->g6\n" + ."transition5[\"t6\"]\n" + ."f5-->transition5\n" + .'transition5-->g6', + ], + [ + $this->createWorkflowWithSameNameTransition(), + "graph LR\n" + ."a0([\"a\"])\n" + ."b1((\"b\"))\n" + ."c2((\"c\"))\n" + ."transition0[\"a_to_bc\"]\n" + ."a0-->transition0\n" + ."transition0-->b1\n" + ."transition0-->c2\n" + ."transition1[\"b_to_c\"]\n" + ."b1-->transition1\n" + ."transition1-->c2\n" + ."transition2[\"to_a\"]\n" + ."b1-->transition2\n" + ."transition2-->a0\n" + ."transition3[\"to_a\"]\n" + ."c2-->transition3\n" + .'transition3-->a0', + ], + [ + $this->createSimpleWorkflowDefinition(), + "graph LR\n" + ."a0([\"a\"])\n" + ."b1((\"b\"))\n" + ."c2((\"c\"))\n" + ."style c2 fill:DeepSkyBlue\n" + ."transition0[\"My custom transition label 2\"]\n" + ."a0-->transition0\n" + ."linkStyle 0 stroke:Grey\n" + ."transition0-->b1\n" + ."linkStyle 1 stroke:Grey\n" + ."transition1[\"t2\"]\n" + ."b1-->transition1\n" + .'transition1-->c2', + ], + ]; + } + + public function provideWorkflowWithReservedWords() + { + $builder = new DefinitionBuilder(); + + $builder->addPlaces(['start', 'subgraph', 'end', 'finis']); + $builder->addTransitions([ + new Transition('t0', ['start', 'subgraph'], ['end']), + new Transition('t1', ['end'], ['finis']), + ]); + + $definition = $builder->build(); + + return [ + [ + $definition, + "graph LR\n" + ."start0([\"start\"])\n" + ."subgraph1((\"subgraph\"))\n" + ."end2((\"end\"))\n" + ."finis3((\"finis\"))\n" + ."transition0[\"t0\"]\n" + ."start0-->transition0\n" + ."transition0-->end2\n" + ."subgraph1-->transition0\n" + ."transition1[\"t1\"]\n" + ."end2-->transition1\n" + .'transition1-->finis3', + ], + ]; + } + + public function provideStatemachine(): array + { + return [ + [ + $this->createComplexStateMachineDefinition(), + "graph LR\n" + ."a0([\"a\"])\n" + ."b1((\"b\"))\n" + ."c2((\"c\"))\n" + ."d3((\"d\"))\n" + ."a0-->|\"t1\"|b1\n" + ."d3-->|\"My custom transition label 3\"|b1\n" + ."linkStyle 1 stroke:Grey\n" + ."b1-->|\"t2\"|c2\n" + .'b1-->|"t3"|d3', + ], + ]; + } + + public function provideWorkflowWithMarking(): array + { + $marking = new Marking(); + $marking->mark('b'); + $marking->mark('c'); + + return [ + [ + $this->createSimpleWorkflowDefinition(), + $marking, + "graph LR\n" + ."a0([\"a\"])\n" + ."b1((\"b\"))\n" + ."style b1 stroke-width:4px\n" + ."c2((\"c\"))\n" + ."style c2 fill:DeepSkyBlue,stroke-width:4px\n" + ."transition0[\"My custom transition label 2\"]\n" + ."a0-->transition0\n" + ."linkStyle 0 stroke:Grey\n" + ."transition0-->b1\n" + ."linkStyle 1 stroke:Grey\n" + ."transition1[\"t2\"]\n" + ."b1-->transition1\n" + .'transition1-->c2', + ], + ]; + } +}