1+ <?php
2+
3+ /*
4+ * Copyright (c) 2020-2024. The Nextcloud Bookmarks contributors.
5+ *
6+ * This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
7+ */
8+
9+ namespace OCA \Bookmarks \Migration ;
10+
11+ use Closure ;
12+ use Doctrine \DBAL \Schema \SchemaException ;
13+ use OCP \DB \ISchemaWrapper ;
14+ use OCP \IDBConnection ;
15+ use OCP \Migration \IOutput ;
16+
17+ /**
18+ * Recovery migration for instances where the url_hash migration
19+ * (Version016002000Date20260201124723) was recorded in oc_migrations but never
20+ * actually applied to the schema — e.g. an upgrade that failed in
21+ * postSchemaChange on a unique-constraint violation and was then unstuck by a
22+ * DB snapshot restore or by manually marking the version as executed.
23+ * See https://github.com/nextcloud/bookmarks/issues/2475 and #2374.
24+ *
25+ * The original migration guards column creation with a `hasColumn` check and,
26+ * being recorded as executed, never runs again — so there is otherwise no code
27+ * path that will ever create the column. This migration re-establishes the
28+ * `url_hash` column and backfills it. The unique index is added by the
29+ * following migration (Version016002000Date20260717134723), which must run
30+ * only after duplicates have been merged and hashes written here, so that the
31+ * index cannot fail on colliding hashes.
32+ *
33+ * All steps are idempotent: on a healthy instance the column already exists,
34+ * there are no NULL hashes to backfill, and this migration is effectively a
35+ * no-op.
36+ */
37+ class Version016002000Date20260717124723 extends Version016002000Date20260218124723 {
38+ public function __construct (
39+ private IDBConnection $ db ,
40+ ) {
41+ parent ::__construct ($ db );
42+ }
43+
44+ /**
45+ * @throws SchemaException
46+ */
47+ public function changeSchema (IOutput $ output , Closure $ schemaClosure , array $ options ) {
48+ /** @var ISchemaWrapper $schema */
49+ $ schema = $ schemaClosure ();
50+ if ($ schema ->hasTable ('bookmarks ' )) {
51+ $ table = $ schema ->getTable ('bookmarks ' );
52+ if (!$ table ->hasColumn ('url_hash ' )) {
53+ $ output ->info ('Recovering missing url_hash column on the bookmarks table ' );
54+ $ table ->addColumn ('url_hash ' , 'string ' , [
55+ 'notnull ' => false ,
56+ 'length ' => 32 ,
57+ ]);
58+ }
59+ }
60+
61+ return $ schema ;
62+ }
63+
64+ public function postSchemaChange (IOutput $ output , Closure $ schemaClosure , array $ options ) {
65+ // Only do any work if there are bookmarks without a hash. On healthy
66+ // instances this short-circuits before the (expensive) dedup scan.
67+ $ countQb = $ this ->db ->getQueryBuilder ();
68+ $ countQb ->select ($ countQb ->func ()->count ('id ' ))
69+ ->from ('bookmarks ' )
70+ ->where ($ countQb ->expr ()->isNull ('url_hash ' ));
71+ $ result = $ countQb ->executeQuery ();
72+ $ count = (int )$ result ->fetchOne ();
73+ $ result ->closeCursor ();
74+
75+ if ($ count === 0 ) {
76+ return ;
77+ }
78+
79+ // Merge per-user duplicate URLs before hashing, otherwise duplicates
80+ // would produce colliding (user_id, url_hash) pairs and break the
81+ // unique index added by the next migration.
82+ $ this ->deduplicateAll ($ output );
83+
84+ $ output ->info ('Hashing URLs of n= ' . $ count . ' bookmarks ' );
85+ $ output ->startProgress ($ count );
86+
87+ $ qb = $ this ->db ->getQueryBuilder ();
88+ $ qb ->select ('id ' , 'url ' )
89+ ->from ('bookmarks ' )
90+ ->where ($ qb ->expr ()->isNull ('url_hash ' ));
91+ $ result = $ qb ->executeQuery ();
92+
93+ $ setQb = $ this ->db ->getQueryBuilder ();
94+ $ setQb ->update ('bookmarks ' )
95+ ->set ('url_hash ' , $ setQb ->createParameter ('url_hash ' ))
96+ ->where ($ setQb ->expr ()->eq ('id ' , $ setQb ->createParameter ('id ' )));
97+
98+ $ i = 1 ;
99+ $ this ->db ->beginTransaction ();
100+ try {
101+ while ($ row = $ result ->fetch ()) {
102+ if ($ i ++ % 1000 === 0 ) {
103+ $ this ->db ->commit ();
104+ $ this ->db ->beginTransaction ();
105+ }
106+ $ setQb ->setParameter ('url_hash ' , hash ('xxh128 ' , $ row ['url ' ]));
107+ $ setQb ->setParameter ('id ' , $ row ['id ' ]);
108+ $ setQb ->executeStatement ();
109+ $ output ->advance ();
110+ }
111+ $ this ->db ->commit ();
112+ } catch (\Throwable $ e ) {
113+ $ this ->db ->rollBack ();
114+ throw $ e ;
115+ }
116+ $ output ->finishProgress ();
117+ $ result ->closeCursor ();
118+ }
119+ }
0 commit comments