fix(core): ensure unique vcategory

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
pull/54165/head
skjnldsv 2025-07-31 08:53:35 +07:00 committed by John Molakvoæ
parent 51e5f7b159
commit 9aac182109
7 changed files with 116 additions and 2 deletions

@ -210,5 +210,11 @@ class AddMissingIndicesListener implements IEventListener {
'systag_objecttype', 'systag_objecttype',
['objecttype'] ['objecttype']
); );
$event->addMissingUniqueIndex(
'vcategory',
'unique_category_per_user',
['uid', 'type', 'category']
);
} }
} }

@ -658,6 +658,7 @@ class Version13000Date20170718121200 extends SimpleMigrationStep {
$table->addIndex(['uid'], 'uid_index'); $table->addIndex(['uid'], 'uid_index');
$table->addIndex(['type'], 'type_index'); $table->addIndex(['type'], 'type_index');
$table->addIndex(['category'], 'category_index'); $table->addIndex(['category'], 'category_index');
$table->addUniqueIndex(['uid', 'type', 'category'], 'unique_category_per_user');
} }
if (!$schema->hasTable('vcategory_to_object')) { if (!$schema->hasTable('vcategory_to_object')) {

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Migrations;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;
/**
* Make sure vcategory entries are unique per user and type
* This migration will clean up existing duplicates
* and add a unique constraint to prevent future duplicates.
*/
class Version32000Date20250731062008 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $connection,
) {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
#[Override]
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Clean up duplicate categories before adding unique constraint
$this->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");
}
}
}

@ -1510,6 +1510,7 @@ return array(
'OC\\Core\\Migrations\\Version31000Date20240814184402' => $baseDir . '/core/Migrations/Version31000Date20240814184402.php', 'OC\\Core\\Migrations\\Version31000Date20240814184402' => $baseDir . '/core/Migrations/Version31000Date20240814184402.php',
'OC\\Core\\Migrations\\Version31000Date20250213102442' => $baseDir . '/core/Migrations/Version31000Date20250213102442.php', 'OC\\Core\\Migrations\\Version31000Date20250213102442' => $baseDir . '/core/Migrations/Version31000Date20250213102442.php',
'OC\\Core\\Migrations\\Version32000Date20250620081925' => $baseDir . '/core/Migrations/Version32000Date20250620081925.php', 'OC\\Core\\Migrations\\Version32000Date20250620081925' => $baseDir . '/core/Migrations/Version32000Date20250620081925.php',
'OC\\Core\\Migrations\\Version32000Date20250731062008' => $baseDir . '/core/Migrations/Version32000Date20250731062008.php',
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php', 'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',

@ -1551,6 +1551,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Migrations\\Version31000Date20240814184402' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20240814184402.php', 'OC\\Core\\Migrations\\Version31000Date20240814184402' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20240814184402.php',
'OC\\Core\\Migrations\\Version31000Date20250213102442' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20250213102442.php', 'OC\\Core\\Migrations\\Version31000Date20250213102442' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20250213102442.php',
'OC\\Core\\Migrations\\Version32000Date20250620081925' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250620081925.php', 'OC\\Core\\Migrations\\Version32000Date20250620081925' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250620081925.php',
'OC\\Core\\Migrations\\Version32000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250731062008.php',
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php', 'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php',

@ -273,7 +273,6 @@ class Tags implements ITags {
return false; return false;
} }
if ($this->userHasTag($name, $this->user)) { 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']); $this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
return false; return false;
} }

@ -9,7 +9,7 @@
// between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level // between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level
// when updating major/minor version number. // when updating major/minor version number.
$OC_Version = [32, 0, 0, 1]; $OC_Version = [32, 0, 0, 2];
// The human-readable string // The human-readable string
$OC_VersionString = '32.0.0 dev'; $OC_VersionString = '32.0.0 dev';