Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
20 / 22
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PruneMediaCommand
90.91% covered (success)
90.91%
20 / 22
75.00% covered (warning)
75.00%
3 / 4
8.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 referencedNames
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Shared\Infrastructure\Console;
6
7use App\Shared\Infrastructure\Storage\ImageStorage;
8use Doctrine\DBAL\Connection;
9use Symfony\Component\Console\Attribute\AsCommand;
10use Symfony\Component\Console\Command\Command;
11use Symfony\Component\Console\Input\InputInterface;
12use Symfony\Component\Console\Input\InputOption;
13use Symfony\Component\Console\Output\OutputInterface;
14use Symfony\Component\Console\Style\SymfonyStyle;
15
16/**
17 * Safety net for the eager unlink. Every upload path deletes the file it replaces
18 * and every deletion path takes its file along, so a file no row references means
19 * one of those unlinks failed. Reports by default; deletes only with --force,
20 * because a bug in the query below would otherwise erase live images.
21 *
22 * ponytail: raw SQL over the two columns that reference a file, instead of a port
23 * per context — a maintenance command has no domain to model, and its functional
24 * test runs against the real schema, so a renamed column fails loudly. Add a port
25 * when a third context stores images.
26 */
27#[AsCommand(
28    name: 'app:media:prune',
29    description: 'List uploaded images no row references any more (--force deletes them).',
30)]
31final class PruneMediaCommand extends Command
32{
33    /** Table => the column holding the name of a stored file. */
34    private const REFERENCES = [
35        'trips' => 'image_name',
36        'users' => 'avatar_name',
37    ];
38
39    public function __construct(
40        private readonly ImageStorage $storage,
41        private readonly Connection $connection,
42    ) {
43        parent::__construct();
44    }
45
46    protected function configure(): void
47    {
48        $this->addOption('force', null, InputOption::VALUE_NONE, 'Delete the orphans instead of only listing them');
49    }
50
51    protected function execute(InputInterface $input, OutputInterface $output): int
52    {
53        $io = new SymfonyStyle($input, $output);
54
55        $orphans = array_values(array_diff($this->storage->storedNames(), $this->referencedNames()));
56        if ($orphans === []) {
57            $io->success('No orphan images: every stored file is still referenced.');
58
59            return Command::SUCCESS;
60        }
61
62        $io->listing($orphans);
63
64        if ($input->getOption('force') !== true) {
65            $io->warning(sprintf('%d orphan image(s). Re-run with --force to delete them.', count($orphans)));
66
67            return Command::SUCCESS;
68        }
69
70        foreach ($orphans as $name) {
71            $this->storage->delete($name);
72        }
73
74        $io->success(sprintf('Deleted %d orphan image(s).', count($orphans)));
75
76        return Command::SUCCESS;
77    }
78
79    /**
80     * @return list<string>
81     */
82    private function referencedNames(): array
83    {
84        $names = [];
85        foreach (self::REFERENCES as $table => $column) {
86            $rows = $this->connection->fetchFirstColumn(
87                sprintf('SELECT %s FROM %s WHERE %s IS NOT NULL', $column, $table, $column),
88            );
89            $names[] = array_values(array_filter($rows, is_string(...)));
90        }
91
92        return array_merge(...$names);
93    }
94}