-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
8677f44
Port from claudiu-cristea/drupal-dependencies
claudiu-cristea b597741
Drupal 10 backwards compatibility in test
claudiu-cristea 3080418
Documentation
claudiu-cristea 95e7df5
Simplify option: --dependent-type -> --type
claudiu-cristea 1de7280
Constant from base class '\RecursiveIteratorIterator' referenced via …
claudiu-cristea 83961b7
Document internals
claudiu-cristea 17b68c9
Document private methods
claudiu-cristea 2c85d1d
Use arrow function
claudiu-cristea 204e27c
Merge branch '13.x' into drupal-dependencies
claudiu-cristea File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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