Skip to content

Commit 5b55f98

Browse files
committed
Convert sql:sanitize command. Convert the UserTable sanitizer to a Listener
1 parent 74b697c commit 5b55f98

File tree

6 files changed

+198
-96
lines changed

6 files changed

+198
-96
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drush\Commands\sql\sanitize;
6+
7+
use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
8+
use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait;
9+
use Drush\Attributes as CLI;
10+
use Drush\Boot\DrupalBootLevels;
11+
use Drush\Commands\AutowireTrait;
12+
use Drush\Commands\core\DocsCommands;
13+
use Drush\Event\SanitizeConfirmsEvent;
14+
use Drush\Exceptions\UserAbortException;
15+
use Drush\Style\DrushStyle;
16+
use Psr\EventDispatcher\EventDispatcherInterface;
17+
use Symfony\Component\Console\Attribute\AsCommand;
18+
use Symfony\Component\Console\Command\Command;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
22+
#[AsCommand(
23+
name: self::NAME,
24+
description: 'Sanitize the database by removing or obfuscating user data.',
25+
aliases: ['sqlsan','sql-sanitize']
26+
)]
27+
// @todo Deal with topics on classes.
28+
#[CLI\Topics(topics: [DocsCommands::HOOKS])]
29+
#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
30+
final class SanitizeCommand extends Command implements CustomEventAwareInterface
31+
{
32+
use AutowireTrait;
33+
use CustomEventAwareTrait;
34+
35+
const NAME = 'sql:sanitize';
36+
37+
public function __construct(protected EventDispatcherInterface $eventDispatcher)
38+
{
39+
parent::__construct();
40+
}
41+
42+
43+
protected function configure()
44+
{
45+
$this
46+
->setDescription('Sanitize the database by removing or obfuscating user data.')
47+
->addUsage('drush sql:sanitize --sanitize-password=no')
48+
->addUsage('drush sql:sanitize --allowlist-fields=field_biography,field_phone_number');
49+
}
50+
51+
/**
52+
* Commandfiles may add custom operations by implementing a Listener that subscribes to two events:
53+
*
54+
* - `\Drush\Events\SanitizeConfirmsEvent`. Display summary to user before confirmation.
55+
* - `\Symfony\Component\Console\Event\ConsoleTerminateEvent`. Run queries or call APIs to perform sanitizing
56+
*
57+
* Several working Listeners may be found at https://github.com/drush-ops/drush/tree/13.x/src/Drush/Listeners/sanitize
58+
*/
59+
60+
protected function execute(InputInterface $input, OutputInterface $output): int
61+
{
62+
$io = new DrushStyle($input, $output);
63+
64+
/**
65+
* In order to present only one prompt, collect all confirmations up front.
66+
*/
67+
$event = new SanitizeConfirmsEvent($input);
68+
$this->eventDispatcher->dispatch($event, SanitizeConfirmsEvent::class);
69+
$messages = $event->getMessages();
70+
71+
// Also collect from legacy commandfiles.
72+
$handlers = $this->getCustomEventHandlers(SanitizeCommands::CONFIRMS);
73+
foreach ($handlers as $handler) {
74+
$handler($messages, $input);
75+
}
76+
// @phpstan-ignore if.alwaysFalse
77+
if ($messages) {
78+
$output->writeln(dt('The following operations will be performed:'));
79+
$io->listing($messages);
80+
}
81+
if (!$io->confirm(dt('Do you want to sanitize the current database?'))) {
82+
throw new UserAbortException();
83+
}
84+
// All sanitize operations happen during the built-in console.terminate event.
85+
86+
return self::SUCCESS;
87+
}
88+
}

src/Commands/sql/sanitize/SanitizeCommands.php

Lines changed: 3 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,11 @@
44

55
namespace Drush\Commands\sql\sanitize;
66

7-
use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
8-
use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait;
9-
use Drush\Attributes as CLI;
10-
use Drush\Boot\DrupalBootLevels;
11-
use Drush\Commands\core\DocsCommands;
12-
use Drush\Commands\DrushCommands;
13-
use Drush\Exceptions\UserAbortException;
7+
use JetBrains\PhpStorm\Deprecated;
148

