Passed
Pull Request — master (#7008)
by
unknown
08:46
created

ConvertToMultiUrlCommand::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 5
rs 10
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CoreBundle\Command;
8
9
use DateTimeImmutable;
10
use Doctrine\DBAL\Connection;
11
use Doctrine\DBAL\Exception as DBALException;
12
use Doctrine\DBAL\Schema\AbstractSchemaManager;
13
use Doctrine\DBAL\Types\Types;
14
use Symfony\Component\Console\Attribute\AsCommand;
15
use Symfony\Component\Console\Command\Command;
16
use Symfony\Component\Console\Input\InputArgument;
17
use Symfony\Component\Console\Input\InputInterface;
18
use Symfony\Component\Console\Input\InputOption;
19
use Symfony\Component\Console\Output\OutputInterface;
20
use Symfony\Component\Console\Style\SymfonyStyle;
21
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
22
23
/**
24
 * MultiURL Converter (Single → Multi)
25
 * Usage:
26
 *   php bin/console app:multi-url:convert "https://admin.example.com/" [--admin-username=admin] [--preserve-admin-id] [--dry-run] [--force]
27
 *
28
 * What it does:
29
 *   • Default: adds a NEW ADMIN URL; keeps current URL as SECONDARY; links the chosen admin user.
30
 *   • --preserve-admin-id: keeps CURRENT row as ADMIN, inserts SECONDARY (id+1), and moves URL-bound FKs
31
 *     (incl. access_url_rel_usergroup for classes).
32
 *
33
 * Safeguards:
34
 *   • Expects exactly 1 row in access_url (unless --force). Runs in a transaction; --dry-run prints the plan only.
35
 *
36
 * After running:
37
 *   • Enable multi-URL in configuration and clear caches if needed. Non-existent tables/columns are skipped.
38
 *
39
 */
40
41
#[AsCommand(
42
    name: 'app:multi-url:convert',
43
    description: 'Convert a single-URL Chamilo portal to MultiURL. Optionally preserve current access_url ID as admin and move foreign keys.'
44
)]
45
class ConvertToMultiUrlCommand extends Command
46
{
47
    public function __construct(
48
        private readonly Connection $conn,
49
        private readonly ParameterBagInterface $params
50
    ) {
51
        parent::__construct();
52
    }
53
54
    protected function configure(): void
55
    {
56
        $this
57
            ->addArgument('admin-url', InputArgument::REQUIRED, 'New admin URL (with scheme and trailing slash)')
58
            ->addOption('admin-username', null, InputOption::VALUE_REQUIRED, 'Global admin username (default: user id 1)')
59
            ->addOption('preserve-admin-id', null, InputOption::VALUE_NONE, 'Legacy migration: keep current access_url id as ADMIN and insert secondary with id+1, moving foreign keys')
60
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not write; only show planned changes')
61
            ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip single-URL safety check (use with caution)');
62
    }
63
64
    protected function execute(InputInterface $input, OutputInterface $output): int
65
    {
66
        $io          = new SymfonyStyle($input, $output);
67
        $newAdminUrl = (string) $input->getArgument('admin-url');
68
        $username    = (string) ($input->getOption('admin-username') ?? '');
69
        $preserveId  = (bool) $input->getOption('preserve-admin-id');
70
        $dryRun      = (bool) $input->getOption('dry-run');
71
        $force       = (bool) $input->getOption('force');
72
73
        $prefix = (string) ($this->params->has('database_prefix') ? $this->params->get('database_prefix') : '');
74
        $T = static fn(string $name) => $prefix.$name;
75
76
        /** @var AbstractSchemaManager $sm */
77
        $sm = $this->conn->createSchemaManager();
78
        $exists = fn(string $table) => $sm->tablesExist([$table]);
79
80
        // Canonical tables (based on your entities)
81
        $accessUrl             = $T('access_url');
82
        $relCourse             = $T('access_url_rel_course');
83
        $relCourseCategory     = $T('access_url_rel_course_category');
84
        $relSession            = $T('access_url_rel_session');
85
        $relUser               = $T('access_url_rel_user');
86
        $relUsergroup          = $T('access_url_rel_usergroup');
87
88
        $userRelCourseVote     = $T('user_rel_course_vote');   // url_id
89
        $trackOnline           = $T('track_e_online');         // access_url_id
90
        $sysAnnouncement       = $T('sys_announcement');       // access_url_id
91
        $skill                 = $T('skill');                   // access_url_id
92
        $branchSync            = $T('branch_sync');             // access_url_id
93
        $sessionCategory       = $T('session_category');        // access_url_id
94
95
        // Optional/legacy extras (existence-checked)
96
        $systemCalendar        = $T('system_calendar');         // access_url_id (if present)
97
        $trackCourseRanking    = $T('track_course_ranking');    // url_id (if present)
98
        $userTable             = $T('user');
99
100
        // Safety: expect single URL unless --force
101
        $countUrl = (int) $this->conn->fetchOne("SELECT COUNT(*) FROM {$accessUrl}");
102
        if (!$force && $countUrl !== 1) {
103
            $io->error("Aborting: expected exactly 1 row in access_url; found {$countUrl}. Use --force if you know what you are doing.");
104
            return Command::FAILURE;
105
        }
106
107
        // Fetch a deterministic "current" row (lowest id) for planning
108
        $row = $this->conn->fetchAssociative("SELECT * FROM {$accessUrl} ORDER BY id ASC LIMIT 1");
109
        if (!$row) {
110
            $io->error('No access_url row found. Nothing to convert.');
111
            return Command::FAILURE;
112
        }
113
        $currentId  = (int) $row['id'];
114
        $currentUrl = (string) $row['url'];
115
116
        // Resolve admin user id from username (fallback to 1)
117
        $adminUserId = 1;
118
        if ($username !== '') {
119
            $adminUserId = (int) ($this->conn->fetchOne(
120
                "SELECT id FROM {$userTable} WHERE username = :u",
121
                ['u' => $username],
122
                ['u' => Types::STRING]
123
            ) ?: 1);
124
            if ($adminUserId === 1 && $username !== '') {
125
                $io->warning("Username '{$username}' not found. Falling back to user id 1.");
126
            }
127
        } else {
128
            $io->note('No admin username provided; using admin id 1.');
129
        }
130
131
        // Inspect access_url columns to know if Gedmo Tree fields exist (and if root/tree_root exists)
132
        $columns = [];
133
        foreach ($sm->listTableColumns($accessUrl) as $col) {
134
            $columns[$col->getName()] = true;
135
        }
136
        $hasLft = isset($columns['lft']);
137
        $hasRgt = isset($columns['rgt']);
138
        $hasLvl = isset($columns['lvl']);
139
        $rootCol = isset($columns['tree_root']) ? 'tree_root' : (isset($columns['root']) ? 'root' : null);
140
        $hasTree = ($hasLft && $hasRgt && $hasLvl);
141
142
        // Plan (no writes here)
143
        $io->section('Current / Planned URLs');
144
        if ($preserveId) {
145
            $adminUrlId = $currentId;         // stays admin
146
            $oldUrlId   = $currentId + 1;     // secondary row
147
            $io->listing([
148
                "Keep current row as ADMIN: id={$adminUrlId} url will become {$newAdminUrl}",
149
                "Insert SECONDARY URL: id={$oldUrlId} url will be {$currentUrl}",
150
                "Move foreign keys: {$adminUrlId} -> {$oldUrlId} (includes user groups/classes).",
151
            ]);
152
        } else {
153
            $io->listing([
154
                "Keep current URL as SECONDARY: id={$currentId} url={$currentUrl}",
155
                "Insert new ADMIN URL with auto id: url={$newAdminUrl}",
156
                "No FK moves; only link admin user to new admin URL.",
157
            ]);
158
        }
159
160
        // Early exit on dry-run: show plan only
161
        if ($dryRun) {
162
            $io->success('Dry-run complete. No changes were committed.');
163
            $io->note('If you proceed for real, remember to enable multiple_access_urls afterwards.');
164
            return Command::SUCCESS;
165
        }
166
167
        $this->conn->beginTransaction();
168
        try {
169
            if ($preserveId) {
170
                // 1) Make current row ADMIN
171
                $sql = "UPDATE {$accessUrl} SET url = :adminUrl, description = :descr WHERE id = :id";
172
                $io->text("∙ Update current access_url -> ADMIN");
173
                $this->conn->executeStatement($sql, [
174
                    'adminUrl' => $newAdminUrl,
175
                    'descr'    => 'The main admin URL',
176
                    'id'       => $currentId,
177
                ], [
178
                    'adminUrl' => Types::STRING,
179
                    'descr'    => Types::STRING,
180
                    'id'       => Types::INTEGER,
181
                ]);
182
183
                // 2) Insert SECONDARY with explicit id = currentId+1 (old URL)
184
                $adminUrlId = $currentId;
185
                $oldUrlId   = $currentId + 1;
186
187
                $insertCols = ['id','url','description','active','created_by','tms','url_type'];
188
                $params     = [
189
                    'id'          => $oldUrlId,
190
                    'url'         => $currentUrl,
191
                    'description' => '',
192
                    'active'      => 1,
193
                    'created_by'  => 1,
194
                    'tms'         => new \DateTime('now', new \DateTimeZone('UTC')),
195
                    'url_type'    => null,
196
                ];
197
                $types      = [
198
                    'id'          => Types::INTEGER,
199
                    'url'         => Types::STRING,
200
                    'description' => Types::STRING,
201
                    'active'      => Types::BOOLEAN,
202
                    'created_by'  => Types::INTEGER,
203
                    'tms'         => Types::DATETIME_MUTABLE,
204
                    'url_type'    => Types::BOOLEAN,
205
                ];
206
207
                if ($hasTree) {
208
                    // Provide values at INSERT time to satisfy NOT NULL columns in strict MySQL
209
                    $insertCols[] = 'lft';
210
                    $insertCols[] = 'rgt';
211
                    $insertCols[] = 'lvl';
212
                    $params['lft'] = 1;
213
                    $params['rgt'] = 2;
214
                    $params['lvl'] = 0;
215
                    $types['lft'] = Types::INTEGER;
216
                    $types['rgt'] = Types::INTEGER;
217
                    $types['lvl'] = Types::INTEGER;
218
                }
219
                if ($rootCol) {
220
                    // Insert NULL for root; we will UPDATE to self id right after
221
                    $insertCols[] = $rootCol;
222
                    $params[$rootCol] = null;
223
                    $types[$rootCol]  = Types::INTEGER;
224
                }
225
226
                $io->text("∙ Insert SECONDARY access_url (explicit id = {$oldUrlId})");
227
                $placeholders = [];
228
                foreach ($insertCols as $c) {
229
                    $placeholders[] = ':'.$c;
230
                }
231
                $this->conn->executeStatement(
232
                    "INSERT INTO {$accessUrl} (".implode(',', $insertCols).") VALUES (".implode(',', $placeholders).")",
233
                    $params,
234
                    $types
235
                );
236
237
                // Initialize root column to self id if present
238
                if ($rootCol) {
239
                    $io->text("∙ Initialize tree root column for SECONDARY");
240
                    $this->conn->executeStatement(
241
                        "UPDATE {$accessUrl} SET {$rootCol} = :self WHERE id = :self",
242
                        ['self' => $oldUrlId],
243
                        ['self' => Types::INTEGER]
244
                    );
245
                }
246
247
                // 3) Move FKs from ADMIN to SECONDARY
248
                $move = function (string $table, string $col) use ($io, $adminUrlId, $oldUrlId, $exists) {
249
                    if (!$exists($table)) {
250
                        return;
251
                    }
252
                    $io->text("∙ Update {$table}.{$col} {$adminUrlId} -> {$oldUrlId}");
253
                    $this->conn->executeStatement(
254
                        "UPDATE {$table} SET {$col} = :to WHERE {$col} = :from",
255
                        ['to' => $oldUrlId, 'from' => $adminUrlId],
256
                        ['to' => Types::INTEGER, 'from' => Types::INTEGER]
257
                    );
258
                };
259
260
                $move($userRelCourseVote, 'url_id');
261
                $move($trackOnline,       'access_url_id');
262
                $move($sysAnnouncement,   'access_url_id');
263
                $move($skill,             'access_url_id');
264
                $move($relCourse,         'access_url_id');
265
                $move($relCourseCategory, 'access_url_id');
266
                $move($relSession,        'access_url_id');
267
                $move($relUser,           'access_url_id');
268
                $move($relUsergroup,      'access_url_id');
269
                $move($branchSync,        'access_url_id');
270
                $move($sessionCategory,   'access_url_id');
271
272
                if ($exists($systemCalendar)) {
273
                    $move($systemCalendar, 'access_url_id');
274
                }
275
                if ($exists($trackCourseRanking)) {
276
                    $move($trackCourseRanking, 'url_id');
277
                }
278
279
                // 4) Ensure admin user is linked to ADMIN url
280
                $io->text("∙ Ensure admin user has relation to ADMIN url");
281
                $this->conn->executeStatement(
282
                    "INSERT INTO {$relUser} (access_url_id, user_id)
283
                     SELECT :adminId, :userId
284
                     WHERE NOT EXISTS (
285
                       SELECT 1 FROM {$relUser} WHERE access_url_id = :adminId AND user_id = :userId
286
                     )",
287
                    ['adminId' => $adminUrlId, 'userId' => $adminUserId],
288
                    ['adminId' => Types::INTEGER, 'userId' => Types::INTEGER]
289
                );
290
            } else {
291
                // SAFER: Insert ADMIN with auto id; keep current as SECONDARY (no FK moves)
292
                $io->text("∙ Insert ADMIN access_url (auto id)");
293
294
                $insertCols = ['url','description','active','created_by','tms','url_type'];
295
                $params     = [
296
                    'url'         => $newAdminUrl,
297
                    'description' => 'The main admin URL',
298
                    'active'      => 1,
299
                    'created_by'  => 1,
300
                    'tms'         => new \DateTime('now', new \DateTimeZone('UTC')),
301
                    'url_type'    => null,
302
                ];
303
                $types      = [
304
                    'url'         => Types::STRING,
305
                    'description' => Types::STRING,
306
                    'active'      => Types::BOOLEAN,
307
                    'created_by'  => Types::INTEGER,
308
                    'tms'         => Types::DATETIME_MUTABLE,
309
                    'url_type'    => Types::BOOLEAN,
310
                ];
311
312
                if ($hasTree) {
313
                    // Provide lft/rgt/lvl at INSERT to satisfy NOT NULL
314
                    $insertCols[] = 'lft';
315
                    $insertCols[] = 'rgt';
316
                    $insertCols[] = 'lvl';
317
                    $params['lft'] = 1;
318
                    $params['rgt'] = 2;
319
                    $params['lvl'] = 0;
320
                    $types['lft'] = Types::INTEGER;
321
                    $types['rgt'] = Types::INTEGER;
322
                    $types['lvl'] = Types::INTEGER;
323
                }
324
                if ($rootCol) {
325
                    $insertCols[] = $rootCol;
326
                    $params[$rootCol] = null; // set later to self id
327
                    $types[$rootCol]  = Types::INTEGER;
328
                }
329
330
                $placeholders = [];
331
                foreach ($insertCols as $c) {
332
                    $placeholders[] = ':'.$c;
333
                }
334
335
                $this->conn->executeStatement(
336
                    "INSERT INTO {$accessUrl} (".implode(',', $insertCols).") VALUES (".implode(',', $placeholders).")",
337
                    $params,
338
                    $types
339
                );
340
341
                // Get new id deterministically
342
                $newId = (int) $this->conn->fetchOne(
343
                    "SELECT id FROM {$accessUrl} WHERE url = :u ORDER BY id DESC LIMIT 1",
344
                    ['u' => $newAdminUrl],
345
                    ['u' => Types::STRING]
346
                );
347
348
                // Initialize tree root to self id if present
349
                if ($rootCol) {
350
                    $io->text("∙ Initialize tree root column for ADMIN access_url");
351
                    $this->conn->executeStatement(
352
                        "UPDATE {$accessUrl}
353
                         SET {$rootCol} = :self
354
                         WHERE id = :self",
355
                        ['self' => $newId],
356
                        ['self' => Types::INTEGER]
357
                    );
358
                }
359
360
                // Ensure admin user is linked to new ADMIN url
361
                $io->text("∙ Ensure admin user has relation to ADMIN url");
362
                $this->conn->executeStatement(
363
                    "INSERT INTO {$relUser} (access_url_id, user_id)
364
                     SELECT :adminId, :userId
365
                     WHERE NOT EXISTS (
366
                       SELECT 1 FROM {$relUser} WHERE access_url_id = :adminId AND user_id = :userId
367
                     )",
368
                    ['adminId' => $newId, 'userId' => $adminUserId],
369
                    ['adminId' => Types::INTEGER, 'userId' => Types::INTEGER]
370
                );
371
            }
372
373
            $this->conn->commit();
374
            $io->success('Portal converted to MultiURL successfully.');
375
            $io->note('Remember to enable multi-URL mode in configuration (e.g., configuration.php or platform setting).');
376
            return Command::SUCCESS;
377
        } catch (DBALException $e) {
378
            $this->conn->rollBack();
379
            $io->error('Conversion failed: '.$e->getMessage());
380
            return Command::FAILURE;
381
        }
382
    }
383
}
384