Skip to content

Commit

Permalink
Add Drupal Dependencies commands to Drush core (#6060)
Browse files Browse the repository at this point in the history
* Port from claudiu-cristea/drupal-dependencies

* Drupal 10 backwards compatibility in test

* Documentation

* Simplify option: --dependent-type -> --type

* Constant from base class '\RecursiveIteratorIterator' referenced via child class

* Document internals

* Document private methods

* Use arrow function
  • Loading branch information
claudiu-cristea authored Nov 19, 2024
1 parent d124723 commit f17a1d7
Show file tree
Hide file tree
Showing 17 changed files with 865 additions and 0 deletions.
35 changes: 35 additions & 0 deletions docs/drupal-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
Inspecting Drupal dependencies
==============================
:octicons-tag-24: 13.4+

These commands allow a developer or site builder to inspect the Drupal dependencies. It's similar with Composer's `why` command but acts in the Drupal realm, by showing dependencies between modules or config entities.

Find module dependants
----------------------

Drupal modules are able to define other modules as dependencies, using the module's [metadata info.yml file](https://www.drupal.org/docs/develop/creating-modules/let-drupal-know-about-your-module-with-an-infoyml-file). To get all modules that depend on a given module type:

drush why:module node --type=module

This will show all the _installed_ module dependents of `node` module. The results are rendered visually as a tree, making it easy to understand the nested relations. It also marks visually the circular dependencies.

If you want to get the dependency tree as data, use the `--format` option. E.g., `--format=yaml` or `--format=json`.

The above command only rendered the dependency tree of _installed_ modules. If you need to get the module dependants regardless whether they are installed or not, use the `--no-only-installed` option/flag:

drush why:module node --type=module --no-only-installed

Config entities are able to declare [dependencies on modules](https://www.drupal.org/docs/drupal-apis/configuration-api/configuration-entity-dependencies). You can find also the config entities that depend on a given module. The following command shows all config entities depending on `node` module:

drush why:module node --type=config

Dependents are also rendered as a tree, showing a nested structure. The `--format` option can be used in the same way, to get a machine-readable structure.

Find config entity dependants
-----------------------------

Config entities are able also to declare [dependencies on other config entities](https://www.drupal.org/docs/drupal-apis/configuration-api/configuration-entity-dependencies). With `why:config` Drush command we can determine the config entities depending on a specific entity:

drush why:config node.type.article

This will also render the results in a structured tree visualisation. Same, the `--format` option can be used to get data structured as `json`, `yaml`, etc.
12 changes: 12 additions & 0 deletions src/Commands/core/DocsCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ final class DocsCommands extends DrushCommands
const EXAMPLE_SYNC_VIA_HTTP = 'docs:example-sync-via-http';
const POLICY = 'docs:policy';
const DEPLOY = 'docs:deploy';
const DRUPAL_DEPENDENCIES = 'docs:drupal-dependencies';

/**
* README.md
Expand Down Expand Up @@ -218,4 +219,15 @@ public function deploy(): void
{
self::printFileTopic($this->commandData);
}

/**
* Inspecting Drupal dependencies.
*/
#[CLI\Command(name: self::DRUPAL_DEPENDENCIES)]
#[CLI\Help(hidden: true)]
#[CLI\Topics(path: '../../../docs/drupal-dependencies.md')]
public function drupalDependencies(): void
{
self::printFileTopic($this->commandData);
}
}
329 changes: 329 additions & 0 deletions src/Commands/core/DrupalDependenciesCommands.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
<?php

declare(strict_types=1);

namespace Drush\Commands\core;

use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\OutputFormatters\StructuredData\UnstructuredData;
use Drupal\Component\DependencyInjection\ContainerInterface;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Extension\Dependency;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Extension\ModuleExtensionList;
use Drush\Attributes as CLI;
use Drush\Boot\DrupalBootLevels;
use Drush\Commands\DrushCommands;
use RecursiveIteratorIterator;
use Symfony\Component\Console\Input\InputOption;

/**
* Drush commands revealing Drupal dependencies.
*/
final class DrupalDependenciesCommands extends DrushCommands
{
public const WHY_MODULE = 'why:module';
public const WHY_CONFIG = 'why:config';
private const CIRCULAR_REFERENCE = '***circular***';

/**
* List of dependents grouped by dependency.
*/
private array $dependents = [];

/**
* Nested array with computed dependency tree.
*/
private array $tree = [];

/**
* Visited dependency > dependent paths. Used to detect circular references.
*/
private array $relation = [];

/**
* Computed dependent -> dependencies relations.
*/
private array $dependencies = [
// List of module dependencies grouped by module dependent.
'module-module' => [],
// List of config module dependencies grouped by config dependent.
'config-module' => [],
// List of config dependencies grouped by config dependent.
'config-config' => [],
];

public function __construct(
private readonly ModuleExtensionList $moduleExtensionList,
private readonly array $installedModules,
) {
parent::__construct();
}

public static function create(ContainerInterface $container): self
{
return new self(
$container->get('extension.list.module'),
$container->getParameter('container.modules')
);
}

#[CLI\Command(name: self::WHY_MODULE, aliases: ['wm'])]
#[CLI\Help(description: 'List all objects (modules, configurations) depending on a given module')]
#[CLI\Argument(name: 'module', description: 'The module to check dependents for')]
#[CLI\Option(
name: 'type',
description: 'Type of dependents: module, config',
suggestedValues: ['module', 'config']
)]
#[CLI\Option(
name: 'only-installed',
description: 'Only check for installed modules'
)]
#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
#[CLI\Usage(
name: 'drush why:module node --type=module',
description: 'Show all installed modules depending on node module'
)]
#[CLI\Usage(
name: 'drush why:module node --type=module --no-only-installed',
description: 'Show all modules, including uninstalled, depending on node module'
)]
#[CLI\Usage(
name: 'drush why:module node --type=config',
description: 'Show all configuration entities depending on node module'
)]
#[CLI\Usage(
name: 'drush why:module node --type=config --format=json',
description: 'Return config entity dependents as JSON'
)]
#[CLI\Topics(topics: [DocsCommands::DRUPAL_DEPENDENCIES])]
public function dependentsOfModule(string $module, array $options = [
'type' => InputOption::VALUE_REQUIRED,
'only-installed' => true,
'format' => '',
]): string|UnstructuredData|null
{
if ($options['type'] === 'module') {
$this->buildDependents($this->dependencies['module-module']);
} else {
$this->scanConfigs();
$this
->buildDependents($this->dependencies['config-module'])
->buildDependents($this->dependencies['config-config']);
}

if (!isset($this->dependents[$module])) {
$this->logger()->notice(dt('No @type depends on @module', [
'@module' => $module,
'@type' => $options['type'] === 'module' ? dt('other module') : dt('config entity'),
]));
return null;
}

$this->buildTree($module);

if (empty($options['format'])) {
return $this->drawTree($module);
}

return new UnstructuredData($this->tree);
}