15-
#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
16-
final class SanitizeCommands extends DrushCommands implements CustomEventAwareInterface
9+
#[Deprecated('Moved to Drush\Commands\sql\sanitize\SanitizeCommand.')]
10+
final class SanitizeCommands
1711
{
18-
use CustomEventAwareTrait;
19-
2012
const SANITIZE = 'sql:sanitize';
2113
const CONFIRMS = 'sql-sanitize-confirms';
22-
23-
/**
24-
* Sanitize the database by removing or obfuscating user data.
25-
*
26-
* Commandfiles may add custom operations by implementing:
27-
*
28-
* - `#[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)]`. Display summary to user before confirmation.
29-
* - `#[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)]`. Run queries or call APIs to perform sanitizing
30-
*
31-
* Several working commandfiles may be found at https://github.com/drush-ops/drush/tree/13.x/src/Commands/sql/sanitize
32-
*/
33-
#[CLI\Command(name: self::SANITIZE, aliases: ['sqlsan','sql-sanitize'])]
34-
#[CLI\Usage(name: 'drush sql:sanitize --sanitize-password=no', description: 'Sanitize database without modifying any passwords.')]
35-
#[CLI\Usage(name: 'drush sql:sanitize --allowlist-fields=field_biography,field_phone_number', description: 'Sanitizes database but exempts two user fields from modification.')]
36-
#[CLI\Topics(topics: [DocsCommands::HOOKS])]
37-
public function sanitize(): void
38-
{
39-
/**
40-
* In order to present only one prompt, collect all confirmations from
41-
* commandfiles up front. sql:sanitize plugins are commandfiles that implement
42-
* \Drush\Commands\sql\SanitizePluginInterface
43-
*/
44-
$messages = [];
45-
$input = $this->input();
46-
$handlers = $this->getCustomEventHandlers(self::CONFIRMS);
47-
foreach ($handlers as $handler) {
48-
$handler($messages, $input);
49-
}
50-
// @phpstan-ignore if.alwaysFalse
51-
if ($messages) {
52-
$this->output()->writeln(dt('The following operations will be performed:'));
53-
$this->io()->listing($messages);
54-
}
55-
if (!$this->io()->confirm(dt('Do you want to sanitize the current database?'))) {
56-
throw new UserAbortException();
57-
}
58-
59-
// All sanitize operations defined in post-command hooks, including Drush
60-
// core sanitize routines. See \Drush\Commands\sql\sanitize\SanitizePluginInterface.
61-
}
6214
}

src/Commands/sql/sanitize/SanitizePluginInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
namespace Drush\Commands\sql\sanitize;
66

77
use Consolidation\AnnotatedCommand\CommandData;
8+
use JetBrains\PhpStorm\Deprecated;
89
use Symfony\Component\Console\Input\InputInterface;
910

