Passed
Pull Request — master (#7142)
by
unknown
09:36
created

Version20251202103000::down()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 24
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 4
eloc 14
c 2
b 1
f 0
nc 6
nop 1
dl 0
loc 24
rs 9.7998
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
8
9
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
10
use Doctrine\DBAL\Schema\Schema;
11
12
final class Version20251202103000 extends AbstractMigrationChamilo
13
{
14
    private const TARGET_CHARSET = 'utf8mb4';
15
    private const TARGET_COLLATION = 'utf8mb4_unicode_ci';
16
17
    private const LEGACY_CHARSET = 'utf8mb3';
18
    private const LEGACY_COLLATION = 'utf8mb3_unicode_ci';
19
20
    public function getDescription(): string
21
    {
22
        return 'Convert remaining core tables to utf8mb4 / utf8mb4_unicode_ci';
23
    }
24
25
    public function up(Schema $schema): void
26
    {
27
        $this->abortIf(
28
            !\in_array($this->connection->getDatabasePlatform()->getName(), ['mysql', 'mariadb'], true),
29
            'This migration only supports MySQL/MariaDB.'
30
        );
31
32
        foreach ($this->getTablesToConvert() as $table) {
33
            $this->convertTable($table, self::TARGET_CHARSET, self::TARGET_COLLATION);
34
        }
35
    }
36
37
    public function down(Schema $schema): void
38
    {
39
        $this->abortIf(
40
            !\in_array($this->connection->getDatabasePlatform()->getName(), ['mysql', 'mariadb'], true),
41
            'This migration only supports MySQL/MariaDB.'
42
        );
43
44
        $tables = $this->getTablesToConvert();
45
        $offenders = $this->findUtf8mb3IncompatibleColumns($tables);
46
47
        if (!empty($offenders)) {
48
            $message = "Cannot downgrade to utf8mb3. Found 4-byte Unicode characters in:\n- " .
49
                implode("\n- ", $offenders) .
50
                "\n\nRemove those characters first or restore a pre-migration backup.";
51
52
            if (method_exists($this, 'throwIrreversibleMigrationException')) {
53
                $this->throwIrreversibleMigrationException($message);
54
            }
55
56
            $this->abortIf(true, $message);
57
        }
58
59
        foreach ($tables as $table) {
60
            $this->convertTable($table, self::LEGACY_CHARSET, self::LEGACY_COLLATION);
61
        }
62
    }
63
64
    private function findUtf8mb3IncompatibleColumns(array $tables): array
65
    {
66
        $db = $this->connection->fetchOne('SELECT DATABASE()');
67
68
        $in = implode(',', array_fill(0, count($tables), '?'));
69
70
        $cols = $this->connection->fetchAllAssociative(
71
            "SELECT TABLE_NAME, COLUMN_NAME
72
         FROM information_schema.COLUMNS
73
         WHERE TABLE_SCHEMA = ?
74
           AND TABLE_NAME IN ($in)
75
           AND COLLATION_NAME LIKE 'utf8mb4%'
76
           AND DATA_TYPE IN ('char','varchar','tinytext','text','mediumtext','longtext')",
77
            array_merge([$db], $tables)
78
        );
79
80
        $bad = [];
81
82
        foreach ($cols as $col) {
83
            $t = $col['TABLE_NAME'];
84
            $c = $col['COLUMN_NAME'];
85
86
            // MySQL 8+ supports this Unicode range regexp.
87
            // If your server doesn't support it, this query will throw and we fallback.
88
            try {
89
                $hit = $this->connection->fetchOne(
90
                    "SELECT 1 FROM `$t` WHERE `$c` REGEXP '[\\x{10000}-\\x{10FFFF}]' LIMIT 1"
91
                );
92
            } catch (\Throwable $e) {
93
                // Fallback: any UTF-8 4-byte sequence starts with lead bytes F0-F4.
94
                $hit = $this->connection->fetchOne(
95
                    "SELECT 1 FROM `$t` WHERE HEX(`$c`) REGEXP 'F[0-4]' LIMIT 1"
96
                );
97
            }
98
99
            if ($hit) {
100
                $bad[] = "$t.$c";
101
            }
102
        }
103
104
        return $bad;
105
    }
106
107
    /**
108
     * @return string[]
109
     */
110
    private function getTablesToConvert(): array
111
    {
112
        return [
113
            // Main
114
            'access_url',
115
            'agenda_reminder',
116
            'block',
117
            'branch_sync',
118
            'branch_transaction',
119
            'branch_transaction_status',
120
            'career',
121
            'chat',
122
            'chat_video',
123
            'course',
124
            'course_category',
125
            'course_rel_class',
126
            'course_request',
127
            'course_type',
128
129
            // Course tables (single DB schema in C2)
130
            'c_announcement',
131
            'c_announcement_attachment',
132
            'c_attendance',
133
            'c_attendance_sheet',
134
            'c_attendance_sheet_log',
135
            'c_blog',
136
            'c_blog_attachment',
137
            'c_blog_comment',
138
            'c_blog_post',
139
            'c_blog_rating',
140
            'c_blog_task',
141
            'c_calendar_event',
142
            'c_calendar_event_attachment',
143
            'c_calendar_event_repeat',
144
            'c_course_description',
145
            'c_course_setting',
146
            'c_document',
147
            'c_dropbox_category',
148
            'c_dropbox_feedback',
149
            'c_dropbox_file',
150
            'c_dropbox_post',
151
            'c_forum_attachment',
152
            'c_forum_category',
153
            'c_forum_forum',
154
            'c_forum_post',
155
            'c_forum_thread',
156
            'c_glossary',
157
            'c_group_category',
158
            'c_group_info',
159
            'c_group_rel_user',
160
            'c_link',
161
            'c_link_category',
162
            'c_lp',
163
            'c_lp_category',
164
            'c_lp_item',
165
            'c_lp_item_view',
166
            'c_lp_iv_interaction',
167
            'c_lp_iv_objective',
168
            'c_notebook',
169
            'c_quiz',
170
            'c_quiz_answer',
171
            'c_quiz_category',
172
            'c_quiz_question',
173
            'c_quiz_question_category',
174
            'c_quiz_question_option',
175
            'c_quiz_rel_question',
176
            'c_student_publication',
177
            'c_student_publication_comment',
178
            'c_survey',
179
            'c_survey_answer',
180
            'c_survey_invitation',
181
            'c_survey_question',
182
            'c_survey_question_option',
183
            'c_thematic',
184
            'c_thematic_advance',
185
            'c_thematic_plan',
186
            'c_tool',
187
            'c_tool_intro',
188
            'c_wiki',
189
            'c_wiki_conf',
190
            'c_wiki_discuss',
191
            'c_wiki_mailcue',
192
193
            // Extra fields / settings
194
            'extra_field',
195
            'extra_field_options',
196
            'extra_field_saved_search',
197
            'extra_field_values',
198
            'ext_log_entries',
199
            'fos_group',
200
201
            // Gradebook
202
            'gradebook_category',
203
            'gradebook_certificate',
204
            'gradebook_evaluation',
205
            'gradebook_link',
206
            'gradebook_linkeval_log',
207
            'gradebook_score_display',
208
            'grade_components',
209
            'grade_model',
210
211
            // Misc
212
            'language',
213
            'legal',
214
            'message_attachment',
215
            'portfolio',
216
            'portfolio_category',
217
            'promotion',
218
            'room',
219
            'scheduled_announcements',
220
            'search_engine_ref',
221
222
            // Sequence
223
            'sequence',
224
            'sequence_condition',
225
            'sequence_method',
226
            'sequence_row_entity',
227
            'sequence_rule',
228
            'sequence_type_entity',
229
            'sequence_variable',
230
231
            // Sessions / settings
232
            'session',
233
            'session_category',
234
            'settings',
235
            'settings_options',
236
237
            // Skills
238
            'skill',
239
            'skill_level',
240
            'skill_level_profile',
241
            'skill_profile',
242
            'skill_rel_gradebook',
243
            'skill_rel_item',
244
            'skill_rel_user',
245
            'skill_rel_user_comment',
246
247
            // Specific fields
248
            'specific_field',
249
            'specific_field_values',
250
251
            // Templates / announcements
252
            'system_template',
253
            'sys_announcement',
254
            'tag',
255
            'templates',
256
257
            // Tickets
258
            'ticket_category',
259
            'ticket_message',
260
            'ticket_message_attachments',
261
            'ticket_priority',
262
            'ticket_project',
263
            'ticket_status',
264
            'ticket_ticket',
265
266
            // Tool rights
267
            'tool',
268
            'tool_resource_right',
269
270
            // Tracking
271
            'track_e_access',
272
            'track_e_attempt',
273
            'track_e_course_access',
274
            'track_e_default',
275
            'track_e_downloads',
276
            'track_e_exercises',
277
            'track_e_hotpotatoes',
278
            'track_e_hotspot',
279
            'track_e_lastaccess',
280
            'track_e_login',
281
            'track_e_online',
282
283
            // Users
284
            'user',
285
            'usergroup',
286
            'user_api_key',
287
            'user_course_category',
288
            'user_friend_relation_type',
289
        ];
290
    }
291
292
    private function convertTable(string $table, string $charset, string $collation): void
293
    {
294
        if (!$this->tableExists($table)) {
295
            // Table might not exist depending on the edition / previous migrations.
296
            return;
297
        }
298
299
        // CONVERT changes all textual columns (CHAR/VARCHAR/TEXT/ENUM/SET) and the table default collation.
300
        $this->addSql(sprintf(
301
            'ALTER TABLE `%s` CONVERT TO CHARACTER SET %s COLLATE %s',
302
            $table,
303
            $charset,
304
            $collation
305
        ));
306
    }
307
308
    private function tableExists(string $table): bool
309
    {
310
        try {
311
            return $this->connection->createSchemaManager()->tablesExist([$table]);
312
        } catch (\Throwable $e) {
313
            // Keep the migration resilient if schema introspection fails.
314
            return false;
315
        }
316
    }
317
}
318