Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Drupal Dependencies commands to Drush core #6060

Merged
merged 9 commits into from
Nov 19, 2024
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')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This container param I wanted to autowire, so I've opened #6061

);
}

#[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