Skip to content

Commit cefe724

Browse files
committed
fix(core): ensure unique vcategory
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent 2672301 commit cefe724

File tree

7 files changed

+116
-2
lines changed

7 files changed

+116
-2
lines changed

core/Application.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ public function __construct() {
231231
'systag_by_objectid',
232232
['objectid']
233233
);
234+
235+
$event->addMissingUniqueIndex(
236+
'vcategory',
237+
'unique_category_per_user',
238+
['uid', 'type', 'category']
239+
);
234240
});
235241

236242
$eventDispatcher->addListener(AddMissingPrimaryKeyEvent::class, function (AddMissingPrimaryKeyEvent $event) {

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 Version30000Date20250731062008 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
@@ -1382,6 +1382,7 @@
13821382
'OC\\Core\\Migrations\\Version30000Date20240814180800' => $baseDir . '/core/Migrations/Version30000Date20240814180800.php',
13831383
'OC\\Core\\Migrations\\Version30000Date20240815080800' => $baseDir . '/core/Migrations/Version30000Date20240815080800.php',
13841384
'OC\\Core\\Migrations\\Version30000Date20240906095113' => $baseDir . '/core/Migrations/Version30000Date20240906095113.php',
1385+
'OC\\Core\\Migrations\\Version30000Date20250731062008' => $baseDir . '/core/Migrations/Version30000Date20250731062008.php',
13851386
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
13861387
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
13871388
'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
@@ -1415,6 +1415,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
14151415
'OC\\Core\\Migrations\\Version30000Date20240814180800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240814180800.php',
14161416
'OC\\Core\\Migrations\\Version30000Date20240815080800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240815080800.php',
14171417
'OC\\Core\\Migrations\\Version30000Date20240906095113' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240906095113.php',
1418+
'OC\\Core\\Migrations\\Version30000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20250731062008.php',
14181419
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
14191420
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
14201421
'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
@@ -274,7 +274,6 @@ public function add(string $name) {
274274
return false;
275275
}
276276
if ($this->userHasTag($name, $this->user)) {
277-
// TODO use unique db properties instead of an additional check
278277
$this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
279278
return false;
280279
}

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 = [30, 0, 13, 1];
12+
$OC_Version = [30, 0, 13, 2];
1313

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

0 commit comments

Comments
 (0)