Completed
Push — master ( 7062b3...1c7f62 )
by
unknown
01:56 queued 39s
created

MigrateAttendancesFastCommand::execute()   F

Complexity

Conditions 29
Paths > 20000

Size

Total Lines 194
Code Lines 126

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 29
eloc 126
c 2
b 0
f 0
nc 32148
nop 2
dl 0
loc 194
rs 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Command;
8
9
use Doctrine\DBAL\Connection;
10
use Doctrine\DBAL\Exception as DbalException;
11
use Doctrine\DBAL\ParameterType;
12
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
13
use Doctrine\ORM\EntityManagerInterface;
14
use Symfony\Component\Console\Attribute\AsCommand;
15
use Symfony\Component\Console\Command\Command;
16
use Symfony\Component\Console\Input\InputInterface;
17
use Symfony\Component\Console\Input\InputOption;
18
use Symfony\Component\Console\Output\OutputInterface;
19
use Symfony\Component\Console\Style\SymfonyStyle;
20
use Symfony\Component\String\Slugger\SluggerInterface;
21
use Symfony\Component\Uid\Uuid;
22
23
#[AsCommand(
24
    name: 'chamilo:migration:migrate-attendances-fast',
25
    description: 'Fast SQL migration for attendances (resource_node/resource_link) not migrated during Doctrine migrations.',
