Skip to content

Commit d1a35bb

Browse files
committed
fix(core): ensure unique vcategory
Signed-off-by: skjnldsv <[email protected]>
1 parent 7fbe333 commit d1a35bb

File tree

7 files changed

+116
-2
lines changed

7 files changed

+116
-2
lines changed

core/Listener/AddMissingIndicesListener.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,5 +210,11 @@ public function handle(Event $event): void {
210210
'systag_objecttype',
211211
['objecttype']
212212
);
213+
214+
$event->addMissingUniqueIndex(
215+
'vcategory',
216+
'unique_category_per_user',
217+
['uid', 'type', 'category']
218+
);
213219
}
214220
}

core/Migrations/Version13000Date20170718121200.php

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

663664
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
@@ -1510,6 +1510,7 @@
15101510
'OC\\Core\\Migrations\\Version31000Date20240814184402' => $baseDir . '/core/Migrations/Version31000Date20240814184402.php',
15111511
'OC\\Core\\Migrations\\Version31000Date20250213102442' => $baseDir . '/core/Migrations/Version31000Date20250213102442.php',
15121512
'OC\\Core\\Migrations\\Version32000Date20250620081925' => $baseDir . '/core/Migrations/Version32000Date20250620081925.php',
1513+
'OC\\Core\\Migrations\\Version32000Date20250731062008' => $baseDir . '/core/Migrations/Version32000Date20250731062008.php',
15131514
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
15141515
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
15151516
'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',

lib/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,6 +1551,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
15511551
'OC\\Core\\Migrations\\Version31000Date20240814184402' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20240814184402.php',
15521552
'OC\\Core\\Migrations\\Version31000Date20250213102442' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20250213102442.php',
15531553
'OC\\Core\\Migrations\\Version32000Date20250620081925' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250620081925.php',
1554+
'OC\\Core\\Migrations\\Version32000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250731062008.php',
15541555
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
15551556
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
15561557
'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.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
}

version.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level
1010
// when updating major/minor version number.
1111

12-
$OC_Version = [32, 0, 0, 1];
12+
$OC_Version = [32, 0, 0, 2];
1313

1414
// The human-readable string
1515
$OC_VersionString = '32.0.0 dev';

0 commit comments

Comments
 (0)