Skip to content

Commit 49a3794

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

File tree

7 files changed

+149
-7
lines changed

7 files changed

+149
-7
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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
* @param IOutput $output
43+
* @param Closure(): ISchemaWrapper $schemaClosure
44+
* @param array $options
45+
* @return null|ISchemaWrapper
46+
*/
47+
#[Override]
48+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
49+
return null;
50+
}
51+
52+
/**
53+
* @param IOutput $output
54+
* @param Closure(): ISchemaWrapper $schemaClosure
55+
* @param array $options
56+
*/
57+
#[Override]
58+
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
59+
}
60+
61+
/**
62+
* Clean up duplicate categories
63+
*/
64+
private function cleanupDuplicateCategories(IOutput $output) {
65+
$output->info('Starting cleanup of duplicate vcategory records...');
66+
67+
// Find all categories, ordered to identify duplicates
68+
$qb = $this->connection->getQueryBuilder();
69+
$qb->select('id', 'uid', 'type', 'category')
70+
->from('vcategory')
71+
->orderBy('uid')
72+
->addOrderBy('type')
73+
->addOrderBy('category')
74+
->addOrderBy('id');
75+
76+
$result = $qb->executeQuery();
77+
78+
$seen = [];
79+
$duplicateCount = 0;
80+
81+
while ($category = $result->fetch()) {
82+
$key = $category['uid'] . '|' . $category['type'] . '|' . $category['category'];
83+
$categoryId = (int)$category['id'];
84+
85+
if (!isset($seen[$key])) {
86+
// First occurrence - keep this one
87+
$seen[$key] = $categoryId;
88+
continue;
89+
}
90+
91+
// Duplicate found
92+
$keepId = $seen[$key];
93+
$duplicateCount++;
94+
95+
$output->info("Found duplicate: keeping ID $keepId, removing ID $categoryId");
96+
97+
// Update object references
98+
$updateQb = $this->connection->getQueryBuilder();
99+
$updateQb->update('vcategory_to_object')
100+
->set('categoryid', $updateQb->createNamedParameter($keepId))
101+
->where($updateQb->expr()->eq('categoryid', $updateQb->createNamedParameter($categoryId)));
102+
103+
$affectedRows = $updateQb->executeStatement();
104+
if ($affectedRows > 0) {
105+
$output->info(" - Updated $affectedRows object references from category $categoryId to $keepId");
106+
}
107+
108+
// Remove duplicate category record
109+
$deleteQb = $this->connection->getQueryBuilder();
110+
$deleteQb->delete('vcategory')
111+
->where($deleteQb->expr()->eq('id', $deleteQb->createNamedParameter($categoryId)));
112+
113+
$deleteQb->executeStatement();
114+
$output->info(" - Deleted duplicate category record ID $categoryId");
115+
116+
}
117+
118+
$result->closeCursor();
119+
120+
if ($duplicateCount === 0) {
121+
$output->info('No duplicate categories found');
122+
} else {
123+
$output->info("Duplicate cleanup completed - processed $duplicateCount duplicates");
124+
}
125+
}
126+
}

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: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -268,15 +268,17 @@ public function hasTag(string $name): bool {
268268
public function add(string $name) {
269269
$name = trim($name);
270270

271-
if ($name === '') {
271+
if (empty($name)) {
272272
$this->logger->debug(__METHOD__ . ' Cannot add an empty tag', ['app' => 'core']);
273273
return false;
274274
}
275+
276+
// Prevent duplicate
275277
if ($this->userHasTag($name, $this->user)) {
276-
// TODO use unique db properties instead of an additional check
277278
$this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
278279
return false;
279280
}
281+
280282
try {
281283
$tag = new Tag($this->user, $this->type, $name);
282284
$tag = $this->mapper->insert($tag);
@@ -288,6 +290,7 @@ public function add(string $name) {
288290
]);
289291
return false;
290292
}
293+
291294
$this->logger->debug(__METHOD__ . ' Added an tag with ' . $tag->getId(), ['app' => 'core']);
292295
return $tag->getId();
293296
}
@@ -303,7 +306,7 @@ public function rename($from, string $to): bool {
303306
$from = trim($from);
304307
$to = trim($to);
305308

306-
if ($to === '' || $from === '') {
309+
if (empty($to) || empty($from)) {
307310
$this->logger->debug(__METHOD__ . 'Cannot use an empty tag names', ['app' => 'core']);
308311
return false;
309312
}
@@ -313,12 +316,14 @@ public function rename($from, string $to): bool {
313316
} else {
314317
$key = $this->getTagByName($from);
315318
}
319+
316320
if ($key === false) {
317321
$this->logger->debug(__METHOD__ . 'Tag ' . $from . 'does not exist', ['app' => 'core']);
318322
return false;
319323
}
320324
$tag = $this->tags[$key];
321325

326+
// Prevent duplicate
322327
if ($this->userHasTag($to, $tag->getOwner())) {
323328
$this->logger->debug(__METHOD__ . 'A tag named' . $to . 'already exists for user' . $tag->getOwner(), ['app' => 'core']);
324329
return false;
@@ -355,7 +360,7 @@ public function addMultiple($names, bool $sync = false, ?int $id = null): bool {
355360

356361
$newones = [];
357362
foreach ($names as $name) {
358-
if (!$this->hasTag($name) && $name !== '') {
363+
if (!$this->hasTag($name) && !empty($name)) {
359364
$newones[] = new Tag($this->user, $this->type, $name);
360365
}
361366
if (!is_null($id)) {
@@ -504,13 +509,15 @@ public function removeFromFavorites($objid) {
504509
public function tagAs($objid, $tag, string $path = '') {
505510
if (is_string($tag) && !is_numeric($tag)) {
506511
$tag = trim($tag);
507-
if ($tag === '') {
512+
if (empty($tag)) {
508513
$this->logger->debug(__METHOD__ . ', Cannot add an empty tag');
509514
return false;
510515
}
516+
511517
if (!$this->hasTag($tag)) {
512518
$this->add($tag);
513519
}
520+
514521
$tagId = $this->getTagId($tag);
515522
} else {
516523
$tagId = $tag;
@@ -547,7 +554,7 @@ public function tagAs($objid, $tag, string $path = '') {
547554
public function unTag($objid, $tag, string $path = '') {
548555
if (is_string($tag) && !is_numeric($tag)) {
549556
$tag = trim($tag);
550-
if ($tag === '') {
557+
if (empty($tag)) {
551558
$this->logger->debug(__METHOD__ . ', Tag name is empty');
552559
return false;
553560
}

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)