From 6b48ea21fdf19b8ec884fab4e91323f524eae530 Mon Sep 17 00:00:00 2001 From: Nattfarinn Date: Wed, 14 Aug 2024 13:55:29 +0200 Subject: [PATCH] fix: Command to remove duplicated entries after faulty IBX-5388 fix --- .../Resources/config/commands.yml | 8 + .../VirtualFieldDuplicateFixCommand.php | 271 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/bundle/Core/Command/VirtualFieldDuplicateFixCommand.php diff --git a/eZ/Bundle/EzPublishCoreBundle/Resources/config/commands.yml b/eZ/Bundle/EzPublishCoreBundle/Resources/config/commands.yml index 165639f868..29a273a993 100644 --- a/eZ/Bundle/EzPublishCoreBundle/Resources/config/commands.yml +++ b/eZ/Bundle/EzPublishCoreBundle/Resources/config/commands.yml @@ -62,3 +62,11 @@ services: $userHandler: '@ezpublish.spi.persistence.user_handler' tags: - { name: console.command } + + Ibexa\Bundle\Core\Command\VirtualFieldDuplicateFixCommand: + autowire: true + autoconfigure: true + arguments: + $connection: '@ezpublish.persistence.connection' + tags: + - { name: console.command } diff --git a/src/bundle/Core/Command/VirtualFieldDuplicateFixCommand.php b/src/bundle/Core/Command/VirtualFieldDuplicateFixCommand.php new file mode 100644 index 0000000000..a10d803e99 --- /dev/null +++ b/src/bundle/Core/Command/VirtualFieldDuplicateFixCommand.php @@ -0,0 +1,271 @@ +setDescription('Removes duplicate fields created as a result of faulty IBX-5388 performance fix.'); + + $this->connection = $connection; + } + + public function configure(): void + { + $this->addOption( + 'batch-size', + 'b', + InputOption::VALUE_OPTIONAL, + 'Number of attributes affected per iteration', + self::DEFAULT_BATCH_SIZE + ); + + $this->addOption( + 'max-iterations', + 'i', + InputOption::VALUE_OPTIONAL, + 'Max iterations count (default or -1: unlimited)', + self::MAX_ITERATIONS_UNLIMITED + ); + + $this->addOption( + 'sleep', + 's', + InputOption::VALUE_OPTIONAL, + 'Wait between iterations, in milliseconds', + self::DEFAULT_SLEEP + ); + + $this->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Force operation (implies non-interactive mode)', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $style = new SymfonyStyle($input, $output); + $stopwatch = new Stopwatch(true); + $stopwatch->start('total', 'command'); + + $force = $input->getOption('force'); + if ($force) { + $input->setInteractive(false); + } + + $batchSize = (int)$input->getOption('batch-size'); + if ($batchSize === 0) { + $style->warning('Batch size is set to 0. Nothing to do.'); + + return Command::INVALID; + } + + $maxIterations = (int)$input->getOption('max-iterations'); + if ($maxIterations === 0) { + $style->warning('Max iterations is set to 0. Nothing to do.'); + + return Command::INVALID; + } + + $sleep = (int)$input->getOption('sleep'); + + try { + $totalCount = $this->getDuplicatedAttributeTotalCount($style, $stopwatch); + + if ($totalCount > 0) { + $confirmation = $this->askForConfirmation($style); + if (!$confirmation && !$force) { + $style->info('Confirmation rejected. Terminating.'); + + return Command::FAILURE; + } + } else { + $style->success('Database is clean of attribute duplicates. Nothing to do.'); + + return Command::SUCCESS; + } + + $iteration = 1; + $totalDeleted = 0; + do { + $deleted = 0; + $stopwatch->start('iteration', 'sql'); + + $attributes = $this->getDuplicatedAttributesBatch($batchSize); + foreach ($attributes as $attribute) { + $attributeIds = $this->getDuplicatedAttributeIds($attribute); + + $deleted += $this->deleteAttributes($attributeIds); + $totalDeleted += $deleted; + } + + $style->info( + sprintf( + 'Iteration %d: Removed %d duplicates (total removed this execution: %d). [Debug %s]', + $iteration, + $deleted, + $totalDeleted, + $stopwatch->stop('iteration') + ) + ); + + if ($maxIterations !== self::MAX_ITERATIONS_UNLIMITED && ++$iteration > $maxIterations) { + $style->warning('Max iterations count reached. Terminating.'); + + return self::FAILURE; + } + + // Wait, if needed, before moving to next iteration + usleep($sleep * 1000); + } while ($batchSize === count($attributes)); + + $style->success(sprintf( + 'Operation successful. Removed total of %d duplicates. [Debug %s]', + $totalDeleted, + $stopwatch->stop('total') + )); + } catch (Exception $exception) { + $style->error($exception->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + private function getDuplicatedAttributeTotalCount( + SymfonyStyle $style, + Stopwatch $stopwatch + ): int { + $stopwatch->start('total_count', 'sql'); + $query = $this->connection->createQueryBuilder() + ->select('COUNT(a.id) as instances') + ->groupBy('version', 'contentclassattribute_id', 'contentobject_id', 'language_id') + ->from('ezcontentobject_attribute', 'a') + ->having('instances > 1'); + + $count = $query->execute()->rowCount(); + + if ($count > 0) { + $style->warning( + sprintf( + 'Found %d of affected attributes. [Debug: %s]', + $count, + $stopwatch->stop('total_count') + ) + ); + } + + return $count; + } + + /** + * @phpstan-return array + */ + private function getDuplicatedAttributesBatch(int $batchSize): array + { + $query = $this->connection->createQueryBuilder(); + + $query + ->select('version', 'contentclassattribute_id', 'contentobject_id', 'language_id') + ->groupBy('version', 'contentclassattribute_id', 'contentobject_id', 'language_id') + ->from('ezcontentobject_attribute') + ->having('COUNT(id) > 1') + ->setFirstResult(0) + ->setMaxResults($batchSize); + + return $query->execute()->fetchAllAssociative(); + } + + /** + * @phpstan-param array{ + * version: int, + * contentclassattribute_id: int, + * contentobject_id: int, + * language_id: int + * } $attribute + * + * @return int[] + */ + private function getDuplicatedAttributeIds(array $attribute): array + { + $query = $this->connection->createQueryBuilder(); + + $query + ->select('id') + ->from('ezcontentobject_attribute') + ->where('version = :version') + ->andWhere('contentclassattribute_id = :contentclassattribute_id') + ->andWhere('contentobject_id = :contentobject_id') + ->andWhere('language_id = :language_id') + ->orderBy('id', 'ASC') + ->setFirstResult(0); + + $query->setParameters($attribute); + + $result = $query->execute()->fetchFirstColumn(); + $attributeIds = array_map('intval', $result); + + // Keep the original attribute row, the very first one + array_shift($attributeIds); + + return $attributeIds; + } + + private function askForConfirmation(SymfonyStyle $style): bool + { + $style->warning('Operation is irreversible.'); + + return $style->askQuestion( + new ConfirmationQuestion( + 'Proceed with deletion?', + false + ) + ); + } + + private function deleteAttributes($ids): int + { + $query = $this->connection->createQueryBuilder(); + + $query + ->delete('ezcontentobject_attribute') + ->where($query->expr()->in('id', $ids)); + + return (int)$query->execute(); + } +}