migrate_visualize-1.0.x-dev/src/MigrateGraph.php
src/MigrateGraph.php
<?php
namespace Drupal\migrate_visualize;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\migrate\Plugin\MigrateProcessInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Fhaculty\Graph\Graph;
use Fhaculty\Graph\Vertex;
/**
* Inspect a Drupal Migration and capture analysis in a graph.
*/
class MigrateGraph {
use StringTranslationTrait;
/**
* Module configuration.
*
* @var Drupal\Core\Config\ImmutableConfig
*/
protected ImmutableConfig $config;
/**
* The migration to be graphed.
*
* @var \Drupal\migrate\Plugin\MigrationInterface
*/
protected MigrationInterface $migration;
/**
* Graph which captures our analysis of the Migration.
*
* @var \Fhaculty\Graph\Graph
*/
protected Graph $graph;
/**
* Process plugin keys which denote a source being referred to.
*
* @var string[]
*/
protected $sourceKeys = [
'source',
];
/**
* Construct a new MigrationGraph object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager
* The entity field manager.
* @param \Drupal\Core\StringTranslation\TranslationInterface $stringTranslation
* The string translation service.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected EntityFieldManagerInterface $entityFieldManager,
protected $stringTranslation,
) {
$this->config = $this->configFactory->get('migrate_visualize.settings');
$this->graph = new Graph();
}
/**
* Set the migration to be graphed.
*
* @var \Drupal\migrate\Plugin\MigrationInterface
* The migration to graph.
*/
public function setMigration(MigrationInterface $migration) {
$this->migration = $migration;
}
/**
* Retrieve the analysis.
*
* @return \Fhaculty\Graph\Graph
* The generated graph.
*/
public function getGraph() {
return $this->graph;
}
/**
* Make graph IDs "safe" (for MermaidJS).
*
* @param string $id
* ID (yaml key) of a graph item.
*
* @return string
* ID, safe for use with MermaidJS.
*/
public function safeId(string $id) {
return preg_replace('/[^a-zA-Z0-9_:]/', '_', $id);
}
/**
* Bypass PHP protections on object properties for analysis.
*
* Getting at process plugin configs seems complicated, but I'm pretty sure
* this ain't how it's supposed to happen.
*
* Hmm ... can we use $migration->toArray(), if we have the migration config
* entity, instead of picking the migration plugin object apart?
*
* @var object $object
* The object to inspect.
* @var string $propertyName
* The name of the property to retrieve.
*
* @return mixed
* The property value extracted.
*
* @see https://www.drupal.org/project/drupal/issues/2937177
* @see https://www.drupal.org/project/feeds_migrate/issues/3012966
*/
public static function extractProtectedProperty($object, $propertyName) {
$extractor = function () use ($propertyName) {
// @phpstan-ignore variable.undefined
return $this->$propertyName;
};
return $extractor->call($object);
}
/**
* Graph a migration.
*
* @param \Drupal\migrate\Plugin\MigrationInterface $migration
* The migration under inspection.
*
* @throws \Exception
*/
public function graph(MigrationInterface $migration) : void {
$this->setMigration($migration);
$this->graphSourcePlugin($this->migration->getSourcePlugin(), $this->migration->getSourceConfiguration());
$this->graphDestinationPlugin($this->migration->getDestinationPlugin(), $this->migration->getDestinationConfiguration());
$this->graphProcessPlugins($this->migration->getProcessPlugins());
}
/**
* Graph the source plugin.
*
* The source plugin fields often aren't complete, additional fields will be
* inferred from process as well.
*
* NB: $sourcePlugin->getFields() exists, but may return labels if present,
* and we want unique field names.
*/
protected function graphSourcePlugin($sourcePlugin, array $configuration) : void {
$graph = $this->getGraph();
// Gather source plugin meta values.
foreach (['plugin'] as $configKey) {
if (isset($configuration[$configKey]) && !$graph->hasVertex("source:meta:{$configKey}")) {
$metaVertexId = $this->safeId("source:meta:{$configKey}");
$metaVertex = $graph->createVertex($metaVertexId);
$metaVertex->setAttribute('migrate_visualize.discovery', 'source');
$metaVertex->setAttribute('migrate_visualize.type', 'source:meta');
$metaVertex->setAttribute('migrate_visualize.label', $this->t("@key: @value", [
'@key' => $configKey,
'@value' => $configuration[$configKey],
]));
}
}
switch ($configuration['plugin']) {
case 'embedded_data':
foreach (reset($configuration['data_rows']) as $key => $value) {
$fieldVertexId = $this->safeId("source:field:{$key}");
if (!$graph->hasVertex($fieldVertexId)) {
$fieldVertex = $graph->createVertex($fieldVertexId);
$fieldVertex->setAttribute('migrate_visualize.discovery', 'source');
}
else {
$fieldVertex = $graph->getVertex($fieldVertexId);
}
$fieldVertex->setAttribute('migrate_visualize.type', 'source:field');
$fieldVertex->setAttribute('migrate_visualize.key', "{$fieldVertexId}");
$fieldVertex->setAttribute('migrate_visualize.label', "{$key}");
}
break;
}
// Gather source plugin fields when the source plugin defines fields
// (eg JSON, XML, CSV sources).
if (isset($configuration['fields'])) {
foreach ($configuration['fields'] as $field) {
$fieldVertexId = $this->safeId("source:field:{$field['name']}");
if (!$graph->hasVertex($fieldVertexId)) {
$fieldVertex = $graph->createVertex($fieldVertexId);
$fieldVertex->setAttribute('migrate_visualize.discovery', 'source');
}
else {
$fieldVertex = $graph->getVertex($fieldVertexId);
}
$fieldVertex->setAttribute('migrate_visualize.type', 'source:field');
$fieldVertex->setAttribute('migrate_visualize.key', "{$fieldVertexId}");
$fieldVertex->setAttribute('migrate_visualize.label', "{$field['name']}");
}
}
}
/**
* Graph the destination plugin.
*/
protected function graphDestinationPlugin($destinationPlugin, array $configuration) : void {
$graph = $this->getGraph();
$configuration = self::extractProtectedProperty($this->migration->getDestinationPlugin(), 'configuration');
// Gather destination plugin meta values.
foreach (['plugin', 'default_bundle'] as $configKey) {
if (isset($configuration[$configKey]) && !$graph->hasVertex("destination:meta:{$configKey}")) {
$metaVertexId = $this->safeId("destination:meta:{$configKey}");
$metaVertex = $graph->createVertex($metaVertexId);
$metaVertex->setAttribute('migrate_visualize.discovery', 'destination');
$metaVertex->setAttribute('migrate_visualize.type', 'destination:meta');
$metaVertex->setAttribute('migrate_visualize.label', $this->t("@key: @value", [
'@key' => $configKey,
'@value' => $configuration[$configKey],
]));
$metaVertex->setAttribute('configuration', $configuration);
}
}
// @todo Check better if it starts with "entity:".
$entityPlugins = [
'entity',
'entity_complete',
];
if (in_array(explode(':', $configuration['plugin'])[0], $entityPlugins)) {
if (isset($configuration['default_bundle'])) {
$entityType = explode(':', $configuration['plugin'])[1];
$fields = $this->entityFieldManager->getFieldDefinitions($entityType, $configuration['default_bundle']);
foreach ($fields as $fieldId => $field) {
$fieldVertexId = $this->safeId("destination:field:{$fieldId}");
$fieldVertex = $graph->createVertex($fieldVertexId);
$fieldVertex->setAttribute('migrate_visualize.discovery', 'destination');
$fieldVertex->setAttribute('migrate_visualize.type', 'destination:field');
$fieldVertex->setAttribute('migrate_visualize.label', $this->t("@fieldId: @value", [
'@fieldId' => $fieldId,
'@value' => $field->getLabel(),
]));
}
}
}
}
/**
* Graph the process plugin pipelines.
*
* @var array $processPlugins
* The process plugin pipelines for this migration.
*/
protected function graphProcessPlugins($processPlugins) : void {
$graph = $this->getGraph();
/** @var \Drupal\migrate\Plugin\MigrateProcessInterface[] $plugins */
foreach ($processPlugins as $destinationPropertyName => $plugins) {
$processVertexId = $this->safeId("process:field:{$destinationPropertyName}");
$processVertex = $graph->createVertex($processVertexId);
$processVertex->setAttribute('migrate_visualize.type', 'process:pipeline');
$processVertex->setAttribute('migrate_visualize.label', $this->t("@fieldId", [
'@fieldId' => $destinationPropertyName,
]));
$previousVertex = NULL;
foreach ($plugins as $pipelineStepId => $plugin) {
$previousVertex = $this->graphProcessPlugin($destinationPropertyName, $plugin, $processVertex, $pipelineStepId, $plugins, $previousVertex);
}
$destinationVertexId = $this->safeid("destination:field:{$destinationPropertyName}");
if ($graph->hasVertex($destinationVertexId)) {
// Destination was created from inspecting entity fields.
$destinationVertex = $graph->getVertex($destinationVertexId);
}
else {
// Dynamically create a destination field.
$destinationVertex = $graph->createVertex($destinationVertexId);
$destinationVertex->setAttribute('migrate_visualize.type', 'destination:field');
$destinationVertex->setAttribute('migrate_visualize.key', "{$destinationVertexId}");
$destinationVertex->setAttribute('migrate_visualize.label', "{$destinationPropertyName}");
$destinationVertex->setAttribute('migrate_visualize.discovery', 'process');
}
$processVertex->createEdgeTo($destinationVertex);
}
}
/**
* Graph each plugin (step) in the process.
*
* @param \string $destinationPropertyName
* The calculated destination property name for this plugin in the process.
* @param \Drupal\migrate\Plugin\MigrateProcessInterface $plugin
* The configured process plugin.
* @param \Fhaculty\Graph\Vertex $destinationVertex
* The vertex associated with this process's destination.
* @param \int $pipelineStepId
* The array ID of this plugin in its pipeline.
* @param \array $plugins
* The array of plugins in this pipeline.
* @param \Fhaculty\Graph\Vertex $additionalSourceVertex
* (Optional) An additional source vertex. Used for chained sub-processes.
*/
protected function graphProcessPlugin(string $destinationPropertyName, MigrateProcessInterface $plugin, Vertex $destinationVertex, int $pipelineStepId, array $plugins, ?Vertex $additionalSourceVertex = NULL) {
$graph = $this->getGraph();
/** @var ProcessPluginBase $processPlugin */
$configuration = self::extractProtectedProperty($plugin, 'configuration');
$definition = $plugin->getPluginDefinition();
$pluginId = $definition['id'];
// @see https://www.drupal.org/project/migrate_visualize/issues/3262781
if ($pluginId === 'get' && $configuration['plugin'] !== 'get') {
return;
}
$pluginVertexId = $this->safeId("process:field:{$destinationPropertyName}:{$pipelineStepId}");
$pluginVertex = $graph->createVertex($pluginVertexId);
$previousPluginVertex = NULL;
$pluginVertex->setAttribute('migrate_visualize.type', 'process:pipeline:step');
$pluginVertex->setAttribute('migrate_visualize.label', $this->getProcessPluginStepLabel($plugin, $configuration));
// Connect to sources.
$sourceVertices = [];
foreach ($this->sourceKeys as $key) {
if (isset($configuration[$key])) {
if (is_string($configuration[$key])) {
$sources = [$configuration[$key]];
}
elseif (is_array($configuration[$key])) {
$sources = $configuration[$key];
}
else {
// @todo What here?
$sources = ['unknown'];
}
foreach ($sources as $sourceField) {
$sourceVertexId = $this->safeId("source:field:{$sourceField}");
// For this source, generate matching pseudofield destination ID.
$prevDestId = preg_replace('#^@([^/]+).*#', '$1', $sourceField);
$prevDestinationVertexId = $this->safeId("destination:field:{$prevDestId}");
if ($graph->hasVertex($sourceVertexId)) {
// Source vertex created in graphSourcePlugin().
$sourceVertex = $graph->getVertex($sourceVertexId);
}
else {
// Dynamically create source vertex for field.
$sourceVertex = $graph->createVertex($sourceVertexId);
$sourceVertex->setAttribute('migrate_visualize.type', 'source:field');
$sourceVertex->setAttribute('migrate_visualize.key', $sourceVertexId);
$sourceVertex->setAttribute('migrate_visualize.label', "{$sourceField}");
$sourceVertex->setAttribute('migrate_visualize.discovery', 'process');
if ($sourceField[0] === '@' && $this->config->get('graph_pseudofield_sources')) {
if ($this->graph->hasVertex($prevDestinationVertexId)) {
$edge = $this->graph->getVertex($prevDestinationVertexId)
->createEdgeTo($sourceVertex);
$edge->setAttribute('migrate_visualize.discovery', 'process');
$edge->setAttribute('migrate_visualize.type', 'pseudofield');
}
}
}
$sourceVertices[] = $sourceVertex;
}
}
}
// Check if this plugin is part of a chain as have an additional source
// vertex passed as an argument.
if ($additionalSourceVertex) {
$sourceVertices[] = $additionalSourceVertex;
}
foreach ($sourceVertices as $sourceVertex) {
if (!$sourceVertex->hasEdgeTo($pluginVertex)) {
$edge = $sourceVertex->createEdgeTo($pluginVertex);
}
}
// Previous step exists.
if (!is_null($previousPluginVertex)) {
$previousPluginEdge = $previousPluginVertex->createEdgeTo($pluginVertex);
}
$previousPluginVertex = $pluginVertex;
// Last step in pipeline.
if (count($plugins) == ($pipelineStepId + 1)) {
$pluginVertex->createEdgeTo($destinationVertex);
}
return $pluginVertex;
}
/**
* Generate a label for a process pipeline step.
*
* @param \Drupal\migrate\Plugin\MigrateProcessInterface $processPlugin
* The configured process plugin.
* @param \array $configuration
* The extracted configuration of the plugin.
*
* @return string
* The generated label.
*/
protected function getProcessPluginStepLabel(MigrateProcessInterface $processPlugin, array $configuration) : string {
switch ($configuration['plugin']) {
case "callback":
return "callback: {$configuration['callable']}";
case "get":
return "get";
case "entity_generate":
return "entity_generate";
default:
return $configuration['plugin'];
}
return get_class($processPlugin);
}
}