26
)]
27
final class MigrateAttendancesFastCommand extends Command
28
{
29
    public const RESOURCE_TYPE_ID_ATTENDANCE = 10;
30
    public const RESOURCE_TYPE_GROUP_ATTENDANCE = 10;
31
32
    public function __construct(
33
        private readonly EntityManagerInterface $em,
34
        private readonly Connection $connection,
35
        private readonly SluggerInterface $slugger,
36
    ) {
37
        parent::__construct();
38
    }
39
40
    protected function configure(): void
41
    {
42
        $this->addOption(
43
            'drop-c-item-property',
44
            null,
45
            InputOption::VALUE_NONE,
46
            'Drop legacy table c_item_property after successful migration (only if no pending attendances remain).'
47
        );
48
49
        // Alias for convenience (same behavior as --drop-c-item-property)
50
        $this->addOption(
51
            'drop-c-item-properties',
52
            null,
53
            InputOption::VALUE_NONE,
54
            'Alias of --drop-c-item-property (drops legacy table c_item_property after successful migration, only if no pending attendances remain).'
55
        );
56
    }
57
58
    protected function execute(InputInterface $input, OutputInterface $output): int
59
    {
60
        $io = new SymfonyStyle($input, $output);
61
        $io->title('Fast attendances migration');
62
63
        // Accept both flags as the same action (alias)
64
        $dropItemProperty = (bool) $input->getOption('drop-c-item-property')
65
            || (bool) $input->getOption('drop-c-item-properties');
66
67
        $fallbackAdminId = $this->getFallbackAdminId();
68
        $uuidIsBinary = $this->detectUuidIsBinary();
69
70
        $hasItemProperty = $this->tableExists('c_item_property');
71
        $hasAttendanceCId = $this->tableHasColumn('c_attendance', 'c_id');
72
        $hasAttendanceTitle = $this->tableHasColumn('c_attendance', 'title');
73
        $hasAttendanceName = $this->tableHasColumn('c_attendance', 'name');
74
75
        // At this migration stage, c_attendance.session_id is expected to be removed.
76
        // We only rely on c_item_property.session_id when available.
77
        $hasItemPropertySessionId = $hasItemProperty && $this->tableHasColumn('c_item_property', 'session_id');
78
79
        if (!$hasItemProperty && !$hasAttendanceCId) {
80
            $io->error('Cannot determine attendance->course mapping: c_item_property does not exist and c_attendance.c_id does not exist.');
81
            return Command::FAILURE;
82
        }
83
84
        if ($hasItemProperty && !$hasItemPropertySessionId) {
85
            $io->note('c_item_property.session_id is not available. Session context will be stored as NULL in resource_link.');
86
        }
87
88
        $courseIds = $this->getCourseIdsToProcess($hasItemProperty, $hasAttendanceCId);
89
90
        if (0 === \count($courseIds)) {
91
            $io->success('No attendances to migrate (nothing pending).');
92
            if ($dropItemProperty) {
93
                $this->maybeDropItemProperty($io);
94
            }
95
            return Command::SUCCESS;
96
        }
97
98
        $processedCourses = 0;
99
        $processedAttendances = 0;
100
101
        foreach ($courseIds as $courseId) {
102
            $courseId = (int) $courseId;
103
104
            $courseRow = $this->connection->fetchAssociative(
105
                'SELECT id, resource_node_id FROM course WHERE id = :id',
106
                ['id' => $courseId]
107
            );
108
109
            if (!$courseRow) {
110
                $io->warning("Course {$courseId} not found - skipping.");
111
                continue;
112
            }
113
114
            $courseResourceNodeId = isset($courseRow['resource_node_id']) ? (int) $courseRow['resource_node_id'] : 0;
115
            if ($courseResourceNodeId <= 0) {
116
                $io->warning("Course {$courseId} has no resource_node_id - skipping.");
117
                continue;
118
            }
119
120
            $courseNode = $this->connection->fetchAssociative(
121
                'SELECT id, path, level FROM resource_node WHERE id = :id',
122
                ['id' => $courseResourceNodeId]
123
            );
124
125
            if (!$courseNode) {
126
                $io->warning("Course {$courseId} resource_node {$courseResourceNodeId} not found - skipping.");
127
                continue;
128
            }
129
130
            $coursePath = rtrim((string) ($courseNode['path'] ?? ''), '/');
131
132
            $attendanceRows = $this->fetchPendingAttendancesForCourse(
133
                courseId: $courseId,
134
                hasItemProperty: $hasItemProperty,
135
                hasAttendanceCId: $hasAttendanceCId,
136
                hasAttendanceTitle: $hasAttendanceTitle,
137
                hasAttendanceName: $hasAttendanceName,
138
                hasItemPropertySessionId: $hasItemPropertySessionId
139
            );
140
141
            if (0 === \count($attendanceRows)) {
142
                continue;
143
            }
144
145
            $io->section("Course {$courseId}: migrating ".\count($attendanceRows).' attendances');
146
147
            $displayOrder = 0;
148
            $this->connection->beginTransaction();
149
150
            try {
151
                foreach ($attendanceRows as $row) {
152
                    $attendanceId = (int) $row['iid'];
153
                    $attendanceTitle = $this->pickAttendanceTitle($row, $attendanceId);
154
155
                    // session_id is read from c_item_property (when available).
156
                    // Normalize 0 -> NULL as expected by resource_link.session_id.
157
                    $attendanceSessionId = isset($row['session_id']) ? (int) $row['session_id'] : 0;
158
                    $attendanceSessionId = 0 === $attendanceSessionId ? null : $attendanceSessionId;
159
160
                    $ip = [];
161
                    if ($hasItemProperty) {
162
                        $ip = $this->connection->fetchAssociative(
163
                            "SELECT insert_date, lastedit_date, lastedit_user_id, visibility, start_visible, end_visible, to_group_id, to_user_id
164
                             FROM c_item_property
165
                             WHERE tool = 'attendance' AND ref = :ref AND c_id = :cid
166
                             LIMIT 1",
167
                            ['ref' => $attendanceId, 'cid' => $courseId]
168
                        ) ?: [];
169
                    }
170
171
                    $insertDate = $ip['insert_date'] ?? $this->nowUtc();
172
                    $lastEditDate = $ip['lastedit_date'] ?? $insertDate;
173
                    $lastEditUserId = isset($ip['lastedit_user_id']) ? (int) $ip['lastedit_user_id'] : null;
174
175
                    $visibility = isset($ip['visibility']) ? (int) $ip['visibility'] : 1;
176
                    $startVisible = $ip['start_visible'] ?? null;
177
                    $endVisible = $ip['end_visible'] ?? null;
178
179
                    $toGroupId = isset($ip['to_group_id']) ? (int) $ip['to_group_id'] : null;
180
                    $toUserId = isset($ip['to_user_id']) ? (int) $ip['to_user_id'] : null;
181
182
                    $creatorId = $lastEditUserId ?: $fallbackAdminId;
183
184
                    $uuid = Uuid::v4();
185
                    $uuidValue = $uuidIsBinary ? $uuid->toBinary() : $uuid->toRfc4122();
186
187
                    // Keep the slug stable and unique by appending the iid.
188
                    $slug = (string) $this->slugger->slug($attendanceTitle.'-'.$attendanceId)->lower();
189
190
                    $resourceNodeId = $this->insertResourceNode(
191
                        title: $attendanceTitle,
192
                        slug: $slug,
193
                        level: 3,
194
                        createdAt: $insertDate,
195
                        updatedAt: $lastEditDate,
196
                        uuid: $uuidValue,
197
                        uuidIsBinary: $uuidIsBinary,
198
                        resourceTypeId: self::RESOURCE_TYPE_ID_ATTENDANCE,
199
                        creatorId: $creatorId,
200
                        parentId: $courseResourceNodeId
201
                    );
202
203
                    $this->connection->insert('resource_link', [
204
                        'visibility' => $visibility,
205
                        'start_visibility_at' => $startVisible,
206
                        'end_visibility_at' => $endVisible,
207
                        'display_order' => $displayOrder,
208
                        'resource_type_group' => self::RESOURCE_TYPE_GROUP_ATTENDANCE,
209
                        'deleted_at' => null,
210
                        'created_at' => $insertDate,
211
                        'updated_at' => $lastEditDate,
212
                        'resource_node_id' => $resourceNodeId,
213
                        'parent_id' => null,
214
                        'c_id' => $courseId,
215
                        'session_id' => $attendanceSessionId,
216
                        'usergroup_id' => null,
217
                        'group_id' => $toGroupId,
218
                        'user_id' => $toUserId,
219
                    ]);
220
221
                    // resource_node.path should be aligned with the course tree: <coursePath>-<nodeId>/
222
                    $newPath = $coursePath.'-'.$resourceNodeId.'/';
223
                    $this->connection->update('resource_node', ['path' => $newPath], ['id' => $resourceNodeId]);
224
225
                    // Mark attendance as migrated by storing the new resource_node_id.
226
                    $this->connection->update('c_attendance', ['resource_node_id' => $resourceNodeId], ['iid' => $attendanceId]);
227
228
                    $displayOrder++;
229
                    $processedAttendances++;
230
                }
231
232
                $this->connection->commit();
233
                $processedCourses++;
234
            } catch (\Throwable $e) {
235
                $this->connection->rollBack();
236
                $io->error("Course {$courseId}: transaction failed - ".$e->getMessage());
237
                return Command::FAILURE;
238
            }
239
        }
240
241
        $io->success("Done. Courses processed: {$processedCourses}. Attendances migrated: {$processedAttendances}.");
242
243
        if ($dropItemProperty) {
244
            $this->maybeDropItemProperty($io);
245
        } else {
246
            if ($this->tableExists('c_item_property')) {
247
                $io->note('c_item_property still exists. You can drop it later or rerun this command with --drop-c-item-property once you confirm no pending attendances remain.');
248
            }
249
        }
250
251
        return Command::SUCCESS;
252
    }
253
254
    private function getCourseIdsToProcess(bool $hasItemProperty, bool $hasAttendanceCId): array
255
    {
256
        if ($hasItemProperty) {
257
            return $this->connection->fetchFirstColumn(
258
                "SELECT DISTINCT c_id
259
                 FROM c_item_property
260
                 WHERE tool = 'attendance'
261
                 ORDER BY c_id"
262
            );
263
        }
264
265
        // Fallback: legacy schema still has c_attendance.c_id
266
        if ($hasAttendanceCId) {
267
            return $this->connection->fetchFirstColumn(
268
                "SELECT DISTINCT c_id
269
                 FROM c_attendance
270
                 WHERE resource_node_id IS NULL
271
                 ORDER BY c_id"
272
            );
273
        }
274
275
        return [];
276
    }
277
278
    private function fetchPendingAttendancesForCourse(
279
        int $courseId,
280
        bool $hasItemProperty,
281
        bool $hasAttendanceCId,
282
        bool $hasAttendanceTitle,
283
        bool $hasAttendanceName,
284
        bool $hasItemPropertySessionId
285
    ): array {
286
        $selectTitle = $hasAttendanceTitle ? 'a.title' : 'NULL AS title';
287
        $selectName = $hasAttendanceName ? 'a.name' : 'NULL AS name';
288
289
        // session_id comes ONLY from c_item_property when available, otherwise NULL.
290
        $selectSession = $hasItemPropertySessionId ? 'ip.session_id' : 'NULL';
291
292
        if ($hasItemProperty) {
293
            return $this->connection->fetchAllAssociative(
294
                "SELECT a.iid, {$selectTitle}, {$selectName}, {$selectSession} AS session_id
295
                 FROM c_attendance a
296
                 INNER JOIN c_item_property ip
297
                    ON ip.tool = 'attendance'
298
                   AND ip.ref = a.iid
299
                   AND ip.c_id = :cid
300
                 WHERE a.resource_node_id IS NULL
301
                 ORDER BY a.iid",
302
                ['cid' => $courseId]
303
            );
304
        }
305
306
        // Fallback using legacy c_id (no c_item_property available).
307
        // At this stage, we cannot infer a session_id, so we store NULL.
308
        if ($hasAttendanceCId) {
309
            return $this->connection->fetchAllAssociative(
310
                "SELECT a.iid, {$selectTitle}, {$selectName}, NULL AS session_id
311
                 FROM c_attendance a
312
                 WHERE a.c_id = :cid AND a.resource_node_id IS NULL
313
                 ORDER BY a.iid",
314
                ['cid' => $courseId]
315
            );
316
        }
317
318
        return [];
319
    }
320
321
    private function pickAttendanceTitle(array $row, int $attendanceId): string
322
    {
323
        $title = isset($row['title']) ? (string) $row['title'] : '';
324
        if ('' !== trim($title)) {
325
            return $title;
326
        }
327
328
        $name = isset($row['name']) ? (string) $row['name'] : '';
329
        if ('' !== trim($name)) {
330
            return $name;
331
        }
332
333
        return 'Attendance '.$attendanceId;
334
    }
335
336
    /**
337
     * Drops legacy table c_item_property.
338
     * Only runs if no pending attendances remain.
339
     */
340
    private function maybeDropItemProperty(SymfonyStyle $io): void
341
    {
342
        if (!$this->tableExists('c_item_property')) {
343
            $io->note('Legacy table "c_item_property" does not exist - nothing to drop.');
344
            return;
345
        }
346
347
        $pending = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM c_attendance WHERE resource_node_id IS NULL');
348
        if ($pending > 0) {
349
            $io->warning("Not dropping legacy table \"c_item_property\": {$pending} attendances are still pending (resource_node_id IS NULL).");
350
            return;
351
        }
352
353
        $io->section('Dropping legacy table "c_item_property"...');
354
355
        try {
356
            // DBAL generates the proper DROP TABLE for the current platform.
357
            $sm = $this->connection->createSchemaManager();
358
            $sm->dropTable('c_item_property');
359
360
            $io->success('Legacy table "c_item_property" dropped.');
361
        } catch (DbalException $e) {
362
            $io->error('Failed to drop legacy table "c_item_property": '.$e->getMessage());
363
            // Optional: throw $e; // if you want to fail hard
364
        }
365
    }
366
367
    private function getFallbackAdminId(): int
368
    {
369
        $id = $this->connection->fetchOne(
370
            "SELECT id FROM user WHERE roles LIKE :role LIMIT 1",
371
            ['role' => '%ROLE_ADMIN%']
372
        );
373
374
        return $id ? (int) $id : 1;
375
    }
376
377
    private function detectUuidIsBinary(): bool
378
    {
379
        try {
380
            $sm = $this->connection->createSchemaManager();
381
            $table = $sm->introspectTable('resource_node');
382
            if (!$table->hasColumn('uuid')) {
383
                return false;
384
            }
385
386
            $col = $table->getColumn('uuid');
387
            $type = $col->getType()->getName();
388
            $len = $col->getLength();
389
390
            return \in_array($type, ['binary', 'varbinary'], true) || 16 === $len;
391
        } catch (\Throwable) {
392
            return false;
393
        }
394
    }
395
396
    private function tableExists(string $tableName): bool
397
    {
398
        try {
399
            $sm = $this->connection->createSchemaManager();
400
            return \in_array($tableName, $sm->listTableNames(), true);
401
        } catch (\Throwable) {
402
            return false;
403
        }
404
    }
405
406
    private function tableHasColumn(string $tableName, string $columnName): bool
407
    {
408
        try {
409
            $sm = $this->connection->createSchemaManager();
410
            $table = $sm->introspectTable($tableName);
411
            return $table->hasColumn($columnName);
412
        } catch (\Throwable) {
413
            return false;
414
        }
415
    }
416
417
    private function nowUtc(): string
418
    {
419
        return gmdate('Y-m-d H:i:s');
420
    }
421
422
    private function insertResourceNode(
423
        string $title,
424
        string $slug,
425
        int $level,
426
        ?string $createdAt,
427
        ?string $updatedAt,
428
        string $uuid,
429
        bool $uuidIsBinary,
430
        int $resourceTypeId,
431
        int $creatorId,
432
        int $parentId
433
    ): int {
434
        $data = [
435
            'title' => $title,
436
            'slug' => $slug,
437
            'level' => $level,
438
            'path' => null,
439
            'created_at' => $createdAt,
440
            'updated_at' => $updatedAt,
441
            'public' => 0,
442
            'uuid' => $uuid,
443
            'resource_type_id' => $resourceTypeId,
444
            'resource_format_id' => null,
445
            'language_id' => null,
446
            'creator_id' => $creatorId,
447
            'parent_id' => $parentId,
448
        ];
449
450
        $types = [];
451
        if ($uuidIsBinary) {
452
            $types['uuid'] = ParameterType::BINARY;
453
        }
454
455
        if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) {
456
            $sql = 'INSERT INTO resource_node (title, slug, level, path, created_at, updated_at, public, uuid, resource_type_id, resource_format_id, language_id, creator_id, parent_id)
457
                    VALUES (:title, :slug, :level, :path, :created_at, :updated_at, :public, :uuid, :resource_type_id, :resource_format_id, :language_id, :creator_id, :parent_id)
458
                    RETURNING id';
459
460
            return (int) $this->connection->fetchOne($sql, $data, $types);
461
        }
462
463
        $this->connection->insert('resource_node', $data, $types);
464
465
        return (int) $this->connection->lastInsertId();
466
    }
467
}
468