Completed
Push — fix-ci-lock-issue ( 59eaa8...fb09a4 )
by Damien
02:24 queued 02:22
created

CleanAuditLogsCommand::unlock()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
c 0
b 0
f 0
rs 10
cc 1
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace DH\Auditor\Provider\Doctrine\Persistence\Command;
6
7
use DateInterval;
8
use DateTimeImmutable;
9
use DH\Auditor\Auditor;
10
use DH\Auditor\Provider\Doctrine\Configuration;
11
use DH\Auditor\Provider\Doctrine\DoctrineProvider;
12
use DH\Auditor\Provider\Doctrine\Persistence\Helper\DoctrineHelper;
13
use DH\Auditor\Provider\Doctrine\Persistence\Schema\SchemaManager;
14
use DH\Auditor\Provider\Doctrine\Service\StorageService;
15
use Exception;
16
use Symfony\Component\Console\Command\Command;
17
use Symfony\Component\Console\Command\LockableTrait;
18
use Symfony\Component\Console\Helper\ProgressBar;
19
use Symfony\Component\Console\Input\InputArgument;
20
use Symfony\Component\Console\Input\InputInterface;
21
use Symfony\Component\Console\Input\InputOption;
22
use Symfony\Component\Console\Output\OutputInterface;
23
use Symfony\Component\Console\Style\SymfonyStyle;
24
25
/**
26
 * @see \DH\Auditor\Tests\Provider\Doctrine\Persistence\Command\CleanAuditLogsCommandTest
27
 */
