Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
PicklistValueReassignHandler
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
6 / 6
14
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supports
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
6
 findReferencingFields
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 reassignInTable
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 deletePicklistValueRecord
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Automation\Queue\Application\Handler;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Automation\Queue\Domain\Model\QueueJob;
12use App\Modules\Automation\Queue\Domain\Repository\QueueRepositoryInterface;
13use DateTimeImmutable;
14use PDO;
15
16/**
17 * Picklist Value Asynchronous Reassignment and Deletion Handler.
18 *
19 * Scans all database modules and fields associated with the picklist,
20 * batch-updates existing records from old value to new value,
21 * and safely deletes the picklist value from catalog.
22 *
23 * @package App\Modules\Automation\Queue\Application\Handler
24 */
25final readonly class PicklistValueReassignHandler implements JobHandlerInterface
26{
27    public const string JOB_TYPE = 'picklist_value_reassign';
28
29    /**
30     * PicklistValueReassignHandler constructor.
31     *
32     * @param PDO $pdo Database connection.
33     * @param string $tablePrefix Table prefix.
34     */
35    public function __construct(
36        private PDO $pdo,
37        private string $tablePrefix = 'a_'
38    ) {
39    }
40
41    /**
42     * {@inheritdoc}
43     */
44    public function supports(): string
45    {
46        return self::JOB_TYPE;
47    }
48
49    /**
50     * {@inheritdoc}
51     */
52    public function handle(QueueJob $job, QueueRepositoryInterface $queueRepository): string
53    {
54        $payload = $job->getPayload();
55        $picklistId = (int)($payload['picklist_id'] ?? 0);
56        $oldValueId = (int)($payload['old_value_id'] ?? 0);
57        $newValueId = isset($payload['new_value_id']) && $payload['new_value_id'] !== null
58            ? (int)$payload['new_value_id']
59            : null;
60        $oldValCode = isset($payload['old_value_code']) ? (string)$payload['old_value_code'] : null;
61        $newValCode = isset($payload['new_value_code']) ? (string)$payload['new_value_code'] : null;
62
63        $targetFields = $this->findReferencingFields($picklistId);
64        $totalFields = count($targetFields);
65        $processedCount = 0;
66        $totalUpdatedRows = 0;
67
68        $now = new DateTimeImmutable();
69        $queueRepository->updateProgress($job->getId(), 0, max(1, $totalFields), $now);
70
71        foreach ($targetFields as $field) {
72            $tableName = (string)$field['table_name'];
73            $fieldKey = (string)$field['field_key'];
74
75            $rows = $this->reassignInTable($tableName, $fieldKey, $oldValueId, $newValueId, $oldValCode, $newValCode);
76            $totalUpdatedRows += $rows;
77            $processedCount++;
78
79            $now = new DateTimeImmutable();
80            $queueRepository->updateProgress($job->getId(), $processedCount, max(1, $totalFields), $now);
81        }
82
83        // Delete the picklist value record permanently
84        $this->deletePicklistValueRecord($oldValueId);
85
86        return sprintf(
87            'Reassigned %d records across %d tables and deleted picklist value #%d.',
88            $totalUpdatedRows,
89            $totalFields,
90            $oldValueId
91        );
92    }
93
94    /**
95     * Finds all module fields configured with given picklist ID.
96     *
97     * @param int $picklistId Picklist identifier.
98     * @return array<int, array{table_name: string, field_key: string}> Referencing fields list.
99     */
100    private function findReferencingFields(int $picklistId): array
101    {
102        $fieldsTable = $this->tablePrefix . 'core_field_records';
103        $modulesTable = $this->tablePrefix . 'core_module_records';
104
105        $sql = "SELECT m.table_name, f.field_key
106                FROM {$fieldsTable} f
107                JOIN {$modulesTable} m ON f.module_id = m.id
108                WHERE f.picklist_id = :picklist_id
109                  AND m.table_name IS NOT NULL";
110
111        $stmt = $this->pdo->prepare($sql);
112        $stmt->bindValue(':picklist_id', $picklistId, PDO::PARAM_INT);
113        $stmt->execute();
114
115        /** @var array<int, array{table_name: string, field_key: string}> $rows */
116        return (array) $stmt->fetchAll(PDO::FETCH_ASSOC);
117    }
118
119    /**
120     * Reassigns picklist value references inside a single table.
121     *
122     * @param string $tableName Physical database table name.
123     * @param string $columnName Column name.
124     * @param int $oldId Old picklist value ID.
125     * @param int|null $newId New picklist value ID.
126     * @param string|null $oldCode Old code fallback.
127     * @param string|null $newCode New code fallback.
128     * @return int Count of modified records.
129     */
130    private function reassignInTable(
131        string $tableName,
132        string $columnName,
133        int $oldId,
134        ?int $newId,
135        ?string $oldCode,
136        ?string $newCode
137    ): int {
138        // Sanitize identifiers
139        $cleanTable = (string) preg_replace('/\W/', '', $tableName);
140        $cleanColumn = (string) preg_replace('/\W/', '', $columnName);
141
142        if ($cleanTable === '' || $cleanColumn === '') {
143            return 0;
144        }
145
146        // Support both numeric INT IDs and string code values during transition
147        $sql = "UPDATE `{$cleanTable}`
148                SET `{$cleanColumn}` = :new_val
149                WHERE `{$cleanColumn}` = :old_id
150                   OR (:old_code IS NOT NULL AND `{$cleanColumn}` = :old_code)";
151
152        $stmt = $this->pdo->prepare($sql);
153        $stmt->bindValue(':new_val', $newId !== null ? (string)$newId : ($newCode ?? ''));
154        $stmt->bindValue(':old_id', (string)$oldId);
155        $stmt->bindValue(':old_code', $oldCode);
156        $stmt->execute();
157
158        return $stmt->rowCount();
159    }
160
161    /**
162     * Permanently deletes the picklist value record.
163     *
164     * @param int $valueId Value ID.
165     * @return void
166     */
167    private function deletePicklistValueRecord(int $valueId): void
168    {
169        $valTable = $this->tablePrefix . 'core_picklist_value_records';
170        $sql = "DELETE FROM {$valTable} WHERE id = :id";
171        $stmt = $this->pdo->prepare($sql);
172        $stmt->bindValue(':id', $valueId, PDO::PARAM_INT);
173        $stmt->execute();
174    }
175}