#[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: 'why:module')]
public function validateDependentsOfModule(CommandData $commandData): void
{
$type = $commandData->input()->getOption('type');
if (empty($type)) {
throw new \InvalidArgumentException("The --type option is mandatory");
}
if (!in_array($type, ['module', 'config'], true)) {
throw new \InvalidArgumentException(
"The --type option can take only 'module' or 'config' as value"
);
}

$notOnlyInstalled = $commandData->input()->getOption('no-only-installed');
if ($notOnlyInstalled && $type === 'config') {
throw new \InvalidArgumentException("Cannot use --type=config together with --no-only-installed");
}

$module = $commandData->input()->getArgument('module');
if ($type === 'module') {
$this->dependencies['module-module'] = array_map(function (Extension $extension): array {
return array_map(function (string $dependencyString) {
return Dependency::createFromString($dependencyString)->getName();
}, $extension->info['dependencies']);
}, $this->moduleExtensionList->reset()->getList());

if (!$notOnlyInstalled) {
$this->dependencies['module-module'] = array_intersect_key(
$this->dependencies['module-module'],
$this->installedModules,
);
}
if (!isset($this->dependencies['module-module'][$module])) {
throw new \InvalidArgumentException(dt('Invalid @module module', [
'@module' => $module,
]));
}
} elseif (!isset($this->installedModules[$module])) {
throw new \InvalidArgumentException(dt('Invalid @module module', [
'@module' => $module,
]));
}
}

