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

insertResourceNode()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 44
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 22
c 1
b 0
f 0
nc 4
nop 10
dl 0
loc 44
rs 9.568

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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