diff --git a/.circleci/config.yml b/.circleci/config.yml index 449ea55487..d392784b64 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,6 +14,7 @@ defaults: &defaults PHP_EXTENSIONS_DISABLE: xdebug PHP_XDEBUG_MODE: off +#No longer used requires: &requires requires: - check_mergable @@ -173,7 +174,8 @@ workflows: # - test_80_drupal92_security: # <<: *requires - test: - <<: *requires + # Not used, for now. + # <<: *requires <<: *poststeps matrix: parameters: diff --git a/composer.json b/composer.json index a040b807c4..4e3992b626 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "consolidation/annotated-command": "^4.9.2", "consolidation/config": "^2.1.2 || ^3", "consolidation/filter-via-dot-access-data": "^2.0.2", - "consolidation/output-formatters": "^4.3.2", + "consolidation/output-formatters": "dev-use-command-directly-of as 4.x-dev", "consolidation/robo": "^4.0.6 || ^5", "consolidation/site-alias": "^4", "consolidation/site-process": "^5.2.0", @@ -88,7 +88,8 @@ "config": { "allow-plugins": { "composer/installers": true, - "cweagans/composer-patches": true + "cweagans/composer-patches": true, + "symfony/runtime": true }, "optimize-autoloader": true, "preferred-install": "dist", diff --git a/docs/commands.md b/docs/commands.md index 4ad62fc748..257b1ef5f5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,8 +2,8 @@ !!! tip - 1. Drush 13 expects commandfiles to use the [AutowireTrait](https://github.com/drush-ops/drush/blob/13.x/src/Commands/AutowireTrait.php) to inject Drupal and Drush dependencies. Prior versions used a [drush.services.yml file](https://www.drush.org/11.x/dependency-injection/#services-files) which is now deprecated and will be removed in Drush 14. - 1. Drush 12 expects all commandfiles in the `/src/Drush/` directory. The `Drush` subdirectory is a new requirement. + 1. Drush 13+ expects commandfiles to use the [AutowireTrait](https://github.com/drush-ops/drush/blob/13.x/src/Commands/AutowireTrait.php) to inject Drupal and Drush dependencies. Prior versions used a [drush.services.yml file](https://www.drush.org/11.x/dependency-injection/#services-files) which is now deprecated and will be removed in Drush 14. + 1. Drush 12+ expects all commandfiles in the `/src/Drush/` directory. The `Drush` subdirectory is a new requirement. Creating a new Drush command is easy. Follow the steps below. @@ -14,67 +14,38 @@ Creating a new Drush command is easy. Follow the steps below. 5. You may [inject dependencies](dependency-injection.md) into a command instance. 6. Write PHPUnit tests based on [Drush Test Traits](https://github.com/drush-ops/drush/blob/13.x/docs/contribute/unish.md#drush-test-traits). -## Attributes or Annotations -The following are both valid ways to declare a command: - -=== "PHP8 Attributes" - - ```php - use Drush\Attributes as CLI; - - /** - * Retrieve and display xkcd cartoons (attribute variant). - */ - #[CLI\Command(name: 'xkcd:fetch-attributes', aliases: ['xkcd-attributes'])] - #[CLI\Argument(name: 'search', description: 'Optional argument to retrieve the cartoons matching an index, keyword, or "random".')] - #[CLI\Option(name: 'image-viewer', description: 'Command to use to view images (e.g. xv, firefox).', suggestedValues: ['open', 'xv', 'firefox'])] - #[CLI\Option(name: 'google-custom-search-api-key', description: 'Google Custom Search API Key')] - #[CLI\Usage(name: 'drush xkcd', description: 'Retrieve and display the latest cartoon')] - #[CLI\Usage(name: 'drush xkcd sandwich', description: 'Retrieve and display cartoons about sandwiches.')] - public function fetch($search = null, $options = ['image-viewer' => 'open', 'google-custom-search-api-key' => 'AIza']) { - $this->doFetch($search, $options); - } - ``` - -=== "Annotations" - - ```php - /** - * @command xkcd:fetch - * @param $search Optional argument to retrieve the cartoons matching an index number, keyword, or "random". - * @option image-viewer Command to use to view images (e.g. xv, firefox). - * @option google-custom-search-api-key Google Custom Search API Key. - * @usage drush xkcd - * Retrieve and display the latest cartoon. - * @usage drush xkcd sandwich - * Retrieve and display cartoons about sandwiches. - * @aliases xkcd - */ - public function fetch($search = null, $options = ['image-viewer' => 'open', 'google-custom-search-api-key' => 'AIza']) { - $this->doFetch($search, $options); - } - ``` +## Symfony Console Commands -- A commandfile that will only be used on PHP8+ should [use PHP Attributes](https://github.com/drush-ops/drush/pull/4821) instead of Annotations. -- [See Attributes provided by Drush core](https://www.drush.org/api/Drush/Attributes.html). Custom code can supply additional Attribute classes, which may then be added to any command. For example see [InteractConfigName](https://github.com/drush-ops/drush/blob/13.x/src/Attributes/InteractConfigName.php) which is used by [ConfigCommands](https://github.com/drush-ops/drush/blob/8b77c9abe6639de42a198c7e69565f09dcf5f22d/src/Commands/config/ConfigCommands.php#L98). +Drush 14+ deprecates old-style Annotated Commands in favor of pure [Symfony Console commands](https://symfony.com/doc/current/console.html). This implies: + +- Each command lives in its own class file +- The command class extends `Symfony\Component\Console\Command\Command` directly. The base class `DrushCommands` is deprecated. +- The command class should use Console's #[AsCommand] Attribute to declare its name, aliases, and hidden status. The old #[Command] Attribute is deprecated. +- Options and Arguments moved from Attributes to a configure() method on the command class +- The main logic of the command moved to an execute() method on the command class. +- User interaction now happens in an interact() method on the command class. +- Drush and Drupal services may still be autowired. This is how you access the logger. Build own $io as needed. +- Commands that wish to offer multiple _output formats_ (yes please!) should (Example: _TwigUnusedCommand_, + _SqlDumpCommand_): + - inject `FormatterManager` in __construct() + - `use FormatterTrait` + - call `$this->configureFormatter()` in `configure()` in order to automatically add the needed options. + - `execute()` is boilerplate. By convention, do your work in a `doExecute()` method instead. +- [See Optionsets provided by Drush core](https://github.com/drush-ops/drush/blob/13.x/src/Commands/OptionsSets.php). Custom code can supply additional Optionset methods, which any command may choose to use. ## Altering Command Info -Drush command info (annotations/attributes) can be altered from other modules. This is done by creating and registering _command info alterers_. Alterers are classes that are able to intercept and manipulate an existing command annotation. -In the module that wants to alter a command info, add a class that: +Drush command info can be altered from other modules. This is done by creating and registering a command definition listener. Listeners are dispatched once after non-bootstrap commands are instantiated and once again after bootstrap commands are instantiated. -1. The class namespace, relative to base namespace, should be `Drupal\\Drush\CommandInfoAlterers` and the class file should be located under the `src/Drush/CommandInfoAlterers` directory. -1. The filename must have a name like FooCommandInfoAlterer.php. The prefix `Foo` can be whatever string you want. The file must end in `CommandInfoAlterer.php`. -1. The class must implement the `\Consolidation\AnnotatedCommand\CommandInfoAltererInterface`. -1. Implement the alteration logic in the `alterCommandInfo()` method. -1. Along with the alter code, it's strongly recommended to log a debug message explaining what exactly was altered. This makes things easier on others who may need to debug the interaction of the alter code with other modules. Also it's a good practice to inject the the logger in the class constructor. - -For an example, see [WootCommandInfoAlterer](https://github.com/drush-ops/drush/blob/13.x/sut/modules/unish/woot/src/Drush/CommandInfoAlterers/WootCommandInfoAlterer.php) provided by the testing 'woot' module. +In the module that wants to alter a command info, add a class that: -## Symfony Console Commands +1. The class namespace, relative to base namespace, should be `Drupal\\Drush\Listeners` and the class file should be located under the `src/Drush/Listeners` directory. +1. The filename must have a name like FooListener.php. The prefix `Foo` can be whatever string you want. The file must end in `Listener.php`. +1. The class should implement the `#[AsListener]` PHP Attribute. +1. Implement the alteration logic via a `__invoke(ConsoleDefinitionsEvent $event)` method. +1. Along with the alter code, it's strongly recommended to log a debug message explaining what exactly was altered. This makes things easier on others who may need to debug the interaction of the alter code with other modules. Also it's a good practice to inject the logger in the class constructor. -Drush lists and runs Symfony Console commands, in addition to more typical annotated commands. -See [GreetCommands](https://github.com/drush-ops/drush/blob/13.x/sut/modules/unish/woot/src/Drush/Commands/GreetCommands.php) as an example. Note that these commands must conform to the usual class name and class namespace requirements. You might need to extend the Console class if you can't rename and move it. +For an example, see [WootDefinitionListener](https://github.com/drush-ops/drush/blob/13.x/sut/modules/unish/woot/src/Drush/Liseners/WootDefinitionListener.php) provided by the testing 'woot' module. ## Auto-discovered commands (PSR4) diff --git a/src-symfony-compatibility/v6/Style/DrushStyle.php b/src-symfony-compatibility/v6/Style/DrushStyle.php index 5b35bce38a..0291cbf540 100644 --- a/src-symfony-compatibility/v6/Style/DrushStyle.php +++ b/src-symfony-compatibility/v6/Style/DrushStyle.php @@ -37,8 +37,7 @@ public function confirm(string $question, bool $default = true, string $yes = 'Y return confirm($question, $default, $yes, $no, $required, $validate, $hint); } - #[Deprecated('Use select() or multiselect() instead.')] - public function choice(string $question, array $choices, mixed $default = null, bool $multiSelect = false, int $scroll = 10, ?\Closure $validate = null, string $hint = '', bool|string $required = true): mixed + public function choice(string $question, array $choices, mixed $default = null, bool $multiSelect = false, int $scroll = 15, ?\Closure $validate = null, string $hint = '', bool|string $required = true): mixed { if ($multiSelect) { // For backward compat. Deprecated. diff --git a/src/Application.php b/src/Application.php index cf9170ab36..9f2e295a8a 100644 --- a/src/Application.php +++ b/src/Application.php @@ -11,6 +11,7 @@ use Drush\Boot\DrupalBootLevels; use Drush\Command\RemoteCommandProxy; use Drush\Config\ConfigAwareTrait; +use Drush\Event\ConsoleDefinitionsEvent; use Drush\Runtime\RedispatchHook; use Drush\Runtime\ServiceManager; use Drush\Runtime\TildeExpansionHook; @@ -309,6 +310,8 @@ public function configureAndRegisterCommands(InputInterface $input, OutputInterf // any of the configuration steps we do here. $this->configureIO($input, $output); + $this->addListeners($commandfileSearchpath); + // Directly add the yaml-cli commands. $this->addCommands($this->serviceManager->instantiateYamlCliCommands()); @@ -327,6 +330,9 @@ public function configureAndRegisterCommands(InputInterface $input, OutputInterf // Note that Robo::register can accept either Annotated Command // command handlers or Symfony Console Command objects. Robo::register($this, $commandInstances); + + // Dispatch our custom event. It also fires later in \Drush\Boot\DrupalBoot8::bootstrapDrupalFull. + Drush::getContainer()->get('eventDispatcher')->dispatch(new ConsoleDefinitionsEvent($this), ConsoleDefinitionsEvent::class); } /** @@ -338,4 +344,34 @@ public function renderThrowable(\Throwable $e, OutputInterface $output): void $this->doRenderThrowable($e, $output); } + + // Discover event listeners, and add those that do not require bootstrap. + protected function addListeners($commandfileSearchpath): void + { + $listenerClasses = $this->serviceManager->discoverListeners($commandfileSearchpath, '\Drush'); + $listenerClasses = $this->serviceManager->filterListeners($listenerClasses); + $this->serviceManager->addListeners($listenerClasses, Drush::getContainer()); + } + + /** + * Remove a command. Initially used by WootDefinitionListener and its test. + * + * An alternative would be console.excluded https://github.com/search?q=repo%3AHuttopia%2Fconsole-bundle%20console.excluded&type=code + */ + public function remove(string $id): void + { + $rf = new \ReflectionProperty(\Symfony\Component\Console\Application::class, 'commands'); + $rf->setAccessible(true); + $commands = $rf->getValue($this); + unset($commands[$id]); + $rf->setValue($this, $commands); + } + + /** + * A base URL for help. + */ + public function getDocsBaseUrl(): string + { + return 'https://www.drush.org/latest'; + } } diff --git a/src/Attributes/DefaultFields.php b/src/Attributes/DefaultFields.php index 58b0e02a9f..91da4866d7 100644 --- a/src/Attributes/DefaultFields.php +++ b/src/Attributes/DefaultFields.php @@ -5,7 +5,9 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated(replacement: 'Call \Drush\Formatters\FormatterTrait::configureFormatter during configure()')] #[Attribute(Attribute::TARGET_METHOD)] class DefaultFields extends \Consolidation\AnnotatedCommand\Attributes\DefaultFields { diff --git a/src/Attributes/DefaultTableFields.php b/src/Attributes/DefaultTableFields.php index 7e340474bf..ae32282dfa 100644 --- a/src/Attributes/DefaultTableFields.php +++ b/src/Attributes/DefaultTableFields.php @@ -5,7 +5,9 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated(replacement: 'Call \Drush\Formatters\FormatterTrait::configureFormatter during configure()')] #[Attribute(Attribute::TARGET_METHOD)] class DefaultTableFields extends \Consolidation\AnnotatedCommand\Attributes\DefaultTableFields { diff --git a/src/Attributes/FieldLabels.php b/src/Attributes/FieldLabels.php index 6e7a26a1b2..4ecc39d7dc 100644 --- a/src/Attributes/FieldLabels.php +++ b/src/Attributes/FieldLabels.php @@ -5,7 +5,9 @@ namespace Drush\Attributes; use Attribute; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated(replacement: 'Call \Drush\Formatters\FormatterTrait::configureFormatter during configure()')] #[Attribute(Attribute::TARGET_METHOD)] class FieldLabels extends \Consolidation\AnnotatedCommand\Attributes\FieldLabels { diff --git a/src/Attributes/FilterDefaultField.php b/src/Attributes/FilterDefaultField.php index 70d2749cc0..52096a21ff 100644 --- a/src/Attributes/FilterDefaultField.php +++ b/src/Attributes/FilterDefaultField.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class FilterDefaultField extends \Consolidation\AnnotatedCommand\Attributes\FilterDefaultField { } diff --git a/src/Attributes/Format.php b/src/Attributes/Format.php index 0c121d59c1..b2f16bc01e 100644 --- a/src/Attributes/Format.php +++ b/src/Attributes/Format.php @@ -7,7 +7,6 @@ use Attribute; use Consolidation\AnnotatedCommand\Parser\CommandInfo; use Consolidation\OutputFormatters\Options\FormatterOptions; -use Drush\Boot\Kernels; use JetBrains\PhpStorm\ExpectedValues; #[Attribute(Attribute::TARGET_METHOD)] diff --git a/src/Attributes/OptionsetSql.php b/src/Attributes/OptionsetSql.php index 98d0f5d6c4..2f26a0755f 100644 --- a/src/Attributes/OptionsetSql.php +++ b/src/Attributes/OptionsetSql.php @@ -7,8 +7,10 @@ use Attribute; use Consolidation\AnnotatedCommand\Parser\CommandInfo; use Drush\Commands\DrushCommands; +use JetBrains\PhpStorm\Deprecated; -#[Attribute(Attribute::TARGET_METHOD)] +#[Deprecated(replacement: 'Directly add options by calling \Drush\Commands\OptionSets::sql during configure()')] +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class OptionsetSql { public static function handle(\ReflectionAttribute $attribute, CommandInfo $commandInfo) diff --git a/src/Attributes/OptionsetTableSelection.php b/src/Attributes/OptionsetTableSelection.php index 0cec9996d4..7af27d9192 100644 --- a/src/Attributes/OptionsetTableSelection.php +++ b/src/Attributes/OptionsetTableSelection.php @@ -7,7 +7,9 @@ use Attribute; use Consolidation\AnnotatedCommand\Parser\CommandInfo; use Drush\Commands\DrushCommands; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated(replacement: 'Call \Drush\Commands\OptionSets::tableSelection during configure()')] #[Attribute(Attribute::TARGET_METHOD)] class OptionsetTableSelection { @@ -18,7 +20,6 @@ public static function handle(\ReflectionAttribute $attribute, CommandInfo $comm $commandInfo->addOption('tables-key', 'A key in the $tables array.', [], DrushCommands::REQ); $commandInfo->addOption('skip-tables-list', 'A comma-separated list of tables to exclude completely.', [], DrushCommands::REQ); $commandInfo->addOption('structure-tables-list', 'A comma-separated list of tables to include for structure, but not data.', [], DrushCommands::REQ); - $commandInfo->addOption('skip-tables-list', 'A comma-separated list of tables to exclude completely.', [], DrushCommands::REQ); $commandInfo->addOption('tables-list', 'A comma-separated list of tables to transfer.', [], DrushCommands::REQ); } } diff --git a/src/Attributes/Topics.php b/src/Attributes/Topics.php index 9f974ba950..1aac0a0f69 100644 --- a/src/Attributes/Topics.php +++ b/src/Attributes/Topics.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class Topics extends \Consolidation\AnnotatedCommand\Attributes\Topics { } diff --git a/src/Attributes/ValidateEntityLoad.php b/src/Attributes/ValidateEntityLoad.php index a8edc12aee..0e3346f0a4 100644 --- a/src/Attributes/ValidateEntityLoad.php +++ b/src/Attributes/ValidateEntityLoad.php @@ -8,7 +8,9 @@ use Consolidation\AnnotatedCommand\CommandData; use Consolidation\AnnotatedCommand\CommandError; use Drush\Utils\StringUtils; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated(replacement: 'Copy \Drush\Commands\core\ImageFlushCommand::validateEntityLoad into command and call during execute()')] #[Attribute(Attribute::TARGET_METHOD)] class ValidateEntityLoad extends ValidatorBase implements ValidatorInterface { diff --git a/src/Attributes/ValidateFileExists.php b/src/Attributes/ValidateFileExists.php index 2e2cc613c2..d05debfe6d 100644 --- a/src/Attributes/ValidateFileExists.php +++ b/src/Attributes/ValidateFileExists.php @@ -7,7 +7,9 @@ use Attribute; use Consolidation\AnnotatedCommand\CommandData; use Consolidation\AnnotatedCommand\CommandError; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated(replacement: 'Copy \Drush\Commands\core\ImageDeriveCommand::validateFileExists into command and call during execute()')] #[Attribute(Attribute::TARGET_METHOD)] class ValidateFileExists extends ValidatorBase implements ValidatorInterface { diff --git a/src/Attributes/ValidateModulesEnabled.php b/src/Attributes/ValidateModulesEnabled.php index d3cd6b1267..aaf44ff887 100644 --- a/src/Attributes/ValidateModulesEnabled.php +++ b/src/Attributes/ValidateModulesEnabled.php @@ -7,7 +7,9 @@ use Attribute; use Consolidation\AnnotatedCommand\CommandData; use Consolidation\AnnotatedCommand\CommandError; +use JetBrains\PhpStorm\Deprecated; +#[Deprecated(replacement: 'Copy \Drush\Commands\core\ImageFlushCommand::validateModulesEnabled into command and call during execute()')] #[Attribute(Attribute::TARGET_METHOD)] class ValidateModulesEnabled extends ValidatorBase implements ValidatorInterface { diff --git a/src/Boot/DrupalBoot8.php b/src/Boot/DrupalBoot8.php index 496deb9625..bf7aabc747 100644 --- a/src/Boot/DrupalBoot8.php +++ b/src/Boot/DrupalBoot8.php @@ -13,11 +13,10 @@ use Drush\Config\ConfigLocator; use Drush\Drupal\DrushLoggerServiceProvider; use Drush\Drush; +use Drush\Event\ConsoleDefinitionsEvent; use Drush\Runtime\LegacyServiceFinder; use Drush\Runtime\LegacyServiceInstantiator; use Drush\Runtime\ServiceManager; -use Psr\Log\LoggerAwareInterface; -use Psr\Log\LoggerInterface; use Robo\Robo; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -215,12 +214,20 @@ public function bootstrapDrupalFull(BootstrapManager $manager): void // Directly add the Drupal core bootstrapped commands. Drush::getApplication()->addCommands($this->serviceManager->instantiateDrupalCoreBootstrappedCommands()); + $this->addBootstrapListeners(); + $this->addDrupalModuleDrushCommands($manager); + // Dispatch our custom event. It also fires earlier in \Drush\Application::configureAndRegisterCommands. + Drush::getContainer()->get('eventDispatcher')->dispatch(new ConsoleDefinitionsEvent(Drush::getApplication()), ConsoleDefinitionsEvent::class); + // Set a default account to make sure the correct timezone is set $this->kernel->getContainer()->get('current_user')->setAccount(new AnonymousUserSession()); } + /** + * Adds module supplied commands, as well as Drush Console commands that require bootstrap. + */ public function addDrupalModuleDrushCommands(BootstrapManager $manager): void { $application = Drush::getApplication(); @@ -323,4 +330,17 @@ public function bootstrapDrupalSite(BootstrapManager $manager) { $this->bootstrapDoDrupalSite($manager); } + + // Add the Listeners that require bootstrap. + public function addBootstrapListeners(): void + { + $listenersInThisModule = []; + $moduleHandler = \Drupal::moduleHandler(); + foreach ($moduleHandler->getModuleList() as $moduleId => $extension) { + $path = DRUPAL_ROOT . '/' . $extension->getPath() . '/src/Drush'; + $listenersInThisModule = array_merge($listenersInThisModule, $this->serviceManager->discoverListeners([$path], "\Drupal\\$moduleId\Drush")); + } + $classes = $this->serviceManager->bootstrapListenerClasses(); + $this->serviceManager->addListeners(array_merge($listenersInThisModule, $classes), Drush::getContainer(), \Drupal::getContainer()); + } } diff --git a/src/Commands/AutowireTrait.php b/src/Commands/AutowireTrait.php index d4d77ef095..f34f82425c 100644 --- a/src/Commands/AutowireTrait.php +++ b/src/Commands/AutowireTrait.php @@ -21,8 +21,6 @@ trait AutowireTrait * * @param ContainerInterface $container * The service container this instance should use. - * - * @return static */ public static function create(ContainerInterface $container) { diff --git a/src/Commands/OptionSets.php b/src/Commands/OptionSets.php new file mode 100644 index 0000000000..e6e5b808f6 --- /dev/null +++ b/src/Commands/OptionSets.php @@ -0,0 +1,27 @@ +addOption('database', '', InputOption::VALUE_REQUIRED, 'The DB connection key if using multiple connections in settings.php.', 'default'); + $command->addOption('db-url', '', InputOption::VALUE_REQUIRED, 'A Drupal 6 style database URL. For example mysql://root:pass@localhost:port/dbname'); + $command->addOption('target', '', InputOption::VALUE_REQUIRED, 'The name of a target within the specified database connection.', 'default'); + $command->addOption('show-passwords', '', InputOption::VALUE_NONE, 'Show password on the CLI. Useful for debugging.'); + } + + public static function tableSelection(Command $command): void + { + $command->addOption('skip-tables-key', '', InputOption::VALUE_REQUIRED, 'A key in the $skip_tables array. @see [Site aliases](../site-aliases.md)'); + $command->addOption('structure-tables-key', '', InputOption::VALUE_REQUIRED, 'A key in the $structure_tables array. @see [Site aliases](../site-aliases.md)'); + $command->addOption('tables-key', '', InputOption::VALUE_REQUIRED, 'A key in the $tables array.'); + $command->addOption('skip-tables-list', '', InputOption::VALUE_REQUIRED, 'A comma-separated list of tables to exclude completely.'); + $command->addOption('structure-tables-list', '', InputOption::VALUE_REQUIRED, 'A comma-separated list of tables to include for structure, but not data.'); + $command->addOption('tables-list', '', InputOption::VALUE_REQUIRED, 'A comma-separated list of tables to transfer.', []); + } +} diff --git a/src/Commands/config/ConfigCommands.php b/src/Commands/config/ConfigCommands.php index a2830f7cf6..c07f052d24 100644 --- a/src/Commands/config/ConfigCommands.php +++ b/src/Commands/config/ConfigCommands.php @@ -34,7 +34,6 @@ use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Path; -use Symfony\Component\Yaml\Parser; final class ConfigCommands extends DrushCommands implements StdinAwareInterface { @@ -44,6 +43,7 @@ final class ConfigCommands extends DrushCommands implements StdinAwareInterface const INTERACT_CONFIG_NAME = 'interact-config-name'; const VALIDATE_CONFIG_NAME = 'validate-config-name'; + #[Deprecated(reason: 'Use ConfigGetCommand::NAME')] const GET = 'config:get'; const SET = 'config:set'; const EDIT = 'config:edit'; @@ -83,99 +83,6 @@ public function getImportTransformer(): ImportStorageTransformer return $this->importStorageTransformer; } - /** - * Display a config value, or a whole configuration object. - */ - #[CLI\Command(name: self::GET, aliases: ['cget','config-get'])] - #[CLI\Argument(name: 'config_name', description: 'The config object name, for example system.site.')] - #[CLI\Argument(name: 'key', description: 'The config key, for example page.front. Optional.')] - #[CLI\Option(name: 'source', description: 'The config storage source to read.')] - #[CLI\Option(name: 'include-overridden', description: 'Apply module and settings.php overrides to values.')] - #[CLI\Usage(name: 'drush config:get system.site', description: 'Displays the system.site config.')] - #[CLI\Usage(name: 'drush config:get system.site page.front', description: 'Gets system.site:page.front value.')] - #[CLI\Complete(method_name_or_callable: 'configComplete')] - #[CLI\ValidateConfigName()] - #[CLI\InteractConfigName()] - public function get($config_name, $key = '', $options = ['format' => 'yaml', 'source' => 'active', 'include-overridden' => false]) - { - // Displaying overrides only applies to active storage. - $factory = $this->getConfigFactory(); - $config = $options['include-overridden'] ? $factory->get($config_name) : $factory->getEditable($config_name); - $value = $config->get($key); - // @todo If the value is TRUE (for example), nothing gets printed. Is this yaml formatter's fault? - return $key ? ["$config_name:$key" => $value] : $value; - } - - /** - * Save a config value directly. Does not perform a config import. - */ - #[CLI\Command(name: self::SET, aliases: ['cset', 'config-set'])] - #[CLI\Argument(name: 'config_name', description: 'The config object name, for example system.site.')] - #[CLI\Argument(name: 'key', description: 'The config key, for example page.front. Use ? if you are updating multiple top-level keys.')] - #[CLI\Argument(name: 'value', description: 'The value to assign to the config key. Use - to read from Stdin.')] - #[CLI\Option(name: 'input-format', description: 'Format to parse the object. Recognized values: string, yaml. Since JSON is a subset of YAML, $value may be in JSON format.', suggestedValues: ['string', 'json'])] - #[CLI\Usage(name: 'drush config:set system.site name MySite', description: 'Sets a value for the key name of system.site config object.')] - #[CLI\Usage(name: 'drush config:set system.site page.front /path/to/page', description: 'Sets the given URL path as value for the config item with key page.front of system.site config object.')] - #[CLI\Usage(name: 'drush config:set system.site \'[]\'', description: 'Sets the given key to an empty array.')] - #[CLI\Usage(name: 'drush config:set system.site \'NULL\'', description: 'Sets the given key to NULL.')] - #[CLI\Usage(name: 'drush config:set --input-format=yaml user.role.authenticated permissions [foo,bar]', description: 'Use a sequence as value for the key permissions of user.role.authenticated config object.')] - #[CLI\Usage(name: "drush config:set --input-format=yaml system.site page {403: '403', front: home}", description: 'Use a mapping as value for the key page of system.site config object.')] - #[CLI\Usage(name: 'drush config:set --input-format=yaml user.role.authenticated ? "{label: \'Auth user\', weight: 5}"', description: 'Update two top level keys (label, weight) in the system.site config object.')] - #[CLI\Usage(name: 'cat tmp.yml | drush config:set --input-format=yaml user.mail ? -', description: 'Update the user.mail config object in its entirety.')] - #[CLI\Complete(method_name_or_callable: 'configComplete')] - public function set($config_name, $key, $value, $options = ['input-format' => 'string']) - { - $data = $value; - - if (!isset($data)) { - throw new \Exception(dt('No config value specified.')); - } - - // Special flag indicating that the value has been passed via STDIN. - if ($data === '-') { - $data = $this->stdin()->contents(); - } - - // Special handling for null. - if (strtolower($data) === 'null') { - $data = null; - } - - // Special handling for empty array. - if ($data == '[]') { - $data = []; - } - - if ($options['input-format'] === 'yaml') { - $parser = new Parser(); - $data = $parser->parse($data); - } - - $config = $this->getConfigFactory()->getEditable($config_name); - // Check to see if config key already exists. - $new_key = $config->get($key) === null; - $simulate = $this->getConfig()->simulate(); - - if ($key == '?' && !empty($data) && $this->io()->confirm(dt('Do you want to update or set multiple keys on !name config.', ['!name' => $config_name]))) { - foreach ($data as $data_key => $val) { - $config->set($data_key, $val); - } - return $simulate ? self::EXIT_SUCCESS : $config->save(); - } else { - $confirmed = false; - if ($config->isNew() && $this->io()->confirm(dt('!name config does not exist. Do you want to create a new config object?', ['!name' => $config_name]))) { - $confirmed = true; - } elseif ($new_key && $this->io()->confirm(dt('!key key does not exist in !name config. Do you want to create a new config key?', ['!key' => $key, '!name' => $config_name]))) { - $confirmed = true; - } elseif ($this->io()->confirm(dt('Do you want to update !key key in !name config?', ['!key' => $key, '!name' => $config_name]))) { - $confirmed = true; - } - if ($confirmed && !$simulate) { - return $config->set($key, $data)->save(); - } - } - } - /** * Open a config file in a text editor. Edits are imported after closing editor. */ diff --git a/src/Commands/config/ConfigGetCommand.php b/src/Commands/config/ConfigGetCommand.php new file mode 100644 index 0000000000..18551dbbe5 --- /dev/null +++ b/src/Commands/config/ConfigGetCommand.php @@ -0,0 +1,70 @@ +addArgument('config_name', InputArgument::REQUIRED, 'The config object name, for example system.site.') + ->addArgument('key', InputArgument::OPTIONAL, 'The config key, for example page.front. Optional') + ->addOption('source', null, InputOption::VALUE_REQUIRED, 'The config storage source to read.', 'active') + ->addOption('include-overridden', null, InputOption::VALUE_NEGATABLE, 'Apply module and settings.php overrides to values') + ->addUsage('config:get system.site page.front') + ->addUsage('config:get system.site'); + $formatterOptions = (new FormatterOptions()); + $this->configureFormatter(UnstructuredListData::class, $formatterOptions); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $data = $this->doExecute($input); + $this->writeFormattedOutput($input, $output, $data); + return self::SUCCESS; + } + + protected function doExecute($input): string|array + { + $config_name = $input->getArgument('config_name'); + $this->validateConfigName($config_name); + $key = $input->getArgument('key'); + + // Displaying overrides only applies to active storage. + $config = $input->getOption('include-overridden') ? $this->configFactory->get($config_name) : $this->configFactory->getEditable($config_name); + $value = $config->get($key); + return $key ? ["$config_name:$key" => $value] : $value; + } +} diff --git a/src/Commands/config/ConfigNameTrait.php b/src/Commands/config/ConfigNameTrait.php new file mode 100644 index 0000000000..9e7882e034 --- /dev/null +++ b/src/Commands/config/ConfigNameTrait.php @@ -0,0 +1,45 @@ +hasArgument('config_name') && empty($input->getArgument('config_name'))) { + $io = new DrushStyle($input, $output); + // Classes using this trait must have a $configFactory property. + $config_names = $this->configFactory->listAll(); + $choice = $io->suggest('Choose a configuration', array_combine($config_names, $config_names), scroll: 200, required: true); + $input->setArgument('config_name', $choice); + } + } + + // Call this from the execute method of the command that uses this trait. + protected function validateConfigName(string|array $config_name): void + { + $names = StringUtils::csvToArray($config_name); + foreach ($names as $name) { + $config = $this->configFactory->get($name); + if ($config->isNew()) { + $msg = dt('Config !name does not exist', ['!name' => $name]); + throw new InvalidArgumentException($msg); + } + } + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('config_name')) { + $suggestions->suggestValues($this->configFactory->listAll()); + } + } +} diff --git a/src/Commands/config/ConfigSetCommand.php b/src/Commands/config/ConfigSetCommand.php new file mode 100644 index 0000000000..86a6fefe66 --- /dev/null +++ b/src/Commands/config/ConfigSetCommand.php @@ -0,0 +1,116 @@ +addArgument('config_name', InputArgument::REQUIRED, 'The config object name, for example system.site.') + ->addArgument('key', InputArgument::REQUIRED, 'The config key, for example page.front. Use ? if you are updating multiple top-level keys.') + ->addArgument('value', InputArgument::REQUIRED, 'The value to assign to the config key. Use - to read from Stdin.') + ->addOption('input-format', null, InputOption::VALUE_REQUIRED, 'Format to parse the object. Recognized values: string, yaml. Since JSON is a subset of YAML, $value may be in JSON format.', 'string') + // @todo Move the old descriptions of these Usages into setHelp(). + ->addUsage('config:set system.site name MySite') + ->addUsage('config:set user.role.anonymous permissions \'[]\'') + ->addUsage('config:set system.site name \'NULL\'') + ->addUsage("config:set --input-format=yaml system.site page {403: '403', front: home}") + ->addUsage('config:set --input-format=yaml user.role.authenticated permissions [foo,bar]') + ->addUsage('config:set --input-format=yaml user.role.authenticated ? "{label: \'Auth user\', weight: 5}') + ->addUsage('cat tmp.yml | drush config:set --input-format=yaml user.mail ? -'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $io = new DrushStyle($input, $output); + + $data = $input->getArgument('value'); + + // Special flag indicating that the value has been passed via STDIN. + if ($data === '-') { + // See https://github.com/symfony/symfony/issues/37835#issuecomment-674386588. + // If testing this will get input added by `CommandTester::setInputs` method. + $inputStream = ($input instanceof StreamableInputInterface) ? $input->getStream() : null; + // If nothing from input stream use STDIN instead. + $inputStream = $inputStream ?? STDIN; + $data = stream_get_contents($inputStream); + } + + // Special handling for null. + if (strtolower($data) === 'null') { + $data = null; + } + + // Special handling for empty array. + if ($data == '[]') { + $data = []; + } + + if ($input->getOption('input-format') === 'yaml') { + $parser = new Parser(); + $data = $parser->parse($data); + } + + $config_name = $input->getArgument('config_name'); + $config = $this->configFactory->getEditable($config_name); + // Check to see if config key already exists. + $key = $input->getArgument('key'); + $new_key = $config->get($key) === null; + $simulate = $this->drushConfig->simulate(); + + if ($key == '?' && !empty($data) && $io->confirm(dt('Do you want to update or set multiple keys on !name config.', ['!name' => $config_name]))) { + foreach ($data as $data_key => $val) { + $config->set($data_key, $val); + } + if (!$simulate) { + $config->save(); + } + } else { + $confirmed = false; + if ($config->isNew() && $io->confirm(dt('!name config does not exist. Do you want to create a new config object?', ['!name' => $config_name]))) { + $confirmed = true; + } elseif ($new_key && $io->confirm(dt('!key key does not exist in !name config. Do you want to create a new config key?', ['!key' => $key, '!name' => $config_name]))) { + $confirmed = true; + } elseif ($io->confirm(dt('Do you want to update !key key in !name config?', ['!key' => $key, '!name' => $config_name]))) { + $confirmed = true; + } + if ($confirmed && !$simulate) { + $config->set($key, $data)->save(); + } + } + return self::SUCCESS; + } +} diff --git a/src/Commands/core/CoreCommands.php b/src/Commands/core/CoreCommands.php index 792a5726e0..9fa9d11db9 100644 --- a/src/Commands/core/CoreCommands.php +++ b/src/Commands/core/CoreCommands.php @@ -4,12 +4,12 @@ namespace Drush\Commands\core; +use Consolidation\OutputFormatters\Options\FormatterOptions; use Consolidation\OutputFormatters\StructuredData\PropertyList; +use Consolidation\OutputFormatters\StructuredData\RowsOfFields; use Drush\Attributes as CLI; use Drush\Commands\DrushCommands; use Drush\Drush; -use Consolidation\OutputFormatters\StructuredData\RowsOfFields; -use Consolidation\OutputFormatters\Options\FormatterOptions; final class CoreCommands extends DrushCommands { diff --git a/src/Commands/core/ImageCommands.php b/src/Commands/core/ImageCommands.php index 87f7636594..cc446e3d70 100644 --- a/src/Commands/core/ImageCommands.php +++ b/src/Commands/core/ImageCommands.php @@ -4,85 +4,13 @@ namespace Drush\Commands\core; -use Consolidation\AnnotatedCommand\AnnotationData; -use Consolidation\AnnotatedCommand\Hooks\HookManager; -use Drupal\image\Entity\ImageStyle; -use Drush\Attributes as CLI; use Drush\Commands\DrushCommands; -use Drush\Utils\StringUtils; -use Symfony\Component\Console\Input\InputInterface; -use Drush\Boot\DrupalBootLevels; +use JetBrains\PhpStorm\Deprecated; final class ImageCommands extends DrushCommands { - const FLUSH = 'image:flush'; + #[Deprecated(replacement: 'ImageDeriveCommand::NAME')] const DERIVE = 'image:derive'; - - /** - * Flush all derived images for a given style. - */ - #[CLI\Command(name: self::FLUSH, aliases: ['if', 'image-flush'])] - #[CLI\Argument(name: 'style_names', description: 'A comma delimited list of image style machine names. If not provided, user may choose from a list of names.')] - #[CLI\Option(name: 'all', description: 'Flush all derived images')] - #[CLI\Usage(name: 'drush image:flush', description: 'Pick an image style and then delete its derivatives.')] - #[CLI\Usage(name: 'drush image:flush thumbnail,large', description: 'Delete all thumbnail and large derivatives.')] - #[CLI\Usage(name: 'drush image:flush --all', description: 'Flush all derived images. They will be regenerated on demand.')] - #[CLI\ValidateEntityLoad(entityType: 'image_style', argumentName: 'style_names')] - #[CLI\ValidateModulesEnabled(modules: ['image'])] - #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] - public function flush($style_names, $options = ['all' => false]): void - { - foreach (ImageStyle::loadMultiple(StringUtils::csvToArray($style_names)) as $style_name => $style) { - $style->flush(); - $this->logger()->success(dt('Image style !style_name flushed', ['!style_name' => $style_name])); - } - } - - #[CLI\Hook(type: HookManager::INTERACT, target: self::FLUSH)] - public function interactFlush(InputInterface $input, $output): void - { - $styles = array_keys(ImageStyle::loadMultiple()); - $style_names = $input->getArgument('style_names'); - - if (empty($style_names)) { - $styles_all = $styles; - array_unshift($styles_all, 'all'); - $choices = array_combine($styles_all, $styles_all); - $style_names = $this->io()->select(dt("Choose a style to flush"), $choices, 'all', scroll: 20); - if ($style_names == 'all') { - $style_names = implode(',', $styles); - } - $input->setArgument('style_names', $style_names); - } - } - - #[CLI\Hook(type: HookManager::POST_INITIALIZE, target: self::FLUSH)] - public function postInit(InputInterface $input, AnnotationData $annotationData): void - { - // Needed for non-interactive calls.We use post-init phase because interact() methods run early - if ($input->getOption('all')) { - $styles = array_keys(ImageStyle::loadMultiple()); - $input->setArgument('style_names', implode(",", $styles)); - } - } - - /** - * Create an image derivative. - */ - #[CLI\Command(name: self::DERIVE, aliases: ['id', 'image-derive'])] - #[CLI\Argument(name: 'style_name', description: 'An image style machine name.')] - #[CLI\Argument(name: 'source', description: 'Path to a source image. Optionally prepend stream wrapper scheme. Relative paths calculated from Drupal root.')] - #[CLI\Usage(name: 'drush image:derive thumbnail core/themes/bartik/screenshot.png', description: 'Save thumbnail sized derivative of logo image.')] - #[CLI\ValidateFileExists(argName: 'source')] - #[CLI\ValidateEntityLoad(entityType: 'image_style', argumentName: 'style_name')] - #[CLI\ValidateModulesEnabled(modules: ['image'])] - #[CLI\Bootstrap(level: DrupalBootLevels::FULL)] - public function derive($style_name, $source) - { - $image_style = ImageStyle::load($style_name); - $derivative_uri = $image_style->buildUri($source); - if ($image_style->createDerivative($source, $derivative_uri)) { - return $derivative_uri; - } - } + #[Deprecated(replacement: 'ImageFlushCommand::NAME')] + const FLUSH = ImageFlushCommand::NAME; } diff --git a/src/Commands/core/ImageDeriveCommand.php b/src/Commands/core/ImageDeriveCommand.php new file mode 100644 index 0000000000..2dc6b36684 --- /dev/null +++ b/src/Commands/core/ImageDeriveCommand.php @@ -0,0 +1,85 @@ +addArgument('style_name', InputArgument::REQUIRED, 'An image style machine name.') + ->addArgument('source', InputArgument::REQUIRED, 'Path to a source image. Optionally prepend stream wrapper scheme. Relative paths calculated from Drupal root.') + ->addUsage('image:derive thumbnail core/themes/bartik/screenshot.png'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $io = new DrushStyle($input, $output); + + $this->validateModulesEnabled(['image']); + $this->validateEntityLoad([$input->getArgument('style_name')], 'image_style'); + $this->validateFileExists($input->getArgument('source')); + + $image_style = $this->entityTypeManager->getStorage('image_style')->load($input->getArgument('style_name')); + $derivative_uri = $image_style->buildUri($input->getArgument('source')); + if ($image_style->createDerivative($input->getArgument('source'), $derivative_uri)) { + $io->success(dt('Derivative image created: !uri', ['!uri' => $derivative_uri])); + return self::SUCCESS; + } + return self::FAILURE; + } + + protected function validateFileExists(string $path): void + { + if (!empty($path) && !file_exists($path)) { + $msg = dt('File not found: !path', ['!path' => $path]); + throw new InvalidArgumentException($msg); + } + } + + protected function validateEntityLoad(array $ids, string $entity_type_id): void + { + $loaded = $this->entityTypeManager->getStorage($entity_type_id)->loadMultiple($ids); + if ($missing = array_diff($ids, array_keys($loaded))) { + $msg = dt('Unable to load the !type: !str', ['!type' => $entity_type_id, '!str' => implode(', ', $missing)]); + throw new \InvalidArgumentException($msg); + } + } + + protected function validateModulesEnabled(array $modules): void + { + $missing = array_filter($modules, fn($module) => !$this->moduleHandler->moduleExists($module)); + if ($missing) { + $message = dt('The following modules are required: !modules', ['!modules' => implode(', ', $missing)]); + throw new InvalidArgumentException($message); + } + } +} diff --git a/src/Commands/core/ImageFlushCommand.php b/src/Commands/core/ImageFlushCommand.php new file mode 100644 index 0000000000..a05c7bd28d --- /dev/null +++ b/src/Commands/core/ImageFlushCommand.php @@ -0,0 +1,101 @@ +addArgument('style_names', InputArgument::OPTIONAL, 'A comma delimited list of image style machine names. If not provided, user may choose from a list of names.') + ->addOption('all', null, InputOption::VALUE_NONE, 'Flush all derived images') + ->addUsage('image:flush thumbnail,large') + ->addUsage('image:flush --all') + ->setHelp('Immediately before running this command, web crawl your entire web site. Or use your Production PHPStorage dir for comparison.'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new DrushStyle($input, $output); + $styles = array_keys($this->entityTypeManager->getStorage('image_style')->loadMultiple()); + $style_names = $input->getArgument('style_names'); + + if (empty($style_names) && !$input->getOption('all')) { + $styles_all = $styles; + array_unshift($styles_all, 'all'); + $choices = array_combine($styles_all, $styles_all); + $style_names = $io->choice(dt("Choose a style to flush"), $choices, 'all'); + if ($style_names == 'all') { + $style_names = implode(',', $styles); + } + $input->setArgument('style_names', $style_names); + } + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $io = new DrushStyle($input, $output); + + if ($input->getOption('all')) { + $input->setArgument('style_names', array_keys($this->entityTypeManager->getStorage('image_style')->loadMultiple())); + } + + $this->validateModulesEnabled(['image']); + $this->validateEntityLoad(StringUtils::csvToArray($input->getArgument('style_names')), 'image_style'); + + $ids = StringUtils::csvToArray($input->getArgument('style_names')); + foreach ($this->entityTypeManager->getStorage('image_style')->loadMultiple($ids) as $style_name => $style) { + $style->flush(); + $io->success("Image style $style_name flushed"); + } + return self::SUCCESS; + } + + protected function validateEntityLoad(array $ids, string $entity_type_id): void + { + $loaded = $this->entityTypeManager->getStorage($entity_type_id)->loadMultiple($ids); + if ($missing = array_diff($ids, array_keys($loaded))) { + $msg = dt('Unable to load the !type: !str', ['!type' => $entity_type_id, '!str' => implode(', ', $missing)]); + throw new \InvalidArgumentException($msg); + } + } + + protected function validateModulesEnabled(array $modules): void + { + $missing = array_filter($modules, fn($module) => !$this->moduleHandler->moduleExists($module)); + if ($missing) { + $message = dt('The following modules are required: !modules', ['!modules' => implode(', ', $missing)]); + throw new InvalidArgumentException($message); + } + } +} diff --git a/src/Commands/core/TwigCommands.php b/src/Commands/core/TwigCommands.php index 001ff11ddb..460290e1d7 100644 --- a/src/Commands/core/TwigCommands.php +++ b/src/Commands/core/TwigCommands.php @@ -4,18 +4,16 @@ namespace Drush\Commands\core; -use Consolidation\OutputFormatters\StructuredData\RowsOfFields; use Drupal\Core\DrupalKernelInterface; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Extension\ModuleHandlerInterface; -use Drupal\Core\PhpStorage\PhpStorageFactory; use Drupal\Core\State\StateInterface; use Drupal\Core\Template\TwigEnvironment; use Drush\Attributes as CLI; use Drush\Commands\AutowireTrait; use Drush\Commands\DrushCommands; use Drush\Drush; -use Drush\Utils\StringUtils; +use JetBrains\PhpStorm\Deprecated; use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; @@ -23,9 +21,10 @@ final class TwigCommands extends DrushCommands { use AutowireTrait; - const UNUSED = 'twig:unused'; const COMPILE = 'twig:compile'; const DEBUG = 'twig:debug'; + #[Deprecated('Use TwigUnusedCommand::UNUSED instead.')] + const UNUSED = 'twig:unused'; public function __construct( protected TwigEnvironment $twig, @@ -36,51 +35,6 @@ public function __construct( ) { } - /** - * Find potentially unused Twig templates. - * - * Immediately before running this command, web crawl your entire web site. Or - * use your Production PHPStorage dir for comparison. - */ - #[CLI\Command(name: self::UNUSED, aliases: [])] - #[CLI\Argument(name: 'searchpaths', description: 'A comma delimited list of paths to recursively search')] - #[CLI\Usage(name: 'drush twig:unused --field=template /var/www/mass.local/docroot/modules/custom,/var/www/mass.local/docroot/themes/custom', description: 'Output a simple list of potentially unused templates.')] - #[CLI\FieldLabels(labels: ['template' => 'Template', 'compiled' => 'Compiled'])] - #[CLI\DefaultTableFields(fields: ['template', 'compiled'])] - public function unused($searchpaths): RowsOfFields - { - $unused = []; - $phpstorage = PhpStorageFactory::get('twig'); - - // Find all templates in the codebase. - $files = Finder::create() - ->files() - ->name('*.html.twig') - ->exclude('tests') - ->in(StringUtils::csvToArray($searchpaths)); - $this->logger()->notice(dt('Found !count templates', ['!count' => count($files)])); - - // Check to see if a compiled equivalent exists in PHPStorage - foreach ($files as $file) { - $relative = Path::makeRelative($file->getRealPath(), Drush::bootstrapManager()->getRoot()); - $mainCls = $this->twig->getTemplateClass($relative); - $cache = $this->twig->getCache(); - if ($cache) { - $key = $cache->generateKey($relative, $mainCls); - if (!$phpstorage->exists($key)) { - $unused[$key] = [ - 'template' => $relative, - 'compiled' => $key, - ]; - } - } else { - throw new \Exception('There was a problem, please ensure your twig cache is enabled.'); - } - } - $this->logger()->notice(dt('Found !count unused', ['!count' => count($unused)])); - return new RowsOfFields($unused); - } - /** * Compile all Twig template(s). */ diff --git a/src/Commands/core/TwigUnusedCommand.php b/src/Commands/core/TwigUnusedCommand.php new file mode 100644 index 0000000000..ad7981a787 --- /dev/null +++ b/src/Commands/core/TwigUnusedCommand.php @@ -0,0 +1,102 @@ +addArgument('searchpaths', InputArgument::REQUIRED, 'A comma delimited list of paths to recursively search') + // Usages can't have a description with plain Console :(. Use setHelp() if desired as per https://github.com/symfony/symfony/issues/45050 + ->addUsage('twig:unused /var/www/mass.local/docroot/modules/custom,/var/www/mass.local/docroot/themes/custom') + ->setHelp('Immediately before running this command, web crawl your entire web site. Or use your Production PHPStorage dir for comparison.'); + $formatterOptions = (new FormatterOptions()) + ->setFieldLabels(['template' => 'Template', 'compiled' => 'Compiled']) + ->setTableDefaultFields(['template', 'compiled']); + $this->configureFormatter(RowsOfFields::class, $formatterOptions); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $data = $this->doExecute($input, $output); + $this->writeFormattedOutput($input, $output, $data); + return self::SUCCESS; + } + + public function doExecute(InputInterface $input, OutputInterface $output): RowsOfFields + { + $searchpaths = $input->getArgument('searchpaths'); + $unused = []; + $phpstorage = PhpStorageFactory::get('twig'); + + // Find all templates in the codebase. + $files = Finder::create() + ->files() + ->name('*.html.twig') + ->exclude('tests') + ->in(StringUtils::csvToArray($searchpaths)); + $this->logger->notice('Found {count} templates', ['count' => count($files)]); + + // Check to see if a compiled equivalent exists in PHPStorage + foreach ($files as $file) { + $relative = Path::makeRelative($file->getRealPath(), $this->bootstrapManager->getRoot()); + $mainCls = $this->twig->getTemplateClass($relative); + $cache = $this->twig->getCache(); + if ($cache) { + $key = $cache->generateKey($relative, $mainCls); + if (!$phpstorage->exists($key)) { + $unused[$key] = [ + 'template' => $relative, + 'compiled' => $key, + ]; + } + } else { + throw new \Exception('There was a problem, please ensure your twig cache is enabled.'); + } + } + $this->logger->notice('Found {count} unused', ['count' => count($unused)]); + return new RowsOfFields($unused); + } +} diff --git a/src/Commands/help/HelpCLIFormatter.php b/src/Commands/help/HelpCLIFormatter.php index fbc4a48982..20ba0e0ac6 100644 --- a/src/Commands/help/HelpCLIFormatter.php +++ b/src/Commands/help/HelpCLIFormatter.php @@ -42,9 +42,9 @@ public function write(OutputInterface $output, $data, FormatterOptions $options) } } elseif (array_key_exists('usages', $data)) { // Usages come from Console commands. - // Don't show the last two Usages which come from synopsis and alias. See \Symfony\Component\Console\Descriptor\XmlDescriptor::getCommandDocument. - array_pop($data['usages']); - array_pop($data['usages']); + // Don't show the first two Usages which come from synopsis and alias. See \Symfony\Component\Console\Descriptor\XmlDescriptor::getCommandDocument. + array_shift($data['usages']); + array_shift($data['usages']); if ($data['usages']) { $output->writeln(''); $output->writeln('Examples:'); diff --git a/src/Commands/pm/PmCommands.php b/src/Commands/pm/PmCommands.php index a00af96b83..602fdfaf2a 100644 --- a/src/Commands/pm/PmCommands.php +++ b/src/Commands/pm/PmCommands.php @@ -139,7 +139,7 @@ public function validateEnableModules(CommandData $commandData): void // Note: we can't just call the API ($moduleHandler->loadInclude($module, 'install')), // because the API ignores modules that haven't been installed yet. We have // to do it the same way the `function drupal_check_module($module)` does. - $file = DRUPAL_ROOT . '/' . $this->extensionListModule->getPath($module) . "/$module.install"; + $file = DRUPAL_ROOT . '/' . $this->getExtensionListModule()->getPath($module) . "/$module.install"; if (is_file($file)) { require_once $file; } diff --git a/src/Commands/sql/SqlCommands.php b/src/Commands/sql/SqlCommands.php index f3d91f5356..424958c824 100644 --- a/src/Commands/sql/SqlCommands.php +++ b/src/Commands/sql/SqlCommands.php @@ -8,7 +8,6 @@ use Consolidation\AnnotatedCommand\Hooks\HookManager; use Consolidation\AnnotatedCommand\Input\StdinAwareInterface; use Consolidation\AnnotatedCommand\Input\StdinAwareTrait; -use Consolidation\OutputFormatters\StructuredData\PropertyList; use Consolidation\SiteProcess\Util\Tty; use Drupal\Core\Database\Database; use Drush\Attributes as CLI; @@ -19,6 +18,7 @@ use Drush\Exceptions\UserAbortException; use Drush\Exec\ExecTrait; use Drush\Sql\SqlBase; +use JetBrains\PhpStorm\Deprecated; use Symfony\Component\Console\Input\InputInterface; final class SqlCommands extends DrushCommands implements StdinAwareInterface @@ -32,6 +32,7 @@ final class SqlCommands extends DrushCommands implements StdinAwareInterface const DROP = 'sql:drop'; const CLI = 'sql:cli'; const QUERY = 'sql:query'; + #[Deprecated(reason: 'Moved', replacement: SqlDumpCommand::NAME)] const DUMP = 'sql:dump'; #[CLI\Command(name: self::CONF, aliases: ['sql-conf'])] @@ -187,41 +188,6 @@ public function query($query = '', $options = ['result-file' => null, 'file' => return true; } - /** - * Exports the Drupal DB as SQL using mysqldump or equivalent. - * - * --create-db is used by sql-sync, since including the DROP TABLE statements interferes with the import when the database is created. - */ - #[CLI\Command(name: self::DUMP, aliases: ['sql-dump'])] - #[CLI\Bootstrap(level: DrupalBootLevels::MAX, max_level: DrupalBootLevels::CONFIGURATION)] - #[CLI\OptionsetSql] - #[CLI\OptionsetTableSelection] - #[CLI\Option(name: 'result-file', description: "Save to a file. The file should be relative to Drupal root. If --result-file is provided with the value 'auto', a date-based filename will be created under ~/drush-backups directory.")] - #[CLI\Option(name: 'create-db', description: 'Omit DROP TABLE statements. Used by Postgres and Oracle only.')] - #[CLI\Option(name: 'data-only', description: 'Dump data without statements to create any of the schema.')] - #[CLI\Option(name: 'ordered-dump', description: 'Order by primary key and add line breaks for efficient diffs. Slows down the dump. Mysql only.')] - #[CLI\Option(name: 'gzip', description: 'Compress the dump using the gzip program which must be in your $PATH.')] - #[CLI\Option(name: 'extra', description: 'Add custom arguments/options when connecting to database (used internally to list tables).')] - #[CLI\Option(name: 'extra-dump', description: 'Add custom arguments/options to the dumping of the database (e.g. mysqldump command).')] - #[CLI\Usage(name: 'drush sql:dump --result-file=../18.sql', description: 'Save SQL dump to the directory above Drupal root.')] - #[CLI\Usage(name: 'drush sql:dump --skip-tables-key=common', description: 'Skip standard tables. See [Drush configuration](../../using-drush-configuration)')] - #[CLI\Usage(name: 'drush sql:dump --extra-dump=--no-data', description: 'Pass extra option to mysqldump command.')] - #[CLI\FieldLabels(labels: ['path' => 'Path'])] - public function dump($options = ['result-file' => self::REQ, 'create-db' => false, 'data-only' => false, 'ordered-dump' => false, 'gzip' => false, 'extra' => self::REQ, 'extra-dump' => self::REQ, 'format' => 'null']): PropertyList - { - $sql = SqlBase::create($options); - $return = $sql->dump(); - if ($return === false) { - throw new \Exception('Unable to dump database. Rerun with --debug to see any error message.'); - } - - // SqlBase::dump() returns null if 'result-file' option is empty. - if ($return) { - $this->logger()->success(dt('Database dump saved to !path', ['!path' => $return])); - } - return new PropertyList(['path' => $return]); - } - /** * Assert that `mysql` or similar are on the user's PATH. */ diff --git a/src/Commands/sql/SqlDumpCommand.php b/src/Commands/sql/SqlDumpCommand.php new file mode 100644 index 0000000000..8cd257ecd8 --- /dev/null +++ b/src/Commands/sql/SqlDumpCommand.php @@ -0,0 +1,85 @@ +addOption('result-file', null, InputOption::VALUE_REQUIRED, 'Save to a file. The file should be relative to Drupal root. If --result-file is provided with the value \'auto\', a date-based filename will be created under ~/drush-backups directory.') + // create-db is used by sql:sync, since including the DROP TABLE statements interferes with the import when the database is created. + ->addOption('create-db', null, InputOption::VALUE_NONE, 'Omit DROP TABLE statements. Used by Postgres and Oracle only.') + ->addOption('data-only', null, InputOption::VALUE_NONE, 'Dump data without statements to create any of the schema.') + ->addOption('ordered-dump', null, InputOption::VALUE_NONE, 'Order by primary key and add line breaks for efficient diffs. Slows down the dump. Mysql only.') + ->addOption('gzip', null, InputOption::VALUE_NONE, 'Compress the dump using the gzip program which must be in your $PATH.') + ->addOption('extra', null, InputOption::VALUE_REQUIRED, 'Add custom arguments/options when connecting to database (used internally to list tables).') + ->addOption('extra-dump', null, InputOption::VALUE_REQUIRED, 'Add custom arguments/options to the dumping of the database (e.g. mysqldump command).') + ->addUsage('sql:dump --result-file=../18.sql') + ->addUsage('sql:dump --skip-tables-key=common') + ->addUsage('sql:dump --extra-dump=--no-data'); + $formatterOptions = (new FormatterOptions()) + ->setFieldLabels(['path' => 'Path']); + $this->configureFormatter(PropertyList::class, $formatterOptions); + OptionSets::sql($this); + OptionSets::tableSelection($this); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $data = $this->doExecute($input, $output); + $this->writeFormattedOutput($input, $output, $data); + return self::SUCCESS; + } + + protected function doExecute(InputInterface $input, OutputInterface $output): PropertyList + { + $sql = SqlBase::create($input->getOptions()); + $return = $sql->dump(); + if ($return === false) { + throw new \Exception('Unable to dump database. Rerun with --debug to see any error message.'); + } + + // SqlBase::dump() returns null if 'result-file' option is empty. + if ($return) { + $io = new DrushStyle($input, $output); + $io->success(dt('Database dump saved to !path', ['!path' => $return])); + } + return new PropertyList(['path' => $return]); + } +} diff --git a/src/Commands/sql/sanitize/SanitizeCommand.php b/src/Commands/sql/sanitize/SanitizeCommand.php new file mode 100644 index 0000000000..98043080db --- /dev/null +++ b/src/Commands/sql/sanitize/SanitizeCommand.php @@ -0,0 +1,88 @@ +setDescription('Sanitize the database by removing or obfuscating user data.') + ->addUsage('drush sql:sanitize --sanitize-password=no') + ->addUsage('drush sql:sanitize --allowlist-fields=field_biography,field_phone_number'); + } + + /** + * Commandfiles may add custom operations by implementing a Listener that subscribes to two events: + * + * - `\Drush\Events\SanitizeConfirmsEvent`. Display summary to user before confirmation. + * - `\Symfony\Component\Console\Event\ConsoleTerminateEvent`. Run queries or call APIs to perform sanitizing + * + * Several working Listeners may be found at https://github.com/drush-ops/drush/tree/13.x/src/Drush/Listeners/sanitize + */ + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new DrushStyle($input, $output); + + /** + * In order to present only one prompt, collect all confirmations up front. + */ + $event = new SanitizeConfirmsEvent($input); + $this->eventDispatcher->dispatch($event); + $messages = $event->getMessages(); + + // Also collect from legacy commandfiles. + // This works but we would need backwars compat forv POST_COMMAND AC hook as well. +// $handlers = $this->getCustomEventHandlers(SanitizeCommands::CONFIRMS); +// foreach ($handlers as $handler) { +// $handler($messages, $input); +// } + if ($messages) { + $output->writeln(dt('The following operations will be performed:')); + $io->listing($messages); + } + if (!$io->confirm(dt('Do you want to sanitize the current database?'))) { + throw new UserAbortException(); + } + // All sanitize operations happen during the built-in console.terminate event. + + return self::SUCCESS; + } +} diff --git a/src/Commands/sql/sanitize/SanitizeCommands.php b/src/Commands/sql/sanitize/SanitizeCommands.php index 79633a40e3..18120f1ee7 100644 --- a/src/Commands/sql/sanitize/SanitizeCommands.php +++ b/src/Commands/sql/sanitize/SanitizeCommands.php @@ -4,59 +4,11 @@ namespace Drush\Commands\sql\sanitize; -use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface; -use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait; -use Drush\Attributes as CLI; -use Drush\Boot\DrupalBootLevels; -use Drush\Commands\core\DocsCommands; -use Drush\Commands\DrushCommands; -use Drush\Exceptions\UserAbortException; +use JetBrains\PhpStorm\Deprecated; -#[CLI\Bootstrap(level: DrupalBootLevels::FULL)] -final class SanitizeCommands extends DrushCommands implements CustomEventAwareInterface +#[Deprecated('Moved to Drush\Commands\sql\sanitize\SanitizeCommand.')] +final class SanitizeCommands { - use CustomEventAwareTrait; - const SANITIZE = 'sql:sanitize'; const CONFIRMS = 'sql-sanitize-confirms'; - - /** - * Sanitize the database by removing or obfuscating user data. - * - * Commandfiles may add custom operations by implementing: - * - * - `#[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)]`. Display summary to user before confirmation. - * - `#[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)]`. Run queries or call APIs to perform sanitizing - * - * Several working commandfiles may be found at https://github.com/drush-ops/drush/tree/13.x/src/Commands/sql/sanitize - */ - #[CLI\Command(name: self::SANITIZE, aliases: ['sqlsan','sql-sanitize'])] - #[CLI\Usage(name: 'drush sql:sanitize --sanitize-password=no', description: 'Sanitize database without modifying any passwords.')] - #[CLI\Usage(name: 'drush sql:sanitize --allowlist-fields=field_biography,field_phone_number', description: 'Sanitizes database but exempts two user fields from modification.')] - #[CLI\Topics(topics: [DocsCommands::HOOKS])] - public function sanitize(): void - { - /** - * In order to present only one prompt, collect all confirmations from - * commandfiles up front. sql:sanitize plugins are commandfiles that implement - * \Drush\Commands\sql\SanitizePluginInterface - */ - $messages = []; - $input = $this->input(); - $handlers = $this->getCustomEventHandlers(self::CONFIRMS); - foreach ($handlers as $handler) { - $handler($messages, $input); - } - // @phpstan-ignore if.alwaysFalse - if ($messages) { - $this->output()->writeln(dt('The following operations will be performed:')); - $this->io()->listing($messages); - } - if (!$this->io()->confirm(dt('Do you want to sanitize the current database?'))) { - throw new UserAbortException(); - } - - // All sanitize operations defined in post-command hooks, including Drush - // core sanitize routines. See \Drush\Commands\sql\sanitize\SanitizePluginInterface. - } } diff --git a/src/Commands/sql/sanitize/SanitizePluginInterface.php b/src/Commands/sql/sanitize/SanitizePluginInterface.php index a125f65a71..53bf8e4ee8 100644 --- a/src/Commands/sql/sanitize/SanitizePluginInterface.php +++ b/src/Commands/sql/sanitize/SanitizePluginInterface.php @@ -5,11 +5,13 @@ namespace Drush\Commands\sql\sanitize; use Consolidation\AnnotatedCommand\CommandData; +use JetBrains\PhpStorm\Deprecated; use Symfony\Component\Console\Input\InputInterface; /** * Implement this interface when building a Drush sql-sanitize plugin. */ +#[Deprecated(reason: 'Implement an event listener instead.')] interface SanitizePluginInterface { /** diff --git a/src/Commands/sql/sanitize/SanitizeUserFieldsCommands.php b/src/Commands/sql/sanitize/SanitizeUserFieldsCommands.php deleted file mode 100644 index b858e62154..0000000000 --- a/src/Commands/sql/sanitize/SanitizeUserFieldsCommands.php +++ /dev/null @@ -1,123 +0,0 @@ -database; - } - - /** - * @return mixed - */ - public function getEntityFieldManager() - { - return $this->entityFieldManager; - } - - /** - * Sanitize string fields associated with the user. - */ - #[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)] - public function sanitize($result, CommandData $commandData): void - { - $options = $commandData->options(); - $conn = $this->getDatabase(); - $field_definitions = $this->getEntityFieldManager()->getFieldDefinitions('user', 'user'); - $field_storage = $this->getEntityFieldManager()->getFieldStorageDefinitions('user'); - foreach (explode(',', $options['allowlist-fields']) as $key) { - unset($field_definitions[$key], $field_storage[$key]); - } - - foreach ($field_definitions as $key => $def) { - $execute = false; - if (!isset($field_storage[$key]) || $field_storage[$key]->isBaseField()) { - continue; - } - - $table = 'user__' . $key; - $query = $conn->update($table); - $name = $def->getName(); - $field_type_class = \Drupal::service('plugin.manager.field.field_type')->getPluginClass($def->getType()); - $supported_field_types = ['email', 'string', 'string_long', 'telephone', 'text', 'text_long', 'text_with_summary']; - if (in_array($def->getType(), $supported_field_types)) { - $value_array = $field_type_class::generateSampleValue($def); - $value = $value_array['value']; - } else { - continue; - } - switch ($def->getType()) { - case 'string': - case 'string_long': - case 'text': - case 'text_long': - case 'email': - $query->fields([$name . '_value' => $value]); - $execute = true; - break; - - case 'telephone': - $query->fields([$name . '_value' => '15555555555']); - $execute = true; - break; - - case 'text_with_summary': - $query->fields([ - $name . '_value' => $value, - $name . '_summary' => $value_array['summary'], - ]); - $execute = true; - break; - } - if ($execute) { - $query->execute(); - $this->entityTypeManager->getStorage('user')->resetCache(); - $this->logger()->success(dt('!table table sanitized.', ['!table' => $table])); - } else { - $this->logger()->success(dt('No text fields for users need sanitizing.', ['!table' => $table])); - } - } - } - - #[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)] - public function messages(&$messages, InputInterface $input): void - { - $messages[] = dt('Sanitize text fields associated with users.'); - } - - #[CLI\Hook(type: HookManager::OPTION_HOOK, target: SanitizeCommands::SANITIZE)] - #[CLI\Option(name: 'allowlist-fields', description: 'A comma delimited list of fields exempt from sanitization.')] - public function options($options = ['allowlist-fields' => '']): void - { - } -} diff --git a/src/Event/ConsoleDefinitionsEvent.php b/src/Event/ConsoleDefinitionsEvent.php new file mode 100644 index 0000000000..63811117cc --- /dev/null +++ b/src/Event/ConsoleDefinitionsEvent.php @@ -0,0 +1,28 @@ +application = $application; + } + + public function getApplication(): Application + { + return $this->application; + } +} diff --git a/src/Event/SanitizeConfirmsEvent.php b/src/Event/SanitizeConfirmsEvent.php new file mode 100644 index 0000000000..3c34542062 --- /dev/null +++ b/src/Event/SanitizeConfirmsEvent.php @@ -0,0 +1,48 @@ +input = $input; + } + + public function getInput(): InputInterface + { + return $this->input; + } + + public function addMessage(string $message): self + { + $this->messages[] = $message; + return $this; + } + + public function getMessages(): array + { + return $this->messages; + } + + public function setMessages(array $messages): self + { + $this->messages = $messages; + return $this; + } +} diff --git a/src/Formatters/FormatterTrait.php b/src/Formatters/FormatterTrait.php new file mode 100644 index 0000000000..3d0a5a0783 --- /dev/null +++ b/src/Formatters/FormatterTrait.php @@ -0,0 +1,113 @@ +setFormatterOptions($formatterOptions); + $inputOptions = $this->formatterManager->automaticOptions($formatterOptions, $dataType); + foreach ($inputOptions as $inputOption) { + $mode = $this->getPrivatePropValue($inputOption, 'mode'); + $suggestedValues = $this->getPrivatePropValue($inputOption, 'suggestedValues'); + $this->addOption($inputOption->getName(), $inputOption->getShortcut(), $mode, $inputOption->getDescription(), $inputOption->getDefault(), $suggestedValues); + } + + // Append a web link to the command's help. + // @todo $this->getApplication() throws an Exception - we are called during __construct(). Get base URL from the Container? + $application = Drush::getApplication(); + if (method_exists($application, 'getDocsBaseUrl')) { + $url = sprintf('%s/output-formats-filters', $application->getDocsBaseUrl()); + $section = sprintf('Learn more about about output formatting and filtering at %s', $url); + $help = $this->getHelp(); + $help .= "\n\n" . $section; + $this->setHelp($help); + } + + // Add the --filter option if the command has a FilterDefaultField attribute. + // @todo Determine --filter via $formatterOptions instead of FilterDefaultField attribute. + $reflection = new \ReflectionObject($this); + $attributes = $reflection->getAttributes(FilterDefaultField::class); + if (!empty($attributes)) { + $instance = $attributes[0]->newInstance(); + $this->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter output based on provided expression. Default field: ' . $instance->field); + } + } + + /** + * Filter, format, and write to the output + */ + protected function writeFormattedOutput(InputInterface $input, OutputInterface $output, $data): void + { + $data = $this->alterResult($data, $input); + $this->formatterManager->write($output, $input->getOption('format'), $data, $this->getFormatterOptions()->setInput($input)); + } + + protected function alterResult($result, InputInterface $input): mixed + { + if (!$input->hasOption('filter') || !$input->getOption('filter')) { + return $result; + } + $expression = $input->getOption('filter'); + $reflection = new \ReflectionObject($this); + $attributes = $reflection->getAttributes(FilterDefaultField::class); + $instance = $attributes[0]->newInstance(); + $factory = LogicalOpFactory::get(); + $op = $factory->evaluate($expression, $instance->field); + $filter = new FilterOutputData(); + return $this->wrapFilteredResult($filter->filter($result, $op), $result); + } + + /** + * If the source data was wrapped in a marker class such + * as RowsOfFields, then re-apply the wrapper. + */ + protected function wrapFilteredResult($data, $source) + { + if (!$source instanceof \ArrayObject) { + return $data; + } + $sourceClass = get_class($source); + + return new $sourceClass($data); + } + + public function getFormatterOptions(): FormatterOptions + { + return $this->formatterOptions; + } + + public function setFormatterOptions(FormatterOptions $formatterOptions): void + { + $this->formatterOptions = $formatterOptions; + } + + protected function getPrivatePropValue(mixed $object, $name): mixed + { + $rc = new \ReflectionClass($object); + $prop = $rc->getProperty($name); + return $prop->getValue($object); + } +} diff --git a/src/Listeners/sanitize/SanitizeUserFieldsListener.php b/src/Listeners/sanitize/SanitizeUserFieldsListener.php new file mode 100644 index 0000000000..fb54ea79e7 --- /dev/null +++ b/src/Listeners/sanitize/SanitizeUserFieldsListener.php @@ -0,0 +1,118 @@ +getApplication()->all() as $id => $command) { + if ($command->getName() === SanitizeCommand::NAME) { + $command->addOption('allowlist-fields', null, InputOption::VALUE_REQUIRED, 'A comma delimited list of fields exempt from sanitization.'); + } + } + } + + public function onSanitizeConfirm(SanitizeConfirmsEvent $event): void + { + $event->addMessage(dt('Sanitize text fields associated with users.')); + } + + public function onConsoleTerminate(ConsoleTerminateEvent $event): void + { + if ($event->getCommand()->getName() !== SanitizeCommand::NAME) { + return; + } + + $io = new DrushStyle($event->getInput(), $event->getOutput()); + + $options = $event->getInput()->getOptions(); + $field_definitions = $this->entityFieldManager->getFieldDefinitions('user', 'user'); + $field_storage = $this->entityFieldManager->getFieldStorageDefinitions('user'); + foreach (StringUtils::csvToArray($options['allowlist-fields']) as $key) { + unset($field_definitions[$key], $field_storage[$key]); + } + + foreach ($field_definitions as $key => $def) { + $execute = false; + if (!isset($field_storage[$key]) || $field_storage[$key]->isBaseField()) { + continue; + } + + $table = 'user__' . $key; + $query = $this->database->update($table); + $name = $def->getName(); + $field_type_class = $this->fieldTypePluginManager->getPluginClass($def->getType()); + $supported_field_types = ['email', 'string', 'string_long', 'telephone', 'text', 'text_long', 'text_with_summary']; + if (in_array($def->getType(), $supported_field_types)) { + $value_array = $field_type_class::generateSampleValue($def); + $value = $value_array['value']; + } else { + continue; + } + switch ($def->getType()) { + case 'string': + case 'string_long': + case 'text': + case 'text_long': + case 'email': + $query->fields([$name . '_value' => $value]); + $execute = true; + break; + + case 'telephone': + $query->fields([$name . '_value' => '15555555555']); + $execute = true; + break; + + case 'text_with_summary': + $query->fields([ + $name . '_value' => $value, + $name . '_summary' => $value_array['summary'], + ]); + $execute = true; + break; + } + if ($execute) { + $query->execute(); + $this->entityTypeManager->getStorage('user')->resetCache(); + $io->success(dt('!table table sanitized.', ['!table' => $table])); + } else { + $io->success(dt('No text fields for users need sanitizing.', ['!table' => $table])); + } + } + } +} diff --git a/src/Commands/sql/sanitize/SanitizeUserTableCommands.php b/src/Listeners/sanitize/SanitizeUserTableListener.php similarity index 60% rename from src/Commands/sql/sanitize/SanitizeUserTableCommands.php rename to src/Listeners/sanitize/SanitizeUserTableListener.php index bd2a18fef4..0e7fc3d626 100644 --- a/src/Commands/sql/sanitize/SanitizeUserTableCommands.php +++ b/src/Listeners/sanitize/SanitizeUserTableListener.php @@ -2,44 +2,81 @@ declare(strict_types=1); -namespace Drush\Commands\sql\sanitize; +namespace Drush\Listeners\sanitize; use Drupal\Core\Database\Connection; use Drupal\Core\Database\Query\SelectInterface; -use Consolidation\AnnotatedCommand\CommandData; -use Consolidation\AnnotatedCommand\Hooks\HookManager; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Password\PasswordInterface; -use Drush\Attributes as CLI; use Drush\Commands\AutowireTrait; -use Drush\Commands\DrushCommands; +use Drush\Commands\sql\sanitize\SanitizeCommand; +use Drush\Event\ConsoleDefinitionsEvent; +use Drush\Event\SanitizeConfirmsEvent; use Drush\Sql\SqlBase; +use Drush\Style\DrushStyle; use Drush\Utils\StringUtils; -use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Event\ConsoleTerminateEvent; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; /** - * A sql:sanitize plugin. + * Sanitize emails and passwords. This also an example of how to write a + * database sanitizer for sql:sync. */ -final class SanitizeUserTableCommands extends DrushCommands implements SanitizePluginInterface +#[AsEventListener(method: 'onDefinition')] +#[AsEventListener(method: 'onSanitizeConfirm')] +#[AsEventListener(method: 'onConsoleTerminate')] +final class SanitizeUserTableListener { use AutowireTrait; public function __construct( protected Connection $database, protected PasswordInterface $passwordHasher, - protected EntityTypeManagerInterface $entityTypeManager + protected EntityTypeManagerInterface $entityTypeManager, ) { - parent::__construct(); } - /** - * Sanitize emails and passwords. This also an example of how to write a - * database sanitizer for sql:sync. - */ - #[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)] - public function sanitize($result, CommandData $commandData): void + public function onDefinition(ConsoleDefinitionsEvent $event): void + { + foreach ($event->getApplication()->all() as $id => $command) { + if ($command->getName() === SanitizeCommand::NAME) { + $command->addOption( + 'sanitize-email', + null, + InputOption::VALUE_REQUIRED, + 'The pattern for test email addresses in the sanitization operation, or no to keep email addresses unchanged. May contain replacement patterns %uid, %mail or %name.', + 'user+%uid@localhost.localdomain' + ) + ->addOption('sanitize-password', null, InputOption::VALUE_REQUIRED, 'By default, passwords are randomized. Specify no to disable that. Specify any other value to set all passwords to that value.') + ->addOption('ignored-roles', null, InputOption::VALUE_REQUIRED, 'A comma delimited list of roles. Users with at least one of the roles will be exempt from sanitization.'); + } + } + } + + public function onSanitizeConfirm(SanitizeConfirmsEvent $event): void + { + $options = $event->getInput()->getOptions(); + if ($this->isEnabled($options['sanitize-password'])) { + $event->addMessage(dt('Sanitize user passwords.')); + } + if ($this->isEnabled($options['sanitize-email'])) { + $event->addMessage(dt('Sanitize user emails.')); + } + if (in_array('ignored-roles', $options)) { + $event->addMessage(dt('Preserve user emails and passwords for the specified roles.')); + } + } + + public function onConsoleTerminate(ConsoleTerminateEvent $event): void { - $options = $commandData->options(); + if ($event->getCommand()->getName() !== SanitizeCommand::NAME) { + return; + } + + $io = new DrushStyle($event->getInput(), $event->getOutput()); + + $options = $event->getInput()->getOptions(); $query = $this->database->update('users_field_data')->condition('uid', 0, '>'); $messages = []; @@ -60,7 +97,7 @@ public function sanitize($result, CommandData $commandData): void if ($this->isEnabled($options['sanitize-email'])) { if (str_contains($options['sanitize-email'], '%')) { // We need a different sanitization query for MSSQL, Postgres and Mysql. - $sql = SqlBase::create($commandData->input()->getOptions()); + $sql = SqlBase::create($event->getInput()->getOptions()); $db_driver = $sql->scheme(); if ($db_driver === 'pgsql') { $email_map = ['%uid' => "' || uid || '", '%mail' => "' || replace(mail, '@', '_') || '", '%name' => "' || replace(name, ' ', '_') || '"]; @@ -100,34 +137,11 @@ public function sanitize($result, CommandData $commandData): void $query->execute(); $this->entityTypeManager->getStorage('user')->resetCache(); foreach ($messages as $message) { - $this->logger()->success($message); + $io->success($message); } } } - #[CLI\Hook(type: HookManager::OPTION_HOOK, target: SanitizeCommands::SANITIZE)] - #[CLI\Option(name: 'sanitize-email', description: 'The pattern for test email addresses in the sanitization operation, or no to keep email addresses unchanged. May contain replacement patterns %uid, %mail or %name.')] - #[CLI\Option(name: 'sanitize-password', description: 'By default, passwords are randomized. Specify no to disable that. Specify any other value to set all passwords to that value.')] - #[CLI\Option(name: 'ignored-roles', description: 'A comma delimited list of roles. Users with at least one of the roles will be exempt from sanitization.')] - public function options($options = ['sanitize-email' => 'user+%uid@localhost.localdomain', 'sanitize-password' => null, 'ignored-roles' => null]): void - { - } - - #[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)] - public function messages(&$messages, InputInterface $input): void - { - $options = $input->getOptions(); - if ($this->isEnabled($options['sanitize-password'])) { - $messages[] = dt('Sanitize user passwords.'); - } - if ($this->isEnabled($options['sanitize-email'])) { - $messages[] = dt('Sanitize user emails.'); - } - if (in_array('ignored-roles', $options)) { - $messages[] = dt('Preserve user emails and passwords for the specified roles.'); - } - } - /** * Test an option value to see if it is disabled. */ diff --git a/src/Runtime/DependencyInjection.php b/src/Runtime/DependencyInjection.php index 71d6f13eb0..bd0afff68b 100644 --- a/src/Runtime/DependencyInjection.php +++ b/src/Runtime/DependencyInjection.php @@ -7,6 +7,7 @@ use Composer\Autoload\ClassLoader; use Consolidation\Config\ConfigInterface; use Consolidation\Config\Util\ConfigOverlay; +use Consolidation\OutputFormatters\FormatterManager; use Consolidation\SiteAlias\SiteAliasManager; use Consolidation\SiteAlias\SiteAliasManagerAwareInterface; use Consolidation\SiteAlias\SiteAliasManagerInterface; @@ -26,6 +27,8 @@ use Drush\Symfony\DrushStyleInjector; use League\Container\Container; use League\Container\ContainerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; use Robo\Robo; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -35,6 +38,7 @@ */ class DependencyInjection { + const FORMATTER_MANAGER = 'formatterManager'; const SITE_ALIAS_MANAGER = 'site.alias.manager'; const BOOTSTRAP_MANAGER = 'bootstrap.manager'; const LOADER = 'loader'; @@ -62,7 +66,7 @@ public function initContainer( $container = new Container(); // With league/container 3.x, first call wins, so add Drush services first. - $this->addDrushServices($container, $loader, $drupalFinder, $aliasManager, $config, $output); + $this->addDrushServices($container, $loader, $drupalFinder, $aliasManager, $config, $output, $input); // Robo has the same signature for configureContainer in 1.x, 2.x and 3.x. Robo::configureContainer($container, $application, $config, $input, $output); @@ -72,7 +76,7 @@ public function initContainer( Drush::setContainer($container); // Change service definitions as needed for our application. - $this->alterServicesForDrush($container, $application); + $this->alterServicesForDrush($container, $application, $input, $output); // Inject needed services into our application object. $this->injectApplicationServices($container, $application); @@ -92,12 +96,13 @@ public function installHandlers($container): void } // Add Drush Services to league/container 3.x - protected function addDrushServices($container, ClassLoader $loader, DrushDrupalFinder $drupalFinder, SiteAliasManager $aliasManager, DrushConfig $config, OutputInterface $output): void + protected function addDrushServices(Container $container, ClassLoader $loader, DrushDrupalFinder $drupalFinder, SiteAliasManager $aliasManager, DrushConfig $config, OutputInterface $output, InputInterface $input): void { // Override Robo's logger with a LoggerManager that delegates to the Drush logger. Robo::addShared($container, 'logger', '\Drush\Log\DrushLoggerManager') - ->addMethodCall('setLogOutputStyler', ['logStyler']) - ->addMethodCall('add', ['drush', new Logger($output)]); + ->addMethodCall('setLogOutputStyler', ['logStyler']) + ->addMethodCall('add', ['drush', new Logger($output)]); + Robo::addShared($container, LoggerInterface::class, 'logger'); // For autowiring Robo::addShared($container, self::LOADER, $loader); Robo::addShared($container, ClassLoader::class, self::LOADER); // For autowiring @@ -110,10 +115,11 @@ protected function addDrushServices($container, ClassLoader $loader, DrushDrupal // Override Robo's formatter manager with our own // @todo not sure that we'll use this. Maybe remove it. - Robo::addShared($container, 'formatterManager', DrushFormatterManager::class) + Robo::addShared($container, self::FORMATTER_MANAGER, DrushFormatterManager::class) ->addMethodCall('addDefaultFormatters', []) ->addMethodCall('addDefaultSimplifiers', []) ->addMethodCall('addSimplifier', [new EntityToArraySimplifier()]); + Robo::addShared($container, FormatterManager::class, self::FORMATTER_MANAGER); // For autowiring // Add some of our own objects to the container Robo::addShared($container, 'service.manager', 'Drush\Runtime\ServiceManager') @@ -156,11 +162,17 @@ protected function addDrushServices($container, ClassLoader $loader, DrushDrupal ->invokeMethod('setProcessManager', ['process.manager']); } - protected function alterServicesForDrush($container, Application $application): void + protected function alterServicesForDrush($container, Application $application, InputInterface $input, OutputInterface $output): void { $paramInjection = $container->get('parameterInjection'); $paramInjection->register('Symfony\Component\Console\Style\SymfonyStyle', new DrushStyleInjector()); + // Alias the dispatcher service that is defined in \Robo\Robo::configureContainer. + Robo::addShared($container, EventDispatcherInterface::class, 'eventDispatcher'); // For autowiring + + // Alias the config service that is defined in \Robo\Robo::configureContainer. + Robo::addShared($container, DrushConfig::class, 'config'); // For autowiring + // Add our own callback to the hook manager $hookManager = $container->get('hookManager'); $hookManager->addCommandEvent(new GlobalOptionsEventListener()); diff --git a/src/Runtime/ServiceManager.php b/src/Runtime/ServiceManager.php index 0588d63549..285e92fac8 100644 --- a/src/Runtime/ServiceManager.php +++ b/src/Runtime/ServiceManager.php @@ -18,19 +18,25 @@ use Drush\Boot\DrupalBootLevels; use Drush\Commands\DrushCommands; use Drush\Config\DrushConfig; +use Drush\Drush; use Grasmash\YamlCli\Command\GetValueCommand; use Grasmash\YamlCli\Command\LintCommand; use Grasmash\YamlCli\Command\UnsetKeyCommand; use Grasmash\YamlCli\Command\UpdateKeyCommand; use Grasmash\YamlCli\Command\UpdateValueCommand; use League\Container\Container as DrushContainer; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Robo\ClassDiscovery\RelativeNamespaceDiscovery; use Robo\Contract\ConfigAwareInterface; use Robo\Contract\OutputAwareInterface; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\ConsoleEvents; +use Symfony\Component\Console\Event\ConsoleCommandEvent; +use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Input\InputAwareInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; /** * Manage Drush services. @@ -54,6 +60,9 @@ class ServiceManager /** @var string[] */ protected array $bootstrapCommandClasses = []; + /** @var string[] */ + protected array $bootstrapListenerClasses = []; + public function __construct( protected ClassLoader $autoloader, protected DrushConfig $config, @@ -87,6 +96,17 @@ public function bootstrapCommandClasses(): array return $this->bootstrapCommandClasses; } + /** + * Return cached of deferred scubscriber objects. + * + * @return string[] + * List of class names to instantiate at bootstrap time. + */ + public function bootstrapListenerClasses(): array + { + return $this->bootstrapListenerClasses; + } + /** * Discover all of the different kinds of command handler objects * in the places where Drush can find them. Called during preflight; @@ -95,7 +115,7 @@ public function bootstrapCommandClasses(): array * * @param string[] $commandfileSearchpath List of directories to search * @param string $baseNamespace The namespace to use at the base of each - * search diretory. Namespace components mirror directory structure. + * search directory. Namespace components mirror directory structure. * * @return string[] * List of command classes @@ -261,6 +281,32 @@ public function discoverModuleCommandInfoAlterers(array $directoryList, string $ return array_values($commandClasses); } + /** + * Discovers Listener classes from a provided search path. + * + * @param string[] $directoryList List of directories to search + * @param string $baseNamespace The namespace to use at the base of each + * search directory. Namespace components mirror directory structure. + * + * @return string[] + * List Listeners. + */ + public function discoverListeners(array $directoryList, string $baseNamespace): array + { + $discovery = new CommandFileDiscovery(); + $discovery + ->setIncludeFilesAtBase(true) + ->setSearchDepth(3) + ->ignoreNamespacePart('contrib', 'Listeners') + ->ignoreNamespacePart('custom', 'Listeners') + ->ignoreNamespacePart('src') + ->setSearchLocations(['Listeners']) + ->setSearchPattern('#.*(Listener)s?.php$#'); + $baseNamespace = ltrim($baseNamespace, '\\'); + $listenerClasses = $discovery->discover($directoryList, $baseNamespace); + return array_values($listenerClasses); + } + /** * Instantiate commands from Grasmash\YamlCli that we want to expose * as Drush commands. @@ -318,12 +364,12 @@ public function instantiateDrupalCoreBootstrappedCommands(): array * Drupal and Drush DI containers. If there is no static factory, then * instantiate it via 'new $class' * - * @param string[] $bootstrapCommandClasses Classes to instantiate. + * @param string[] $serviceClasses Classes to instantiate. * * @return object[] * List of instantiated service objects */ - public function instantiateServices(array $bootstrapCommandClasses, DrushContainer $drushContainer, ?DrupalContainer $container = null): array + public function instantiateServices(array $serviceClasses, DrushContainer $drushContainer, ?DrupalContainer $container = null): array { $commandHandlers = []; @@ -331,7 +377,7 @@ public function instantiateServices(array $bootstrapCommandClasses, DrushContain // particularly DrushCommands (our abstract base class). // n.b. we cannot simply use 'isInstantiable' here because // the constructor is typically protected when using a static create method - $bootstrapCommandClasses = array_filter($bootstrapCommandClasses, function ($class) { + $serviceClasses = array_filter($serviceClasses, function ($class) { try { $reflection = new \ReflectionClass($class); } catch (\Throwable $e) { @@ -345,7 +391,7 @@ public function instantiateServices(array $bootstrapCommandClasses, DrushContain // Combine the two containers. $drushContainer->delegate($container); } - foreach ($bootstrapCommandClasses as $class) { + foreach ($serviceClasses as $class) { $commandHandler = null; try { @@ -372,6 +418,37 @@ public function instantiateServices(array $bootstrapCommandClasses, DrushContain return $commandHandlers; } + /** + * Add listeners to Drush's event dispatcher. + */ + public function addListeners(iterable $classes, ContainerInterface $drushContainer, ?ContainerInterface $drupalContainer = null): void + { + $instances = $this->instantiateServices($classes, $drushContainer, $drupalContainer); + foreach ($instances as $instance) { + $reflectionObject = new \ReflectionObject($instance); + $attributes = $reflectionObject->getAttributes(AsEventListener::class); + foreach ($attributes as $attribute) { + $attributeInstance = $attribute->newInstance(); + $method = $attributeInstance->method ?? '__invoke'; + $priority = $attributeInstance->priority ?? 0; + $reflectionMethod = $reflectionObject->getMethod($method); + $reflectionParameters = $reflectionMethod->getParameters(); + $paramType = $reflectionParameters[0]->getType(); + if ($paramType instanceof \ReflectionNamedType) { + $eventName = $paramType->getName(); + } else { + throw new \Exception('Event listener method must have a single parameter with a type hint.'); + } + $eventName = match ($eventName) { + ConsoleCommandEvent::class => ConsoleEvents::COMMAND, + ConsoleTerminateEvent::class => ConsoleEvents::TERMINATE, + default => $eventName, + }; + Drush::getContainer()->get('eventDispatcher')->addListener($eventName, $instance->$method(...), $priority); + } + } + } + /** * Determine if the first parameter of the create method supports our container with delegate. */ @@ -406,6 +483,16 @@ protected function bootStrapAttributeValue(string $class): ?int return null; } + // If a command class has a Bootstrap Attribute or static `create` method, we + // postpone instantiating it until after we bootstrap Drupal. + public function filterListeners($listenClasses): array + { + $this->bootstrapListenerClasses = array_filter($listenClasses, [$this, 'requiresBootstrap']); + + // Remove the listener classes that we put into the bootstrap listener classes. + return array_diff($listenClasses, $this->bootstrapListenerClasses); + } + /** * Check whether a command class requires Drupal bootstrap. */ diff --git a/sut/modules/unish/woot/src/Drush/CommandInfoAlterers/WootCommandInfoAlterer.php b/sut/modules/unish/woot/src/Drush/CommandInfoAlterers/WootCommandInfoAlterer.php deleted file mode 100644 index e2cfc6a495..0000000000 --- a/sut/modules/unish/woot/src/Drush/CommandInfoAlterers/WootCommandInfoAlterer.php +++ /dev/null @@ -1,31 +0,0 @@ -logger = $loggerFactory->get('drush'); - } - - public function alterCommandInfo(CommandInfo $commandInfo, $commandFileInstance) - { - if ($commandInfo->getName() === 'woot:altered') { - $commandInfo->setAliases('woot-new-alias'); - $this->logger->debug(dt("Module 'woot' changed the alias of 'woot:altered' command into 'woot-new-alias' in " . __METHOD__ . '().')); - } - } -} diff --git a/sut/modules/unish/woot/src/Drush/Listeners/WootDefinitionListener.php b/sut/modules/unish/woot/src/Drush/Listeners/WootDefinitionListener.php new file mode 100644 index 0000000000..8ee1fc5216 --- /dev/null +++ b/sut/modules/unish/woot/src/Drush/Listeners/WootDefinitionListener.php @@ -0,0 +1,34 @@ +getApplication()->all() as $id => $command) { + if ($command->getName() === 'woot:altered') { + $command->setAliases(['woot-new-alias']); + if ($id == 'woot-initial-alias') { + $event->getApplication()->remove('woot-initial-alias'); + } + $this->logger->debug(dt("Module 'woot' changed the alias of 'woot:altered' command into 'woot-new-alias' in " . __METHOD__ . '().')); + } + } + } +} diff --git a/tests/functional/CommandInfoAlterTest.php b/tests/functional/CommandDefinitionAlterTest.php similarity index 64% rename from tests/functional/CommandInfoAlterTest.php rename to tests/functional/CommandDefinitionAlterTest.php index ae24b32fde..8da5b51848 100644 --- a/tests/functional/CommandInfoAlterTest.php +++ b/tests/functional/CommandDefinitionAlterTest.php @@ -5,20 +5,19 @@ namespace Unish; use Drush\Commands\pm\PmCommands; -use Symfony\Component\Filesystem\Path; /** * @group commands * */ -class CommandInfoAlterTest extends CommandUnishTestCase +class CommandDefinitionAlterTest extends CommandUnishTestCase { use TestModuleHelperTrait; /** - * Tests command info alter. + * Tests Console Definition Event Listener. */ - public function testCommandInfoAlter() + public function testCommandDefinitionAlter() { $this->setUpDrupal(1, true); $this->drush(PmCommands::INSTALL, ['woot']); @@ -27,8 +26,9 @@ public function testCommandInfoAlter() $this->assertStringContainsString('woot-new-alias', $this->getOutput()); // Check the debug messages. - $this->assertStringContainsString('[debug] Commands are potentially altered in Drupal\woot\Drush\CommandInfoAlterers\WootCommandInfoAlterer.', $this->getErrorOutput()); - $this->assertStringContainsString("[debug] Module 'woot' changed the alias of 'woot:altered' command into 'woot-new-alias' in Drupal\woot\Drush\CommandInfoAlterers\WootCommandInfoAlterer::alterCommandInfo().", $this->getErrorOutput()); + $this->assertStringContainsString("[debug] Module 'woot' changed the alias of 'woot:altered' command into 'woot-new-alias' in Drupal\woot\Drush\Listeners\WootDefinitionListener::__invoke().", $this->getErrorOutput()); + // Listeners dispatch mostly outside of Drush so no longer able to asset this message. + // $this->assertStringContainsString('[debug] Commands are potentially altered in Drupal\woot\Drush\Listeners.', $this->getErrorOutput()); // Try to run the command with the initial alias. $this->drush('woot-initial-alias', [], [], null, null, self::EXIT_ERROR); diff --git a/tests/functional/ConfigPullTest.php b/tests/functional/ConfigPullTest.php index bc614218a1..6981a9eec0 100644 --- a/tests/functional/ConfigPullTest.php +++ b/tests/functional/ConfigPullTest.php @@ -4,9 +4,10 @@ namespace Unish; -use Drush\Commands\config\ConfigPullCommands; use Drush\Commands\config\ConfigCommands; +use Drush\Commands\config\ConfigGetCommand; use Drush\Commands\config\ConfigImportCommands; +use Drush\Commands\config\ConfigPullCommands; use Symfony\Component\Filesystem\Path; /** @@ -37,14 +38,14 @@ public function testConfigPull() $source = $aliases['stage']; $destination = $aliases['dev']; // Make UUID match. - $this->drush(ConfigCommands::GET, ['system.site', 'uuid'], $options, $source); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'uuid'], $options, $source); list($name, $uuid) = explode(' ', $this->getOutput()); $this->drush(ConfigCommands::SET, ['system.site', 'uuid', $uuid], $options, $destination); $this->drush(ConfigCommands::SET, ['system.site', 'name', 'testConfigPull'], $options, $source); $this->drush(ConfigPullCommands::PULL, [$source, $destination], $options); $this->drush(ConfigImportCommands::IMPORT, [], $options, $destination); - $this->drush(ConfigCommands::GET, ['system.site', 'name'], $options, $source); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'name'], $options, $source); $this->assertEquals("'system.site:name': testConfigPull", $this->getOutput(), 'Config was successfully pulled.'); // Test that custom target dir works diff --git a/tests/functional/ConfigTest.php b/tests/functional/ConfigTest.php index 3616e5c059..694e11becc 100644 --- a/tests/functional/ConfigTest.php +++ b/tests/functional/ConfigTest.php @@ -5,12 +5,14 @@ namespace Unish; use Drupal\Core\Serialization\Yaml; -use Drush\Commands\core\PhpCommands; -use Drush\Commands\core\StatusCommands; use Drush\Commands\config\ConfigCommands; use Drush\Commands\config\ConfigExportCommands; +use Drush\Commands\config\ConfigGetCommand; use Drush\Commands\config\ConfigImportCommands; +use Drush\Commands\config\ConfigSetCommand; +use Drush\Commands\core\PhpCommands; use Drush\Commands\core\StateCommands; +use Drush\Commands\core\StatusCommands; use Drush\Commands\pm\PmCommands; use Symfony\Component\Filesystem\Path; @@ -40,29 +42,29 @@ public function setup(): void public function testConfigGetSet() { // Simple value - $this->drush(ConfigCommands::SET, ['system.site', 'name', 'config_test']); - $this->drush(ConfigCommands::GET, ['system.site', 'name']); + $this->drush(ConfigSetCommand::NAME, ['system.site', 'name', 'config_test']); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'name']); $this->assertEquals("'system.site:name': config_test", $this->getOutput()); // Nested value - $this->drush(ConfigCommands::SET, ['system.site', 'page.front', 'llama']); - $this->drush(ConfigCommands::GET, ['system.site', 'page.front']); + $this->drush(ConfigSetCommand::NAME, ['system.site', 'page.front', 'llama']); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'page.front']); $this->assertEquals("'system.site:page.front': llama", $this->getOutput()); // Simple sequence value - $this->drush(ConfigCommands::SET, ['user.role.authenticated', 'permissions', '[foo,bar]'], ['input-format' => 'yaml']); - $this->drush(ConfigCommands::GET, ['user.role.authenticated', 'permissions'], ['format' => 'json']); + $this->drush(ConfigSetCommand::NAME, ['user.role.authenticated', 'permissions', '[foo,bar]'], ['input-format' => 'yaml']); + $this->drush(ConfigGetCommand::NAME, ['user.role.authenticated', 'permissions'], ['format' => 'json']); $output = $this->getOutputFromJSON('user.role.authenticated:permissions'); // Mapping value - $this->drush(ConfigCommands::SET, ['system.site', 'page', "{403: '403', front: home}"], ['input-format' => 'yaml']); - $this->drush(ConfigCommands::GET, ['system.site', 'page'], ['format' => 'json']); + $this->drush(ConfigSetCommand::NAME, ['system.site', 'page', "{403: '403', front: home}"], ['input-format' => 'yaml']); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'page'], ['format' => 'json']); $output = $this->getOutputFromJSON('system.site:page'); $this->assertSame(['403' => '403', 'front' => 'home'], $output); // Multiple top-level keys - $this->drush(ConfigCommands::SET, ['user.role.authenticated', '?', "{label: 'Auth user', weight: 5}"], ['input-format' => 'yaml']); - $this->drush(ConfigCommands::GET, ['user.role.authenticated'], ['format' => 'json']); + $this->drush(ConfigSetCommand::NAME, ['user.role.authenticated', '?', "{label: 'Auth user', weight: 5}"], ['input-format' => 'yaml']); + $this->drush(ConfigGetCommand::NAME, ['user.role.authenticated'], ['format' => 'json']); $output = $this->getOutputFromJSON(); $this->assertSame('Auth user', $output['label']); $this->assertSame(5, $output['weight']); @@ -87,7 +89,7 @@ public function testConfigExportImportStatusExistingConfig() // Test import. $this->drush(ConfigImportCommands::IMPORT); - $this->drush(ConfigCommands::GET, ['system.site', 'page'], ['format' => 'json']); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'page'], ['format' => 'json']); $page = $this->getOutputFromJSON('system.site:page'); $this->assertStringContainsString('unish', $page['front'], 'Config was successfully imported.'); @@ -113,7 +115,7 @@ public function testConfigExportImportStatusExistingConfig() $contents = preg_replace('/front: .*/', 'front: unish existing', $contents); file_put_contents($system_site_yml, $contents); $this->installDrupal('dev', true, ['existing-config' => true], false); - $this->drush(ConfigCommands::GET, ['system.site', 'page'], ['format' => 'json']); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'page'], ['format' => 'json']); $page = $this->getOutputFromJSON('system.site:page'); $this->assertStringContainsString('unish existing', $page['front'], 'Existing config was successfully imported during site:install.'); @@ -124,7 +126,7 @@ public function testConfigExportImportStatusExistingConfig() $this->mkdir($partial_path); $contents = file_put_contents($partial_path . '/system.site.yml', $contents); $this->drush(ConfigImportCommands::IMPORT, [], ['partial' => null, 'source' => $partial_path]); - $this->drush(ConfigCommands::GET, ['system.site', 'page'], ['format' => 'json']); + $this->drush(ConfigGetCommand::NAME, ['system.site', 'page'], ['format' => 'json']); $page = $this->getOutputFromJSON('system.site:page'); $this->assertStringContainsString('unish partial', $page['front'], '--partial was successfully imported.'); } diff --git a/tests/functional/FieldTest.php b/tests/functional/FieldTest.php index 751e97956b..d2c9ec347c 100644 --- a/tests/functional/FieldTest.php +++ b/tests/functional/FieldTest.php @@ -5,7 +5,7 @@ namespace Unish; use Drupal\Core\Field\FieldStorageDefinitionInterface; -use Drush\Commands\config\ConfigCommands; +use Drush\Commands\config\ConfigGetCommand; use Drush\Commands\core\PhpCommands; use Drush\Commands\field\FieldBaseInfoCommands; use Drush\Commands\field\FieldBaseOverrideCreateCommands; @@ -133,7 +133,7 @@ public function testFieldBaseCreateOverride() 'is-required' => true, ]; $this->drush(FieldBaseOverrideCreateCommands::BASE_OVERRIDE_CREATE, ['user', 'user'], $options); - $this->drush(ConfigCommands::GET, ['core.base_field_override.user.user.name'], ['format' => 'json']); + $this->drush(ConfigGetCommand::NAME, ['core.base_field_override.user.user.name'], ['format' => 'json']); $json = $this->getOutputFromJSON(); $this->assertSame('Handle', $json['label']); $this->assertSame(true, $json['required']); diff --git a/tests/functional/SqlSyncTest.php b/tests/functional/SqlSyncTest.php index 8bb1377e3f..9e2c5d964f 100644 --- a/tests/functional/SqlSyncTest.php +++ b/tests/functional/SqlSyncTest.php @@ -7,6 +7,7 @@ use Drush\Commands\core\PhpCommands; use Drush\Commands\core\UserCommands; use Drush\Commands\pm\PmCommands; +use Drush\Commands\sql\sanitize\SanitizeCommand; use Drush\Commands\sql\sanitize\SanitizeCommands; use Drush\Commands\sql\SqlCommands; use Drush\Commands\sql\SqlSyncCommands; @@ -115,7 +116,7 @@ public function localSqlSync() 'structure-tables-list' => 'cache,cache*', ]; $this->drush(SqlSyncCommands::SYNC, ['@sut.stage', '@sut.dev'], $sync_options); - $this->drush(SanitizeCommands::SANITIZE, [], ['yes' => null, 'uri' => 'dev',], '@sut.dev'); + $this->drush(SanitizeCommand::NAME, [], ['yes' => null, 'uri' => 'dev',], '@sut.dev'); // Confirm that the sample user is unchanged on the staging site $this->drush(UserCommands::INFORMATION, [$name], $options + ['format' => 'json'], '@sut.stage'); diff --git a/tests/integration/ImageTest.php b/tests/integration/ImageTest.php index 16fbde81f4..8a0299be5c 100644 --- a/tests/integration/ImageTest.php +++ b/tests/integration/ImageTest.php @@ -4,19 +4,22 @@ namespace Unish; -use Drush\Commands\core\ImageCommands; +use Drush\Commands\core\ImageDeriveCommand; +use Drush\Commands\core\ImageFlushCommand; use Drush\Commands\pm\PmCommands; +use Symfony\Component\Console\Tester\ApplicationTester; /** - * Tests image-flush command + * Tests image:flush and image:derive commands. * * @group commands */ -class ImageTest extends UnishIntegrationTestCase +class ImageTest extends UnishApplicationTesterTestCase { public function testImage() { $this->drush(PmCommands::INSTALL, ['image']); + $logo = 'core/misc/menu-expanded.png'; $styles_dir = $this->webroot() . '/sites/default/files/styles/'; $thumbnail = $styles_dir . 'thumbnail/public/' . $logo; @@ -28,23 +31,27 @@ public function testImage() // Remove stray files left over from previous runs @unlink($thumbnail); + $this->assertFileDoesNotExist($thumbnail); // Test that "drush image-derive" works. $style_name = 'thumbnail'; - $this->drush(ImageCommands::DERIVE, [$style_name, $logo]); + $this->drush(ImageDeriveCommand::NAME, [$style_name, $logo]); $this->assertFileExists($thumbnail); // Test that "drush image-flush thumbnail" deletes derivatives created by the thumbnail image style. - $this->drush(ImageCommands::FLUSH, [$style_name], ['all' => null]); + $applicationTester = new ApplicationTester($this->getApplication()); + $applicationTester->run([ImageFlushCommand::NAME, 'style_names' => $style_name, '--no-interaction' => true]); + $output = $applicationTester->getDisplay(); $this->assertFileDoesNotExist($thumbnail); + // @todo note stdin testing documented at https://github.com/symfony/symfony/issues/37835 // Check that "drush image-flush --all" deletes all image styles by creating two different ones and testing its // existence afterwards. - $this->drush(ImageCommands::DERIVE, ['thumbnail', $logo]); + $this->drush(ImageDeriveCommand::NAME, ['thumbnail', $logo]); $this->assertFileExists($thumbnail); - $this->drush(ImageCommands::DERIVE, ['medium', $logo]); + $this->drush(ImageDeriveCommand::NAME, ['medium', $logo]); $this->assertFileExists($medium); - $this->drush(ImageCommands::FLUSH, [], ['all' => null]); + $this->drush(ImageFlushCommand::NAME, [], ['all' => null]); $this->assertFileDoesNotExist($thumbnail); $this->assertFileDoesNotExist($medium); } diff --git a/tests/unish/UnishApplicationTesterTestCase.php b/tests/unish/UnishApplicationTesterTestCase.php new file mode 100644 index 0000000000..80b8428150 --- /dev/null +++ b/tests/unish/UnishApplicationTesterTestCase.php @@ -0,0 +1,16 @@ +application($this->webroot(), [$this->getDrush()]); + } +} diff --git a/tests/unish/UnishIntegrationTestCase.php b/tests/unish/UnishIntegrationTestCase.php index 3dbfc4f040..cb4504d45d 100644 --- a/tests/unish/UnishIntegrationTestCase.php +++ b/tests/unish/UnishIntegrationTestCase.php @@ -27,6 +27,18 @@ abstract class UnishIntegrationTestCase extends UnishTestCase protected string $stdout = ''; protected string $stderr = ''; + /** + * This method is called before each test. + */ + protected function setUp(): void + { + // Install the SUT if necessary + if (!RuntimeController::instance()->initialized()) { + $this->checkInstallSut(); + } + parent::setUp(); + } + /** * @inheritdoc */ @@ -44,7 +56,8 @@ public function getErrorOutputRaw(): string } /** - * Invoke drush via a direct method call to Application::run(). + * Invoke drush via a direct method call to Application::run(). When + * testing command output, use CommandTester or ApplicationTester instead. * * @param $command * A defined drush command such as 'cron', 'status' and so on @@ -60,11 +73,6 @@ public function getErrorOutputRaw(): string */ public function drush(string $command, array $args = [], array $options = [], ?int $expected_return = self::EXIT_SUCCESS, string|bool $stdin = false): ?int { - // Install the SUT if necessary - if (!RuntimeController::instance()->initialized()) { - $this->checkInstallSut(); - } - $cmd = $this->buildCommandLine($command, $args, $options); // Get the application instance from the runtime controller.