Completed
Push — master ( be494a...1075c0 )
by
unknown
14:26
created

SeparateSysHistoryFromSysLogUpdate::getTitle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types = 1);
3
namespace TYPO3\CMS\Install\Updates;
4
5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17
18
use TYPO3\CMS\Core\Database\Connection;
19
use TYPO3\CMS\Core\Database\ConnectionPool;
20
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
21
use TYPO3\CMS\Core\Registry;
22
use TYPO3\CMS\Core\Utility\GeneralUtility;
23
24
/**
25
 * Merge data stored in sys_log that belongs to sys_history
26
 * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
27
 */
28
class SeparateSysHistoryFromSysLogUpdate implements UpgradeWizardInterface, RepeatableInterface
29
{
30
31
    /** @var int Number of records to process in a single query to reduce memory footprint */
32
    private const BATCH_SIZE = 100;
33
34
    /** @var int Phase that copies data from sys_log to sys_history */
35
    private const MOVE_DATA = 0;
36
37
    /** @var int Phase that adds history records for inserts and deletes */
38
    private const UPDATE_HISTORY = 1;
39
40
    /**
41
     * @return string Unique identifier of this updater
42
     */
43
    public function getIdentifier(): string
44
    {
45
        return 'separateSysHistoryFromLog';
46
    }
47
48
    /**
49
     * @return string Title of this updater
50
     */
51
    public function getTitle(): string
52
    {
53
        return 'Migrates existing sys_log entries into sys_history';
54
    }
55
56
    /**
57
     * @return string Longer description of this updater
58
     */
59
    public function getDescription(): string
60
    {
61
        return 'The history of changes of a record is now solely stored within sys_history.'
62
            . ' Previous data within sys_log needs to be migrated into sys_history now.';
63
    }
64
65
    /**
66
     * Checks if an update is needed
67
     *
68
     * @return bool Whether an update is needed (true) or not (false)
69
     */
70
    public function updateNecessary(): bool
71
    {
72
        // sys_log field has been removed, no need to do something.
73
        if (!$this->checkIfFieldInTableExists('sys_history', 'sys_log_uid')) {
74
            return false;
75
        }
76
77
        // Check if there is data to migrate
78
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
79
            ->getQueryBuilderForTable('sys_history');
80
        $queryBuilder->getRestrictions()->removeAll();
81
        $count = $queryBuilder->count('*')
82
            ->from('sys_history')
83
            ->where($queryBuilder->expr()->neq('sys_log_uid', 0))
84
            ->execute()
85
            ->fetchColumn(0);
86
87
        return $count > 0;
88
    }
89
90
    /**
91
     * @return string[] All new fields and tables must exist
92
     */
93
    public function getPrerequisites(): array
94
    {
95
        return [
96
            DatabaseUpdatedPrerequisite::class
97
        ];
98
    }
99
100
    /**
101
     * Moves data from sys_log into sys_history
102
     * where a reference is still there: sys_history.sys_log_uid > 0
103
     *
104
     * @return bool
105
     * @throws \Doctrine\DBAL\ConnectionException
106
     * @throws \Exception
107
     */
108
    public function executeUpdate(): bool
109
    {
110
        // If rows from the target table that is updated and the sys_registry table are on the
111
        // same connection, the update statement and sys_registry position update will be
112
        // handled in a transaction to have an atomic operation in case of errors during execution.
113
        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
114
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_history');
115
        $connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry');
116
117
        // In case the PHP ended for whatever reason, fetch the last position from registry
118
        // and only execute the phase(s) that has/have not been executed yet
119
        $startPositionAndPhase = $this->getStartPositionAndPhase();
120
121
        if ($startPositionAndPhase['phase'] === self::MOVE_DATA) {
122
            $startPositionAndPhase = $this->moveDataFromSysLogToSysHistory(
123
                $connection,
124
                $connectionForSysRegistry,
125
                $startPositionAndPhase
126
            );
127
        }
128
129
        if ($startPositionAndPhase['phase'] === self::UPDATE_HISTORY) {
130
            $this->keepHistoryForInsertAndDeleteActions(
131
                $connectionForSysRegistry,
132
                $startPositionAndPhase
133
            );
134
        }
135
136
        return true;
137
    }
138
139
    /**
140
     * @param \TYPO3\CMS\Core\Database\Connection $connection
141
     * @param \TYPO3\CMS\Core\Database\Connection $connectionForSysRegistry
142
     * @param array $startPositionAndPhase
143
     * @return array
144
     * @throws \Doctrine\DBAL\ConnectionException
145
     * @throws \Exception
146
     */
147
    protected function moveDataFromSysLogToSysHistory(
148
        Connection $connection,
149
        Connection $connectionForSysRegistry,
150
        array $startPositionAndPhase
151
    ): array {
152
        do {
153
            $processedRows = 0;
154
155
            // update "modify" statements (= decoupling)
156
            $queryBuilder = $connection->createQueryBuilder();
157
            $rows = $queryBuilder->select('sys_history.uid AS history_uid', 'sys_history.history_data', 'sys_log.*')
158
                ->from('sys_history')
159
                ->leftJoin(
160
                    'sys_history',
161
                    'sys_log',
162
                    'sys_log',
163
                    $queryBuilder->expr()->eq('sys_history.sys_log_uid', $queryBuilder->quoteIdentifier('sys_log.uid'))
164
                )
165
                ->where($queryBuilder->expr()->gt('sys_history.uid', $queryBuilder->createNamedParameter($startPositionAndPhase['uid'])))
166
                ->setMaxResults(self::BATCH_SIZE)
167
                ->orderBy('sys_history.uid', 'ASC')
168
                ->execute()
169
                ->fetchAll();
170
171
            foreach ($rows as $row) {
172
                $logData = $row['log_data'] !== null ? unserialize($row['log_data'], ['allowed_classes' => false]) : [];
173
                $updateData = [
174
                    'actiontype' => RecordHistoryStore::ACTION_MODIFY,
175
                    'usertype' => 'BE',
176
                    'userid' => $row['userid'],
177
                    'sys_log_uid' => 0,
178
                    'history_data' => json_encode(
179
                        $row['history_data'] !== null
180
                            ? unserialize($row['history_data'], ['allowed_classes' => false])
0 ignored issues
show
Coding Style introduced by
Expected 1 space before "?"; newline found
Loading history...
181
                            : []
0 ignored issues
show
Coding Style introduced by
Expected 1 space before ":"; newline found
Loading history...
182
                    ),
183
                    'originaluserid' => empty($logData['originalUser']) ? null : $logData['originalUser']
184
                ];
185
186
                if ($connection === $connectionForSysRegistry) {
187
                    // sys_history and sys_registry tables are on the same connection, use a transaction
188
                    $connection->beginTransaction();
189
                    try {
190
                        $startPositionAndPhase = $this->updateTablesAndTrackProgress(
191
                            $connection,
192
                            $connection,
193
                            $updateData,
194
                            $logData,
195
                            $row
196
                        );
197
                        $connection->commit();
198
                    } catch (\Exception $up) {
199
                        $connection->rollBack();
200
                        throw ($up);
201
                    }
202
                } else {
203
                    // Different connections for sys_history and sys_registry -> execute two
204
                    // distinct queries and hope for the best.
205
                    $startPositionAndPhase = $this->updateTablesAndTrackProgress(
206
                        $connection,
207
                        $connectionForSysRegistry,
208
                        $updateData,
209
                        $logData,
210
                        $row
211
                    );
212
                }
213
214
                $processedRows++;
215
            }
216
            // repeat until a resultset smaller than the batch size was processed
217
        } while ($processedRows === self::BATCH_SIZE);
218
219
        // phase 0 is finished
220
        $registry = GeneralUtility::makeInstance(Registry::class);
221
        $startPositionAndPhase = [
222
            'phase' => self::UPDATE_HISTORY,
223
            'uid' => 0,
224
        ];
225
        $registry->set('installSeparateHistoryFromSysLog', 'phaseAndPosition', $startPositionAndPhase);
226
227
        return $startPositionAndPhase;
228
    }
229
230
    /**
231
     * Update sys_history and sys_log tables
232
     *
233
     * Also keep track of progress in sys_registry
234
     *
235
     * @param \TYPO3\CMS\Core\Database\Connection $connection
236
     * @param \TYPO3\CMS\Core\Database\Connection $connectionForSysRegistry
237
     * @param array $updateData
238
     * @param array $logData
239
     * @param array $row
240
     * @return array
241
     */
242
    protected function updateTablesAndTrackProgress(
243
        Connection $connection,
244
        Connection $connectionForSysRegistry,
245
        array $updateData,
246
        array $logData,
247
        array $row
248
    ): array {
249
        $connection->update(
250
            'sys_history',
251
            $updateData,
252
            ['uid' => (int)$row['history_uid']],
253
            ['uid' => Connection::PARAM_INT]
254
        );
255
256
        // Store information about history entry in sys_log table
257
        $logData['history'] = $row['history_uid'];
258
        $connection->update(
259
            'sys_log',
260
            ['log_data' => serialize($logData)],
261
            ['uid' => (int)$row['uid']],
262
            ['uid' => Connection::PARAM_INT]
263
        );
264
        $startPositionAndPhase = [
265
            'phase' => self::MOVE_DATA,
266
            'uid' => $row['history_uid'],
267
        ];
268
        $connectionForSysRegistry->update(
269
            'sys_registry',
270
            [
271
                'entry_value' => serialize($startPositionAndPhase)
272
            ],
273
            [
274
                'entry_namespace' => 'installSeparateHistoryFromSysLog',
275
                'entry_key' => 'phaseAndPosition',
276
            ]
277
        );
278
279
        return $startPositionAndPhase;
280
    }
281
282
    /**
283
     * Add Insert and Delete actions from sys_log to sys_history
284
     *
285
     * @param \TYPO3\CMS\Core\Database\Connection $connectionForSysRegistry
286
     * @param array $startPositionAndPhase
287
     */
288
    protected function keepHistoryForInsertAndDeleteActions(
289
        Connection $connectionForSysRegistry,
290
        array $startPositionAndPhase
291
    ) {
292
        do {
293
            $processedRows = 0;
294
295
            // Add insert/delete calls
296
            $logQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
297
            $result = $logQueryBuilder->select('uid', 'userid', 'action', 'tstamp', 'log_data', 'tablename', 'recuid')
298
                ->from('sys_log')
299
                ->where(
300
                    $logQueryBuilder->expr()->eq('type', $logQueryBuilder->createNamedParameter(1, \PDO::PARAM_INT)),
301
                    $logQueryBuilder->expr()->orX(
302
                        $logQueryBuilder->expr()->eq('action', $logQueryBuilder->createNamedParameter(1, \PDO::PARAM_INT)),
303
                        $logQueryBuilder->expr()->eq('action', $logQueryBuilder->createNamedParameter(3, \PDO::PARAM_INT))
304
                    )
305
                )
306
                ->andWhere(
307
                    $logQueryBuilder->expr()->gt('uid', $logQueryBuilder->createNamedParameter($startPositionAndPhase['uid']))
308
                )
309
                ->orderBy('uid', 'ASC')
310
                ->setMaxResults(self::BATCH_SIZE)
311
                ->execute();
312
313
            foreach ($result as $row) {
314
                $logData = (array)unserialize($row['log_data'], ['allowed_classes' => false]);
315
316
                $store = GeneralUtility::makeInstance(
317
                    RecordHistoryStore::class,
318
                    RecordHistoryStore::USER_BACKEND,
0 ignored issues
show
Bug introduced by
TYPO3\CMS\Core\DataHandl...toryStore::USER_BACKEND of type string is incompatible with the type array|array<mixed,mixed> expected by parameter $constructorArguments of TYPO3\CMS\Core\Utility\G...Utility::makeInstance(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

318
                    /** @scrutinizer ignore-type */ RecordHistoryStore::USER_BACKEND,
Loading history...
319
                    $row['userid'],
320
                    (empty($logData['originalUser']) ? null : $logData['originalUser']),
321
                    $row['tstamp']
322
                );
323
324
                switch ($row['action']) {
325
                    // Insert
326
                    case 1:
327
                        $store->addRecord($row['tablename'], (int)$row['recuid'], $logData);
328
                        break;
329
                    // Delete
330
                    case 3:
331
                        $store->deleteRecord($row['tablename'], (int)$row['recuid']);
332
                        break;
333
                }
334
335
                $startPositionAndPhase = [
336
                    'phase' => self::UPDATE_HISTORY,
337
                    'uid' => $row['uid'],
338
                ];
339
                $connectionForSysRegistry->update(
340
                    'sys_registry',
341
                    [
342
                        'entry_value' => serialize($startPositionAndPhase)
343
                    ],
344
                    [
345
                        'entry_namespace' => 'installSeparateHistoryFromSysLog',
346
                        'entry_key' => 'phaseAndPosition',
347
                    ]
348
                );
349
350
                $processedRows++;
351
            }
352
            // repeat until a result set smaller than the batch size was processed
353
        } while ($processedRows === self::BATCH_SIZE);
354
    }
355
356
    /**
357
     * Checks if given field /column in a table exists
358
     *
359
     * @param string $table
360
     * @param string $fieldName
361
     * @return bool
362
     */
363
    protected function checkIfFieldInTableExists($table, $fieldName): bool
364
    {
365
        $tableColumns = GeneralUtility::makeInstance(ConnectionPool::class)
366
            ->getConnectionForTable($table)
367
            ->getSchemaManager()
368
            ->listTableColumns($table);
369
        return isset($tableColumns[$fieldName]);
370
    }
371
372
    /**
373
     * Returns an array with phase / uid combination that specifies the start position the
374
     * update process should start with.
375
     *
376
     * @return array New start position
377
     */
378
    protected function getStartPositionAndPhase(): array
379
    {
380
        $registry = GeneralUtility::makeInstance(Registry::class);
381
        $startPosition = $registry->get('installSeparateHistoryFromSysLog', 'phaseAndPosition', []);
382
        if (empty($startPosition)) {
383
            $startPosition = [
384
                'phase' => self::MOVE_DATA,
385
                'uid' => 0,
386
            ];
387
            $registry->set('installSeparateHistoryFromSysLog', 'phaseAndPosition', $startPosition);
388
        }
389
390
        return $startPosition;
391
    }
392
}
393