28
class CleanAuditLogsCommand extends Command
29
{
30
    use LockableTrait;
31
32
    /**
33
     * @var string
34
     */
35
    private const UNTIL_DATE_FORMAT = 'Y-m-d H:i:s';
36
37
    private Auditor $auditor;
38
39
    public function setAuditor(Auditor $auditor): self
40
    {
41
        $this->auditor = $auditor;
42
43
        return $this;
44
    }
45
46
    protected function configure(): void
47
    {
48
        $this
49
            ->setDescription('Cleans audit tables')
50
            ->setName('audit:clean')
51
            ->addOption('no-confirm', null, InputOption::VALUE_NONE, 'No interaction mode')
52
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not execute SQL queries.')
53
            ->addOption('dump-sql', null, InputOption::VALUE_NONE, 'Prints SQL related queries.')
54
            ->addOption('exclude', 'x', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Entities to exclude from cleaning')
55
            ->addOption('include', 'i', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Entities to include in cleaning')
56
            ->addOption('date', 'd', InputOption::VALUE_REQUIRED, 'Specify a custom date to clean audits until (must be expressed as an ISO 8601 date, e.g. 2023-04-24).')
57
            ->addArgument('keep', InputArgument::OPTIONAL, 'Audits retention period (must be expressed as an ISO 8601 date interval, e.g. P12M to keep the last 12 months or P7D to keep the last 7 days).', 'P12M')
58
        ;
59
    }
60
61
    protected function execute(InputInterface $input, OutputInterface $output): int
62
    {
63
        if (!$this->lock()) {
64
            $output->writeln('The command is already running in another process.');
65
66
            return Command::SUCCESS;
67
        }
68
69
        $io = new SymfonyStyle($input, $output);
70
71
        $keep = $input->getArgument('keep');
72
        $keep = (\is_array($keep) ? $keep[0] : $keep);
73
74
        /** @var string $date */
75
        $date = $input->getOption('date');
76
        $until = null;
77
78
        if ($date) {
79
            // Use custom date if provided
80
            try {
81
                $until = new DateTimeImmutable($date);
82
            } catch (Exception $e) {
83
                $io->error(sprintf('Invalid date format provided: %s', $date));
84
            }
85
        } else {
86
            // Fall back to default retention period
87
            $until = $this->validateKeepArgument($keep, $io);
88
        }
89
90
        if (null === $until) {
91
            return Command::SUCCESS;
92
        }
93
94
        /** @var DoctrineProvider $provider */
95
        $provider = $this->auditor->getProvider(DoctrineProvider::class);
96
        $schemaManager = new SchemaManager($provider);
97
98
        /** @var StorageService[] $storageServices */
99
        $storageServices = $provider->getStorageServices();
100
101
        // auditable classes by storage entity manager
102
        $count = 0;
103
104
        // Collect auditable classes from auditing storage managers
105
        $rawExcludeValues = $input->getOption('exclude') ?? [];
106
        $rawIncludeValues = $input->getOption('include') ?? [];
107
        $excludeEntities = \is_array($rawExcludeValues) ? $rawExcludeValues : [$rawExcludeValues];
108
        $includeEntities = \is_array($rawIncludeValues) ? $rawIncludeValues : [$rawIncludeValues];
109
        $repository = $schemaManager->collectAuditableEntities();
110
        $filteredRepository = [];
111
112
        foreach ($repository as $name => $entityClasses) {
113
            foreach ($entityClasses as $entityClass => $table) {
114
                if (
115
                    !\in_array($entityClass, $excludeEntities, true)
116
                    && (
117
                        empty($includeEntities)
118
                        || \in_array($entityClass, $includeEntities, true)
119
                    )
120
                ) {
121
                    $filteredRepository[$name][$entityClass] = $table;
122
                }
123
            }
124
        }
125
126
        $repository = $filteredRepository;
127
128
        foreach ($repository as $name => $entities) {
129
            $count += \count($entities);
130
        }
131
132
        $message = sprintf(
133
            "You are about to clean audits created before <comment>%s</comment>: %d classes involved.\n Do you want to proceed?",
134
            $until->format(self::UNTIL_DATE_FORMAT),
135
            $count
136
        );
137
138
        $confirm = $input->getOption('no-confirm') ? true : $io->confirm($message, false);
139
        $dryRun = (bool) $input->getOption('dry-run');
140
        $dumpSQL = (bool) $input->getOption('dump-sql');
141
142
        if ($confirm) {
143
            /** @var Configuration $configuration */
144
            $configuration = $provider->getConfiguration();
145
146
            $progressBar = new ProgressBar($output, $count);
147
            $progressBar->setBarWidth(70);
148
            $progressBar->setFormat("%message%\n".$progressBar->getFormatDefinition('debug'));
149
150
            $progressBar->setMessage('Starting...');
151
            $progressBar->start();
152
153
            $queries = [];
154
155
            /**
156
             * @var string                $name
157
             * @var array<string, string> $classes
158
             */
159
            foreach ($repository as $name => $classes) {
160
                foreach ($classes as $entity => $tablename) {
161
                    $connection = $storageServices[$name]->getEntityManager()->getConnection();
162
                    $auditTable = $schemaManager->resolveAuditTableName($entity, $configuration, $connection->getDatabasePlatform());
163
164
                    $queryBuilder = $connection->createQueryBuilder();
165
                    $queryBuilder
166
                        ->delete($auditTable)
167
                        ->where('created_at < :until')
168
                        ->setParameter('until', $until->format(self::UNTIL_DATE_FORMAT))
169
                    ;
170
171
                    if ($dumpSQL) {
172
                        $queries[] = str_replace(':until', "'".$until->format(self::UNTIL_DATE_FORMAT)."'", $queryBuilder->getSQL());
173
                    }
174
175
                    if (!$dryRun) {
176
                        DoctrineHelper::executeStatement($queryBuilder);
177
                    }
178
179
                    $progressBar->setMessage(sprintf('Cleaning audit tables... (<info>%s</info>)', $auditTable));
180
                    $progressBar->advance();
181
                }
182
            }
183
184
            $progressBar->setMessage('Cleaning audit tables... (<info>done</info>)');
185
            $progressBar->display();
186
187
            $io->newLine();
188
            if ($dumpSQL) {
189
                $io->newLine();
190
                $io->writeln('SQL queries to be run:');
191
                foreach ($queries as $query) {
192
                    $io->writeln($query);
193
                }
194
            }
195
196
            $io->newLine();
197
            $io->success('Success.');
198
        } else {
199
            $io->success('Cancelled.');
200
        }
201
202
        // if not released explicitly, Symfony releases the lock
203
        // automatically when the execution of the command ends
204
        $this->release();
205
206
        return Command::SUCCESS;
207
    }
208
209
    private function validateKeepArgument(string $keep, SymfonyStyle $io): ?DateTimeImmutable
210
    {
211
        try {
212
            $dateInterval = new DateInterval($keep);
213
        } catch (Exception) {
214
            $io->error(sprintf("'keep' argument must be a valid ISO 8601 date interval, '%s' given.", $keep));
215
            $this->release();
216
217
            return null;
218
        }
219
220
        return (new DateTimeImmutable())->sub($dateInterval);
221
    }
222
}
223