diff --git a/core/Application.php b/core/Application.php index 02dc1ced3a2e8..d6b36e799fc46 100644 --- a/core/Application.php +++ b/core/Application.php @@ -231,6 +231,12 @@ public function __construct() { 'systag_by_objectid', ['objectid'] ); + + $event->addMissingUniqueIndex( + 'vcategory', + 'unique_category_per_user', + ['uid', 'type', 'category'] + ); }); $eventDispatcher->addListener(AddMissingPrimaryKeyEvent::class, function (AddMissingPrimaryKeyEvent $event) { diff --git a/core/Migrations/Version13000Date20170718121200.php b/core/Migrations/Version13000Date20170718121200.php index a4416924c93b3..524a3877c33ca 100644 --- a/core/Migrations/Version13000Date20170718121200.php +++ b/core/Migrations/Version13000Date20170718121200.php @@ -658,6 +658,7 @@ public function changeSchema(IOutput $output, \Closure $schemaClosure, array $op $table->addIndex(['uid'], 'uid_index'); $table->addIndex(['type'], 'type_index'); $table->addIndex(['category'], 'category_index'); + $table->addUniqueIndex(['uid', 'type', 'category'], 'unique_category_per_user'); } if (!$schema->hasTable('vcategory_to_object')) { diff --git a/core/Migrations/Version32000Date20250731062008.php b/core/Migrations/Version32000Date20250731062008.php new file mode 100644 index 0000000000000..bf15e4a0b2215 --- /dev/null +++ b/core/Migrations/Version32000Date20250731062008.php @@ -0,0 +1,106 @@ +cleanupDuplicateCategories($output); + } + + /** + * Clean up duplicate categories + */ + private function cleanupDuplicateCategories(IOutput $output) { + $output->info('Starting cleanup of duplicate vcategory records...'); + + // Find all categories, ordered to identify duplicates + $qb = $this->connection->getQueryBuilder(); + $qb->select('id', 'uid', 'type', 'category') + ->from('vcategory') + ->orderBy('uid') + ->addOrderBy('type') + ->addOrderBy('category') + ->addOrderBy('id'); + + $result = $qb->executeQuery(); + + $seen = []; + $duplicateCount = 0; + + while ($category = $result->fetch()) { + $key = $category['uid'] . '|' . $category['type'] . '|' . $category['category']; + $categoryId = (int)$category['id']; + + if (!isset($seen[$key])) { + // First occurrence - keep this one + $seen[$key] = $categoryId; + continue; + } + + // Duplicate found + $keepId = $seen[$key]; + $duplicateCount++; + + $output->info("Found duplicate: keeping ID $keepId, removing ID $categoryId"); + + // Update object references + $updateQb = $this->connection->getQueryBuilder(); + $updateQb->update('vcategory_to_object') + ->set('categoryid', $updateQb->createNamedParameter($keepId)) + ->where($updateQb->expr()->eq('categoryid', $updateQb->createNamedParameter($categoryId))); + + $affectedRows = $updateQb->executeStatement(); + if ($affectedRows > 0) { + $output->info(" - Updated $affectedRows object references from category $categoryId to $keepId"); + } + + // Remove duplicate category record + $deleteQb = $this->connection->getQueryBuilder(); + $deleteQb->delete('vcategory') + ->where($deleteQb->expr()->eq('id', $deleteQb->createNamedParameter($categoryId))); + + $deleteQb->executeStatement(); + $output->info(" - Deleted duplicate category record ID $categoryId"); + + } + + $result->closeCursor(); + + if ($duplicateCount === 0) { + $output->info('No duplicate categories found'); + } else { + $output->info("Duplicate cleanup completed - processed $duplicateCount duplicates"); + } + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 63ca843cc988d..889e88c24a105 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1304,6 +1304,7 @@ 'OC\\Core\\Events\\PasswordResetEvent' => $baseDir . '/core/Events/PasswordResetEvent.php', 'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => $baseDir . '/core/Exception/LoginFlowV2NotFoundException.php', 'OC\\Core\\Exception\\ResetPasswordException' => $baseDir . '/core/Exception/ResetPasswordException.php', + 'OC\\Core\\Listener\\AddMissingIndicesListener' => $baseDir . '/core/Listener/AddMissingIndicesListener.php', 'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => $baseDir . '/core/Listener/BeforeMessageLoggedEventListener.php', 'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => $baseDir . '/core/Listener/BeforeTemplateRenderedListener.php', 'OC\\Core\\Middleware\\TwoFactorMiddleware' => $baseDir . '/core/Middleware/TwoFactorMiddleware.php', @@ -1382,6 +1383,7 @@ 'OC\\Core\\Migrations\\Version30000Date20240814180800' => $baseDir . '/core/Migrations/Version30000Date20240814180800.php', 'OC\\Core\\Migrations\\Version30000Date20240815080800' => $baseDir . '/core/Migrations/Version30000Date20240815080800.php', 'OC\\Core\\Migrations\\Version30000Date20240906095113' => $baseDir . '/core/Migrations/Version30000Date20240906095113.php', + 'OC\\Core\\Migrations\\Version32000Date20250731062008' => $baseDir . '/core/Migrations/Version32000Date20250731062008.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 6b787515bf48d..f7a283e94dcab 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1337,6 +1337,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Events\\PasswordResetEvent' => __DIR__ . '/../../..' . '/core/Events/PasswordResetEvent.php', 'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => __DIR__ . '/../../..' . '/core/Exception/LoginFlowV2NotFoundException.php', 'OC\\Core\\Exception\\ResetPasswordException' => __DIR__ . '/../../..' . '/core/Exception/ResetPasswordException.php', + 'OC\\Core\\Listener\\AddMissingIndicesListener' => __DIR__ . '/../../..' . '/core/Listener/AddMissingIndicesListener.php', 'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeMessageLoggedEventListener.php', 'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeTemplateRenderedListener.php', 'OC\\Core\\Middleware\\TwoFactorMiddleware' => __DIR__ . '/../../..' . '/core/Middleware/TwoFactorMiddleware.php', @@ -1415,6 +1416,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version30000Date20240814180800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240814180800.php', 'OC\\Core\\Migrations\\Version30000Date20240815080800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240815080800.php', 'OC\\Core\\Migrations\\Version30000Date20240906095113' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240906095113.php', + 'OC\\Core\\Migrations\\Version32000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250731062008.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', diff --git a/lib/private/Tags.php b/lib/private/Tags.php index b5c42f84f61d8..307c8223ac1ba 100644 --- a/lib/private/Tags.php +++ b/lib/private/Tags.php @@ -274,7 +274,6 @@ public function add(string $name) { return false; } if ($this->userHasTag($name, $this->user)) { - // TODO use unique db properties instead of an additional check $this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']); return false; } diff --git a/version.php b/version.php index 9a0d16b0165bb..5301c2a04f00a 100644 --- a/version.php +++ b/version.php @@ -9,7 +9,7 @@ // between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level // when updating major/minor version number. -$OC_Version = [30, 0, 13, 1]; +$OC_Version = [30, 0, 13, 2]; // The human-readable string $OC_VersionString = '30.0.13';