Skip to content

Commit 380cd06

Browse files
skjnldsvbackportbot[bot]
authored andcommitted
fix(core): ensure unique vcategory
Signed-off-by: skjnldsv <skjnldsv@protonmail.com> [skip ci]
1 parent c3cd5bb commit 380cd06

File tree

5 files changed

+109
-1
lines changed

5 files changed

+109
-1
lines changed

core/Migrations/Version13000Date20170718121200.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ public function changeSchema(IOutput $output, \Closure $schemaClosure, array $op
657657
$table->addIndex(['uid'], 'uid_index');
658658
$table->addIndex(['type'], 'type_index');
659659
$table->addIndex(['category'], 'category_index');
660+
$table->addUniqueIndex(['uid', 'type', 'category'], 'unique_category_per_user');
660661
}
661662

662663
if (!$schema->hasTable('vcategory_to_object')) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Core\Migrations;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\IDBConnection;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
use Override;
18+
19+
/**
20+
* Make sure vcategory entries are unique per user and type
21+
* This migration will clean up existing duplicates
22+
* and add a unique constraint to prevent future duplicates.
23+
*/
24+
class Version32000Date20250731062008 extends SimpleMigrationStep {
25+
public function __construct(
26+
private IDBConnection $connection,
27+
) {
28+
}
29+
30+
/**
31+
* @param IOutput $output
32+
* @param Closure(): ISchemaWrapper $schemaClosure
33+
* @param array $options
34+
*/
35+
#[Override]
36+
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
37+
// Clean up duplicate categories before adding unique constraint
38+
$this->cleanupDuplicateCategories($output);
39+
}
40+
41+
/**
42+
* Clean up duplicate categories
43+
*/
44+
private function cleanupDuplicateCategories(IOutput $output) {
45+
$output->info('Starting cleanup of duplicate vcategory records...');
46+
47+
// Find all categories, ordered to identify duplicates
48+
$qb = $this->connection->getQueryBuilder();
49+
$qb->select('id', 'uid', 'type', 'category')
50+
->from('vcategory')
51+
->orderBy('uid')
52+
->addOrderBy('type')
53+
->addOrderBy('category')
54+
->addOrderBy('id');
55+
56+
$result = $qb->executeQuery();
57+
58+
$seen = [];
59+
$duplicateCount = 0;
60+
61+
while ($category = $result->fetch()) {
62+
$key = $category['uid'] . '|' . $category['type'] . '|' . $category['category'];
63+
$categoryId = (int)$category['id'];
64+
65+
if (!isset($seen[$key])) {
66+
// First occurrence - keep this one
67+
$seen[$key] = $categoryId;
68+
continue;
69+
}
70+
71+
// Duplicate found
72+
$keepId = $seen[$key];
73+
$duplicateCount++;
74+
75+
$output->info("Found duplicate: keeping ID $keepId, removing ID $categoryId");
76+
77+
// Update object references
78+
$updateQb = $this->connection->getQueryBuilder();
79+
$updateQb->update('vcategory_to_object')
80+
->set('categoryid', $updateQb->createNamedParameter($keepId))
81+
->where($updateQb->expr()->eq('categoryid', $updateQb->createNamedParameter($categoryId)));
82+
83+
$affectedRows = $updateQb->executeStatement();
84+
if ($affectedRows > 0) {
85+
$output->info(" - Updated $affectedRows object references from category $categoryId to $keepId");
86+
}
87+
88+
// Remove duplicate category record
89+
$deleteQb = $this->connection->getQueryBuilder();
90+
$deleteQb->delete('vcategory')
91+
->where($deleteQb->expr()->eq('id', $deleteQb->createNamedParameter($categoryId)));
92+
93+
$deleteQb->executeStatement();
94+
$output->info(" - Deleted duplicate category record ID $categoryId");
95+
96+
}
97+
98+
$result->closeCursor();
99+
100+
if ($duplicateCount === 0) {
101+
$output->info('No duplicate categories found');
102+
} else {
103+
$output->info("Duplicate cleanup completed - processed $duplicateCount duplicates");
104+
}
105+
}
106+
}

lib/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,7 @@
13801380
'OC\\Core\\Events\\PasswordResetEvent' => $baseDir . '/core/Events/PasswordResetEvent.php',
13811381
'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => $baseDir . '/core/Exception/LoginFlowV2NotFoundException.php',
13821382
'OC\\Core\\Exception\\ResetPasswordException' => $baseDir . '/core/Exception/ResetPasswordException.php',
1383+
'OC\\Core\\Listener\\AddMissingIndicesListener' => $baseDir . '/core/Listener/AddMissingIndicesListener.php',
13831384
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => $baseDir . '/core/Listener/BeforeMessageLoggedEventListener.php',
13841385
'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => $baseDir . '/core/Listener/BeforeTemplateRenderedListener.php',
13851386
'OC\\Core\\Middleware\\TwoFactorMiddleware' => $baseDir . '/core/Middleware/TwoFactorMiddleware.php',

lib/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
14291429
'OC\\Core\\Events\\PasswordResetEvent' => __DIR__ . '/../../..' . '/core/Events/PasswordResetEvent.php',
14301430
'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => __DIR__ . '/../../..' . '/core/Exception/LoginFlowV2NotFoundException.php',
14311431
'OC\\Core\\Exception\\ResetPasswordException' => __DIR__ . '/../../..' . '/core/Exception/ResetPasswordException.php',
1432+
'OC\\Core\\Listener\\AddMissingIndicesListener' => __DIR__ . '/../../..' . '/core/Listener/AddMissingIndicesListener.php',
14321433
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeMessageLoggedEventListener.php',
14331434
'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeTemplateRenderedListener.php',
14341435
'OC\\Core\\Middleware\\TwoFactorMiddleware' => __DIR__ . '/../../..' . '/core/Middleware/TwoFactorMiddleware.php',

lib/private/Tags.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,6 @@ public function add(string $name) {
273273
return false;
274274
}
275275
if ($this->userHasTag($name, $this->user)) {
276-
// TODO use unique db properties instead of an additional check
277276
$this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
278277
return false;
279278
}

0 commit comments

Comments
 (0)