Skip to content

Commit 6b48ea2

Browse files
committed
fix: Command to remove duplicated entries after faulty IBX-5388 fix
1 parent 98b7b50 commit 6b48ea2

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

eZ/Bundle/EzPublishCoreBundle/Resources/config/commands.yml

+8
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,11 @@ services:
6262
$userHandler: '@ezpublish.spi.persistence.user_handler'
6363
tags:
6464
- { name: console.command }
65+
66+
Ibexa\Bundle\Core\Command\VirtualFieldDuplicateFixCommand:
67+
autowire: true
68+
autoconfigure: true
69+
arguments:
70+
$connection: '@ezpublish.persistence.connection'
71+
tags:
72+
- { name: console.command }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\Bundle\Core\Command;
10+
11+
use Doctrine\DBAL\Connection;
12+
use Exception;
13+
use Symfony\Component\Console\Command\Command;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Input\InputOption;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
use Symfony\Component\Console\Question\ConfirmationQuestion;
18+
use Symfony\Component\Console\Style\SymfonyStyle;
19+
use Symfony\Component\Stopwatch\Stopwatch;
20+
21+
final class VirtualFieldDuplicateFixCommand extends Command
22+
{
23+
private const DEFAULT_BATCH_SIZE = 10000;
24+
25+
private const MAX_ITERATIONS_UNLIMITED = -1;
26+
27+
private const DEFAULT_SLEEP = 0;
28+
29+
private Connection $connection;
30+
31+
public function __construct(
32+
Connection $connection
33+
) {
34+
parent::__construct('ibexa:content:remove-duplicate-fields');
35+
$this->setDescription('Removes duplicate fields created as a result of faulty IBX-5388 performance fix.');
36+
37+
$this->connection = $connection;
38+
}
39+
40+
public function configure(): void
41+
{
42+
$this->addOption(
43+
'batch-size',
44+
'b',
45+
InputOption::VALUE_OPTIONAL,
46+
'Number of attributes affected per iteration',
47+
self::DEFAULT_BATCH_SIZE
48+
);
49+
50+
$this->addOption(
51+
'max-iterations',
52+
'i',
53+
InputOption::VALUE_OPTIONAL,
54+
'Max iterations count (default or -1: unlimited)',
55+
self::MAX_ITERATIONS_UNLIMITED
56+
);
57+
58+
$this->addOption(
59+
'sleep',
60+
's',
61+
InputOption::VALUE_OPTIONAL,
62+
'Wait between iterations, in milliseconds',
63+
self::DEFAULT_SLEEP
64+
);
65+
66+
$this->addOption(
67+
'force',
68+
'f',
69+
InputOption::VALUE_NONE,
70+
'Force operation (implies non-interactive mode)',
71+
);
72+
}
73+
74+
protected function execute(InputInterface $input, OutputInterface $output): int
75+
{
76+
$style = new SymfonyStyle($input, $output);
77+
$stopwatch = new Stopwatch(true);
78+
$stopwatch->start('total', 'command');
79+
80+
$force = $input->getOption('force');
81+
if ($force) {
82+
$input->setInteractive(false);
83+
}
84+
85+
$batchSize = (int)$input->getOption('batch-size');
86+
if ($batchSize === 0) {
87+
$style->warning('Batch size is set to 0. Nothing to do.');
88+
89+
return Command::INVALID;
90+
}
91+
92+
$maxIterations = (int)$input->getOption('max-iterations');
93+
if ($maxIterations === 0) {
94+
$style->warning('Max iterations is set to 0. Nothing to do.');
95+
96+
return Command::INVALID;
97+
}
98+
99+
$sleep = (int)$input->getOption('sleep');
100+
101+
try {
102+
$totalCount = $this->getDuplicatedAttributeTotalCount($style, $stopwatch);
103+
104+
if ($totalCount > 0) {
105+
$confirmation = $this->askForConfirmation($style);
106+
if (!$confirmation && !$force) {
107+
$style->info('Confirmation rejected. Terminating.');
108+
109+
return Command::FAILURE;
110+
}
111+
} else {
112+
$style->success('Database is clean of attribute duplicates. Nothing to do.');
113+
114+
return Command::SUCCESS;
115+
}
116+
117+
$iteration = 1;
118+
$totalDeleted = 0;
119+
do {
120+
$deleted = 0;
121+
$stopwatch->start('iteration', 'sql');
122+
123+
$attributes = $this->getDuplicatedAttributesBatch($batchSize);
124+
foreach ($attributes as $attribute) {
125+
$attributeIds = $this->getDuplicatedAttributeIds($attribute);
126+
127+
$deleted += $this->deleteAttributes($attributeIds);
128+
$totalDeleted += $deleted;
129+
}
130+
131+
$style->info(
132+
sprintf(
133+
'Iteration %d: Removed %d duplicates (total removed this execution: %d). [Debug %s]',
134+
$iteration,
135+
$deleted,
136+
$totalDeleted,
137+
$stopwatch->stop('iteration')
138+
)
139+
);
140+
141+
if ($maxIterations !== self::MAX_ITERATIONS_UNLIMITED && ++$iteration > $maxIterations) {
142+
$style->warning('Max iterations count reached. Terminating.');
143+
144+
return self::FAILURE;
145+
}
146+
147+
// Wait, if needed, before moving to next iteration
148+
usleep($sleep * 1000);
149+
} while ($batchSize === count($attributes));
150+
151+
$style->success(sprintf(
152+
'Operation successful. Removed total of %d duplicates. [Debug %s]',
153+
$totalDeleted,
154+
$stopwatch->stop('total')
155+
));
156+
} catch (Exception $exception) {
157+
$style->error($exception->getMessage());
158+
159+
return Command::FAILURE;
160+
}
161+
162+
return Command::SUCCESS;
163+
}
164+
165+
private function getDuplicatedAttributeTotalCount(
166+
SymfonyStyle $style,
167+
Stopwatch $stopwatch
168+
): int {
169+
$stopwatch->start('total_count', 'sql');
170+
$query = $this->connection->createQueryBuilder()
171+
->select('COUNT(a.id) as instances')
172+
->groupBy('version', 'contentclassattribute_id', 'contentobject_id', 'language_id')
173+
->from('ezcontentobject_attribute', 'a')
174+
->having('instances > 1');
175+
176+
$count = $query->execute()->rowCount();
177+
178+
if ($count > 0) {
179+
$style->warning(
180+
sprintf(
181+
'Found %d of affected attributes. [Debug: %s]',
182+
$count,
183+
$stopwatch->stop('total_count')
184+
)
185+
);
186+
}
187+
188+
return $count;
189+
}
190+
191+
/**
192+
* @phpstan-return array<array{
193+
* version: int,
194+
* contentclassattribute_id: int,
195+
* contentobject_id: int,
196+
* language_id: int,
197+
* }>
198+
*/
199+
private function getDuplicatedAttributesBatch(int $batchSize): array
200+
{
201+
$query = $this->connection->createQueryBuilder();
202+
203+
$query
204+
->select('version', 'contentclassattribute_id', 'contentobject_id', 'language_id')
205+
->groupBy('version', 'contentclassattribute_id', 'contentobject_id', 'language_id')
206+
->from('ezcontentobject_attribute')
207+
->having('COUNT(id) > 1')
208+
->setFirstResult(0)
209+
->setMaxResults($batchSize);
210+
211+
return $query->execute()->fetchAllAssociative();
212+
}
213+
214+
/**
215+
* @phpstan-param array{
216+
* version: int,
217+
* contentclassattribute_id: int,
218+
* contentobject_id: int,
219+
* language_id: int
220+
* } $attribute
221+
*
222+
* @return int[]
223+
*/
224+
private function getDuplicatedAttributeIds(array $attribute): array
225+
{
226+
$query = $this->connection->createQueryBuilder();
227+
228+
$query
229+
->select('id')
230+
->from('ezcontentobject_attribute')
231+
->where('version = :version')
232+
->andWhere('contentclassattribute_id = :contentclassattribute_id')
233+
->andWhere('contentobject_id = :contentobject_id')
234+
->andWhere('language_id = :language_id')
235+
->orderBy('id', 'ASC')
236+
->setFirstResult(0);
237+
238+
$query->setParameters($attribute);
239+
240+
$result = $query->execute()->fetchFirstColumn();
241+
$attributeIds = array_map('intval', $result);
242+
243+
// Keep the original attribute row, the very first one
244+
array_shift($attributeIds);
245+
246+
return $attributeIds;
247+
}
248+
249+
private function askForConfirmation(SymfonyStyle $style): bool
250+
{
251+
$style->warning('Operation is irreversible.');
252+
253+
return $style->askQuestion(
254+
new ConfirmationQuestion(
255+
'Proceed with deletion?',
256+
false
257+
)
258+
);
259+
}
260+
261+
private function deleteAttributes($ids): int
262+
{
263+
$query = $this->connection->createQueryBuilder();
264+
265+
$query
266+
->delete('ezcontentobject_attribute')
267+
->where($query->expr()->in('id', $ids));
268+
269+
return (int)$query->execute();
270+
}
271+
}

0 commit comments

Comments
 (0)