1011
/**
1112
* Implement this interface when building a Drush sql-sanitize plugin.
1213
*/
14+
#[Deprecated(reason: 'Implement an event listener instead.')]
1315
interface SanitizePluginInterface
1416
{
1517
/**

src/Event/SanitizeConfirmsEvent.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Drush\Event;
4+
5+
use Symfony\Component\Console\Input\InputInterface;
6+
use Symfony\Contracts\EventDispatcher\Event;
7+
8+
/*
9+
* A custom event, for prompting the user about candidate sanitize operations.
10+
*
11+
* Listeners should add their confirm messages via addMessage().
12+
*/
13+
14+
final class SanitizeConfirmsEvent extends Event
15+
{
16+
public function __construct(
17+
protected InputInterface $input,
18+
protected array $messages = [],
19+
) {
20+
}
21+
22+
public function setInput(InputInterface $input): void
23+
{
24+
$this->input = $input;
25+
}
26+
27+
public function getInput(): InputInterface
28+
{
29+
return $this->input;
30+
}
31+
32+
public function addMessage(string $message): self
33+
{
34+
$this->messages[] = $message;
35+
return $this;
36+
}
37+
38+
public function getMessages(): array
39+
{
40+
return $this->messages;
41+
}
42+
43+
public function setMessages(array $messages): self
44+
{
45+
$this->messages = $messages;
46+
return $this;
47+
}
48+
}

src/Commands/sql/sanitize/SanitizeUserTableCommands.php renamed to src/Listeners/sanitize/SanitizeUserTableListener.php

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,76 @@
22

33
declare(strict_types=1);
44

5-
namespace Drush\Commands\sql\sanitize;
5+
namespace Drush\Listeners\sanitize;
66

77
use Drupal\Core\Database\Connection;
88
use Drupal\Core\Database\Query\SelectInterface;
9-
use Consolidation\AnnotatedCommand\CommandData;
10-
use Consolidation\AnnotatedCommand\Hooks\HookManager;
119
use Drupal\Core\Entity\EntityTypeManagerInterface;
1210
use Drupal\Core\Password\PasswordInterface;
13-
use Drush\Attributes as CLI;
1411
use Drush\Commands\AutowireTrait;
15-
use Drush\Commands\DrushCommands;
12+
use Drush\Commands\sql\sanitize\SanitizeCommand;
13+
use Drush\Event\ConsoleDefinitionsEvent;
14+
use Drush\Event\SanitizeConfirmsEvent;
1615
use Drush\Sql\SqlBase;
1716
use Drush\Utils\StringUtils;
18-
use Symfony\Component\Console\Input\InputInterface;
17+
use Psr\Log\LoggerInterface;
18+
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
19+
use Symfony\Component\Console\Input\InputOption;
20+
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
1921

2022
/**
21-
* A sql:sanitize plugin.
23+
* Sanitize emails and passwords. This also an example of how to write a
24+
* database sanitizer for sql:sync.
2225
*/
23-
final class SanitizeUserTableCommands extends DrushCommands implements SanitizePluginInterface
26+
#[AsEventListener(method: 'onDefinition')]
27+
#[AsEventListener(method: 'onSanitizeConfirm')]
28+
#[AsEventListener(method: 'onConsoleTerminate')]
29+
final class SanitizeUserTableListener
2430
{
2531
use AutowireTrait;
2632

2733
public function __construct(
2834
protected Connection $database,
2935
protected PasswordInterface $passwordHasher,
30-
protected EntityTypeManagerInterface $entityTypeManager
36+
protected EntityTypeManagerInterface $entityTypeManager,
37+
protected LoggerInterface $logger,
3138
) {
32-
parent::__construct();
3339
}
3440

35-
/**
36-
* Sanitize emails and passwords. This also an example of how to write a
37-
* database sanitizer for sql:sync.
38-
*/
39-
#[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)]
40-
public function sanitize($result, CommandData $commandData): void
41+
public function onDefinition(ConsoleDefinitionsEvent $event): void
42+
{
43+
foreach ($event->getApplication()->all() as $id => $command) {
44+
if ($command->getName() === SanitizeCommand::NAME) {
45+
$command->addOption(
46+
'sanitize-email',
47+
null,
48+
InputOption::VALUE_REQUIRED,
49+
'The pattern for test email addresses in the sanitization operation, or <info>no</info> to keep email addresses unchanged. May contain replacement patterns <info>%uid</info>, <info>%mail</info> or <info>%name</info>.',
50+
51+
)
52+
->addOption('sanitize-password', null, InputOption::VALUE_REQUIRED, 'By default, passwords are randomized. Specify <info>no</info> to disable that. Specify any other value to set all passwords to that value.')
53+
->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.');
54+
}
55+
}
56+
}
57+
58+
public function onSanitizeConfirm(SanitizeConfirmsEvent $event): void
59+
{
60+
$options = $event->getInput()->getOptions();
61+
if ($this->isEnabled($options['sanitize-password'])) {
62+
$event->addMessage(dt('Sanitize user passwords.'));
63+
}
64+
if ($this->isEnabled($options['sanitize-email'])) {
65+
$event->addMessage(dt('Sanitize user emails.'));
66+
}
67+
if (in_array('ignored-roles', $options)) {
68+
$event->addMessage(dt('Preserve user emails and passwords for the specified roles.'));
69+
}
70+
}
71+
72+
public function onConsoleTerminate(ConsoleTerminateEvent $event): void
4173
{
42-
$options = $commandData->options();
74+
$options = $event->getInput()->getOptions();
4375
$query = $this->database->update('users_field_data')->condition('uid', 0, '>');
4476
$messages = [];
4577

@@ -60,7 +92,7 @@ public function sanitize($result, CommandData $commandData): void
6092
if ($this->isEnabled($options['sanitize-email'])) {
6193
if (str_contains($options['sanitize-email'], '%')) {
6294
// We need a different sanitization query for MSSQL, Postgres and Mysql.
63-
$sql = SqlBase::create($commandData->input()->getOptions());
95+
$sql = SqlBase::create($event->getInput()->getOptions());
6496
$db_driver = $sql->scheme();
6597
if ($db_driver === 'pgsql') {
6698
$email_map = ['%uid' => "' || uid || '", '%mail' => "' || replace(mail, '@', '_') || '", '%name' => "' || replace(name, ' ', '_') || '"];
@@ -100,34 +132,11 @@ public function sanitize($result, CommandData $commandData): void
100132
$query->execute();
101133
$this->entityTypeManager->getStorage('user')->resetCache();
102134
foreach ($messages as $message) {
103-
$this->logger()->success($message);
135+
$this->logger->success($message);
104136
}
105137
}
106138
}
107139

108-
#[CLI\Hook(type: HookManager::OPTION_HOOK, target: SanitizeCommands::SANITIZE)]
109-
#[CLI\Option(name: 'sanitize-email', description: 'The pattern for test email addresses in the sanitization operation, or <info>no</info> to keep email addresses unchanged. May contain replacement patterns <info>%uid</info>, <info>%mail</info> or <info>%name</info>.')]
110-
#[CLI\Option(name: 'sanitize-password', description: 'By default, passwords are randomized. Specify <info>no</info> to disable that. Specify any other value to set all passwords to that value.')]
111-
#[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.')]
112-
public function options($options = ['sanitize-email' => 'user+%[email protected]', 'sanitize-password' => null, 'ignored-roles' => null]): void
113-
{
114-
}
115-
116-
#[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)]
117-
public function messages(&$messages, InputInterface $input): void
118-
{
119-
$options = $input->getOptions();
120-
if ($this->isEnabled($options['sanitize-password'])) {
121-
$messages[] = dt('Sanitize user passwords.');
122-
}
123-
if ($this->isEnabled($options['sanitize-email'])) {
124-
$messages[] = dt('Sanitize user emails.');
125-
}
126-
if (in_array('ignored-roles', $options)) {
127-
$messages[] = dt('Preserve user emails and passwords for the specified roles.');
128-
}
129-
}
130-
131140
/**
132141
* Test an option value to see if it is disabled.
133142
*/

src/Runtime/ServiceManager.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use Symfony\Component\Console\Command\Command;
3535
use Symfony\Component\Console\ConsoleEvents;
3636
use Symfony\Component\Console\Event\ConsoleCommandEvent;
37+
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
3738
use Symfony\Component\Console\Input\InputAwareInterface;
3839
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
3940

@@ -438,9 +439,11 @@ public function addListeners(iterable $classes, ContainerInterface $drushContain
438439
} else {
439440
throw new \Exception('Event listener method must have a single parameter with a type hint.');
440441
}
441-
if ($eventName == ConsoleCommandEvent::class) {
442-
$eventName = ConsoleEvents::COMMAND;
443-
}
442+
$eventName = match ($eventName) {
443+
ConsoleCommandEvent::class => ConsoleEvents::COMMAND,
444+
ConsoleTerminateEvent::class => ConsoleEvents::TERMINATE,
445+
default => $eventName,
446+
};
444447
Drush::getContainer()->get('eventDispatcher')->addListener($eventName, $instance->$method(...), $priority);
445448
}
446449
}

0 commit comments

Comments
 (0)