#[CLI\Command(name: self::WHY_CONFIG, aliases: ['wc'])]
#[CLI\Help(description: 'List all config entities depending on a given config entity')]
#[CLI\Argument(name: 'config', description: 'The config entity to check dependents for')]
#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
#[CLI\Usage(
name: 'drush why:config node.type.article',
description: 'Show all config entities modules depending on node.type.article'
)]
#[CLI\Usage(
name: 'drush why:config node.type.article --format=yaml',
description: 'Return config entity dependents as YAML'
)]
#[CLI\Topics(topics: [DocsCommands::DRUPAL_DEPENDENCIES])]
public function dependentsOfConfig(string $config, array $options = [
'format' => '',
]): string|UnstructuredData|null
{
$this->scanConfigs(false);
$this->buildDependents($this->dependencies['config-config']);

if (!isset($this->dependents[$config])) {
$this->logger()->notice(dt('No other config entity depends on @config', [
'@config' => $config,
]));
return null;
}

$this->buildTree($config);

if (empty($options['format'])) {
return $this->drawTree($config);
}

return new UnstructuredData($this->tree);
}

#[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: 'why:config')]
public function validateDependentsOfConfig(CommandData $commandData): void
{
$configName = $commandData->input()->getArgument('config');
$configManager = \Drupal::getContainer()->get('config.manager');
if (!$configManager->loadConfigEntityByName($configName)) {
throw new \InvalidArgumentException(dt('Invalid @config config entity', [
'@config' => $configName,
]));
}
}

/**
* Builds the nested dependency tree.
*/
protected function buildTree(string $dependency, array $path = []): void
{
$path[] = $dependency;
foreach ($this->dependents[$dependency] as $dependent) {
if (isset($this->relation[$dependency]) && $this->relation[$dependency] === $dependent) {
// This relation has been already defined on other path. We mark
// it as circular reference.
NestedArray::setValue($this->tree, [
...$path,
...[$dependent],
], $dependent . ':' . self::CIRCULAR_REFERENCE);
continue;
}

// Save this relation to avoid infinite circular references.
$this->relation[$dependency] = $dependent;

if (isset($this->dependents[$dependent])) {
$this->buildTree($dependent, $path);
} else {
NestedArray::setValue($this->tree, [...$path, ...[$dependent]], $dependent);
}
}
}

/**
* Build the reverse the relation: dependent -> dependencies.
*/
protected function buildDependents(array $dependenciesPerDependent): self
{
foreach ($dependenciesPerDependent as $dependent => $dependencies) {
foreach ($dependencies as $dependency) {
$this->dependents[$dependency][$dependent] = $dependent;
}
}

// Make dependents order predictable.
foreach ($this->dependents as $dependency => $dependents) {
ksort($this->dependents[$dependency]);
}
ksort($this->dependents);

return $this;
}

/**
* Scans all config entities and store their module and config dependencies.
*/
protected function scanConfigs(bool $scanModuleDependencies = true): void
{
$entityTypeManager = \Drupal::entityTypeManager();
$configTypeIds = array_keys(
array_filter(
$entityTypeManager->getDefinitions(),
fn(EntityTypeInterface $entityType): bool => $entityType->entityClassImplements(ConfigEntityInterface::class),
)
);

foreach ($configTypeIds as $configTypeId) {
/** @var \Drupal\Core\Config\Entity\ConfigEntityInterface $config */
foreach ($entityTypeManager->getStorage($configTypeId)->loadMultiple() as $config) {
$dependencies = $config->getDependencies();
$name = $config->getConfigDependencyName();
if ($scanModuleDependencies && !empty($dependencies['module'])) {
$this->dependencies['config-module'][$name] = $dependencies['module'];
}
if (!empty($dependencies['config'])) {
$this->dependencies['config-config'][$name] = $dependencies['config'];
}
}
}
}

/**
* Draws a visual representation of the dependency tree.
*/
private function drawTree(string $dependency): string
{
$recursiveArrayIterator = new \RecursiveArrayIterator(current($this->tree));
$recursiveTreeIterator = new \RecursiveTreeIterator(
$recursiveArrayIterator,
RecursiveIteratorIterator::SELF_FIRST,
);
$recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_HAS_NEXT, '├─');
$recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_LAST, '└─');
$recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_MID_HAS_NEXT, '');
$canvas = [$dependency];
foreach ($recursiveTreeIterator as $row => $value) {
$key = $recursiveTreeIterator->getInnerIterator()->key();
$current = $recursiveTreeIterator->getInnerIterator()->current();
$label = $row;
if ($key . ':' . self::CIRCULAR_REFERENCE === $current) {
$label .= ' <info>(' . dt('circular') . ')</info>';
}
$canvas[] = $label;
}
return implode(PHP_EOL, $canvas);
}
}
Loading

0 comments on commit f17a1d7

Please sign in to comment.