Passed
Push — master ( 1377e4...f206a8 )
by Damien
03:20 queued 11s
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 unlock(): void
40
    {
41
        $this->release();
42
    }
43
44
    public function setAuditor(Auditor $auditor): self
45
    {
46
        $this->auditor = $auditor;
47
48
        return $this;
49
    }
50
51
    protected function configure(): void
52
    {
53
        $this
54
            ->setDescription('Cleans audit tables')
55
            ->setName('audit:clean')
56
            ->addOption('no-confirm', null, InputOption::VALUE_NONE, 'No interaction mode')
57
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not execute SQL queries.')
58
            ->addOption('dump-sql', null, InputOption::VALUE_NONE, 'Prints SQL related queries.')
59
            ->addOption('exclude', 'x', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Entities to exclude from cleaning')
60
            ->addOption('include', 'i', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Entities to include in cleaning')
61
            ->addOption('date', 'd', InputOption::VALUE_OPTIONAL, 'Specify a custom date to clean audits until (must be expressed as an ISO 8601 date, e.g. 2023-04-24).')
62
            ->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')
63
        ;
64
    }
65
66
    protected function execute(InputInterface $input, OutputInterface $output): int
67
    {
68
        if (!$this->lock()) {
69
            $output->writeln('The command is already running in another process.');
70
71
            return 0;
72
        }
73
74
        $io = new SymfonyStyle($input, $output);
75
76
        $keep  = $input->getArgument('keep');
77
        $keep  = (\is_array($keep) ? $keep[0] : $keep);
78
        $date  = $input->getOption('date');
79
        $until = null;
80
81
        if ($date) {
82
            // Use custom date if provided
83
            try {
84
                $until = new DateTimeImmutable($date);
85
            } catch (Exception $e) {
86
                $io->error(sprintf('Invalid date format provided: %s', $date));
87
            }
88
        } else {
89
            // Fall back to default retention period
90
            $until = $this->validateKeepArgument($keep, $io);
91
        }
92
93
        if (null === $until) {
94
            return 0;
95
        }
96
97
        /** @var DoctrineProvider $provider */
98
        $provider      = $this->auditor->getProvider(DoctrineProvider::class);
99
        $schemaManager = new SchemaManager($provider);
100
101
        /** @var StorageService[] $storageServices */
102
        $storageServices = $provider->getStorageServices();
103
104
        // auditable classes by storage entity manager
105
        $count = 0;
106
107
        // Collect auditable classes from auditing storage managers
108
        $excludeEntities    = array_values($input->getOption('exclude') ?? []);
109
        $includeEntities    = array_values($input->getOption('include') ?? []);
110
        $repository         = $schemaManager->collectAuditableEntities();
111
        $filteredRepository = [];
112
113
        foreach ($repository as $name => $entityClasses) {
114
            foreach ($entityClasses as $entityClass => $table) {
115
                if (
116
                    !in_array($entityClass, $excludeEntities) &&
117
                    (
118
                        empty($includeEntities) ||
119
                        in_array($entityClass, $includeEntities)
120
                    )
121
                ) {
122
                    $filteredRepository[$name][$entityClass] = $table;
123
                }
124
            }
125
        }
126
127
        $repository = $filteredRepository;
128
129
        foreach ($repository as $name => $entities) {
130
            $count += \count($entities);
131
        }
132
133
        $message = sprintf(
134
            "You are about to clean audits created before <comment>%s</comment>: %d classes involved.\n Do you want to proceed?",
135
            $until->format(self::UNTIL_DATE_FORMAT),
136
            $count
137
        );
138
139
        $confirm = $input->getOption('no-confirm') ? true : $io->confirm($message, false);
140
        $dryRun = (bool) $input->getOption('dry-run');
141
        $dumpSQL = (bool) $input->getOption('dump-sql');
142
143
        if ($confirm) {
144
            /** @var Configuration $configuration */
145
            $configuration = $provider->getConfiguration();
146
147
            $progressBar = new ProgressBar($output, $count);
148
            $progressBar->setBarWidth(70);
149
            $progressBar->setFormat("%message%\n".$progressBar->getFormatDefinition('debug'));
150
151
            $progressBar->setMessage('Starting...');
152
            $progressBar->start();
153
154
            $queries = [];
155
            foreach ($repository as $name => $classes) {
156
                foreach ($classes as $entity => $tablename) {
157
                    $connection = $storageServices[$name]->getEntityManager()->getConnection();
158
                    $auditTable = $schemaManager->resolveAuditTableName($entity, $configuration, $connection->getDatabasePlatform());
159
160
                    $queryBuilder = $connection->createQueryBuilder();
161
                    $queryBuilder
162
                        ->delete($auditTable)
163
                        ->where('created_at < :until')
164
                        ->setParameter('until', $until->format(self::UNTIL_DATE_FORMAT))
165
                    ;
166
167
                    if ($dumpSQL) {
168
                        $queries[] = str_replace(':until', "'".$until->format(self::UNTIL_DATE_FORMAT)."'", $queryBuilder->getSQL());
169
                    }
170
171
                    if (!$dryRun) {
172
                        DoctrineHelper::executeStatement($queryBuilder);
173
                    }
174
175
                    $progressBar->setMessage(sprintf('Cleaning audit tables... (<info>%s</info>)', $auditTable));
176
                    $progressBar->advance();
177
                }
178
            }
179
180
            $progressBar->setMessage('Cleaning audit tables... (<info>done</info>)');
181
            $progressBar->display();
182
183
            $io->newLine();
184
            if ($dumpSQL) {
185
                $io->newLine();
186
                $io->writeln('SQL queries to be run:');
187
                foreach ($queries as $query) {
188
                    $io->writeln($query);
189
                }
190
            }
191
192
            $io->newLine();
193
            $io->success('Success.');
194
        } else {
195
            $io->success('Cancelled.');
196
        }
197
198
        // if not released explicitly, Symfony releases the lock
199
        // automatically when the execution of the command ends
200
        $this->release();
201
202
        return 0;
203
    }
204
205
    private function validateKeepArgument(string $keep, SymfonyStyle $io): ?DateTimeImmutable
206
    {
207
        try {
208
            $dateInterval = new DateInterval($keep);
209
        } catch (Exception) {
210
            $io->error(sprintf("'keep' argument must be a valid ISO 8601 date interval, '%s' given.", $keep));
211
            $this->release();
212
213
            return null;
214
        }
215
216
        return (new DateTimeImmutable())->sub($dateInterval);
217
    }
218
}
219