1<?php 2 3declare(strict_types=1); 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 18namespace TYPO3\CMS\Install\Updates; 19 20use Doctrine\DBAL\Platforms\SQLServerPlatform; 21use TYPO3\CMS\Core\Database\Connection; 22use TYPO3\CMS\Core\Database\ConnectionPool; 23use TYPO3\CMS\Core\Registry; 24use TYPO3\CMS\Core\Utility\GeneralUtility; 25use TYPO3\CMS\Install\Updates\RowUpdater\RowUpdaterInterface; 26use TYPO3\CMS\Install\Updates\RowUpdater\WorkspaceVersionRecordsMigration; 27 28/** 29 * This is a generic updater to migrate content of TCA rows. 30 * 31 * Multiple classes implementing interface "RowUpdateInterface" can be 32 * registered here, each for a specific update purpose. 33 * 34 * The updater fetches each row of all TCA registered tables and 35 * visits the client classes who may modify the row content. 36 * 37 * The updater remembers for each class if it run through, so the updater 38 * will be shown again if a new updater class is registered that has not 39 * been run yet. 40 * 41 * A start position pointer is stored in the registry that is updated during 42 * the run process, so if for instance the PHP process runs into a timeout, 43 * the job can restart at the position it stopped. 44 * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API. 45 */ 46class DatabaseRowsUpdateWizard implements UpgradeWizardInterface, RepeatableInterface 47{ 48 /** 49 * @var array Single classes that may update rows 50 */ 51 protected $rowUpdater = [ 52 WorkspaceVersionRecordsMigration::class, 53 ]; 54 55 /** 56 * @internal 57 * @return string[] 58 */ 59 public function getAvailableRowUpdater(): array 60 { 61 return $this->rowUpdater; 62 } 63 64 /** 65 * @return string Unique identifier of this updater 66 */ 67 public function getIdentifier(): string 68 { 69 return 'databaseRowsUpdateWizard'; 70 } 71 72 /** 73 * @return string Title of this updater 74 */ 75 public function getTitle(): string 76 { 77 return 'Execute database migrations on single rows'; 78 } 79 80 /** 81 * @return string Longer description of this updater 82 * @throws \RuntimeException 83 */ 84 public function getDescription(): string 85 { 86 $rowUpdaterNotExecuted = $this->getRowUpdatersToExecute(); 87 $description = 'Row updaters that have not been executed:'; 88 foreach ($rowUpdaterNotExecuted as $rowUpdateClassName) { 89 $rowUpdater = GeneralUtility::makeInstance($rowUpdateClassName); 90 if (!$rowUpdater instanceof RowUpdaterInterface) { 91 throw new \RuntimeException( 92 'Row updater must implement RowUpdaterInterface', 93 1484066647 94 ); 95 } 96 $description .= LF . $rowUpdater->getTitle(); 97 } 98 return $description; 99 } 100 101 /** 102 * @return bool True if at least one row updater is not marked done 103 */ 104 public function updateNecessary(): bool 105 { 106 return !empty($this->getRowUpdatersToExecute()); 107 } 108 109 /** 110 * @return string[] All new fields and tables must exist 111 */ 112 public function getPrerequisites(): array 113 { 114 return [ 115 DatabaseUpdatedPrerequisite::class 116 ]; 117 } 118 119 /** 120 * Performs the configuration update. 121 * 122 * @return bool 123 * @throws \Doctrine\DBAL\ConnectionException 124 * @throws \Exception 125 */ 126 public function executeUpdate(): bool 127 { 128 $registry = GeneralUtility::makeInstance(Registry::class); 129 130 // If rows from the target table that is updated and the sys_registry table are on the 131 // same connection, the row update statement and sys_registry position update will be 132 // handled in a transaction to have an atomic operation in case of errors during execution. 133 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); 134 $connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry'); 135 136 /** @var RowUpdaterInterface[] $rowUpdaterInstances */ 137 $rowUpdaterInstances = []; 138 // Single row updater instances are created only once for this method giving 139 // them a chance to set up local properties during hasPotentialUpdateForTable() 140 // and using that in updateTableRow() 141 foreach ($this->getRowUpdatersToExecute() as $rowUpdater) { 142 $rowUpdaterInstance = GeneralUtility::makeInstance($rowUpdater); 143 if (!$rowUpdaterInstance instanceof RowUpdaterInterface) { 144 throw new \RuntimeException( 145 'Row updater must implement RowUpdaterInterface', 146 1484071612 147 ); 148 } 149 $rowUpdaterInstances[] = $rowUpdaterInstance; 150 } 151 152 // Scope of the row updater is to update all rows that have TCA, 153 // our list of tables is just the list of loaded TCA tables. 154 /** @var string[] $listOfAllTables */ 155 $listOfAllTables = array_keys($GLOBALS['TCA']); 156 157 // In case the PHP ended for whatever reason, fetch the last position from registry 158 // and throw away all tables before that start point. 159 sort($listOfAllTables); 160 reset($listOfAllTables); 161 $firstTable = current($listOfAllTables); 162 $startPosition = $this->getStartPosition($firstTable); 163 foreach ($listOfAllTables as $key => $table) { 164 if ($table === $startPosition['table']) { 165 break; 166 } 167 unset($listOfAllTables[$key]); 168 } 169 170 // Ask each row updater if it potentially has field updates for rows of a table 171 $tableToUpdaterList = []; 172 foreach ($listOfAllTables as $table) { 173 foreach ($rowUpdaterInstances as $updater) { 174 if ($updater->hasPotentialUpdateForTable($table)) { 175 if (!is_array($tableToUpdaterList[$table])) { 176 $tableToUpdaterList[$table] = []; 177 } 178 $tableToUpdaterList[$table][] = $updater; 179 } 180 } 181 } 182 183 // Iterate through all rows of all tables that have potential row updaters attached, 184 // feed each single row to each updater and finally update each row in database if 185 // a row updater changed a fields 186 foreach ($tableToUpdaterList as $table => $updaters) { 187 /** @var RowUpdaterInterface[] $updaters */ 188 $connectionForTable = $connectionPool->getConnectionForTable($table); 189 $queryBuilder = $connectionPool->getQueryBuilderForTable($table); 190 $queryBuilder->getRestrictions()->removeAll(); 191 $queryBuilder->select('*') 192 ->from($table) 193 ->orderBy('uid'); 194 if ($table === $startPosition['table']) { 195 $queryBuilder->where( 196 $queryBuilder->expr()->gt('uid', $queryBuilder->createNamedParameter($startPosition['uid'])) 197 ); 198 } 199 $statement = $queryBuilder->execute(); 200 $rowCountWithoutUpdate = 0; 201 while ($row = $rowBefore = $statement->fetch()) { 202 foreach ($updaters as $updater) { 203 $row = $updater->updateTableRow($table, $row); 204 } 205 $updatedFields = array_diff_assoc($row, $rowBefore); 206 if (empty($updatedFields)) { 207 // Updaters changed no field of that row 208 $rowCountWithoutUpdate++; 209 if ($rowCountWithoutUpdate >= 200) { 210 // Update startPosition if there were many rows without data change 211 $startPosition = [ 212 'table' => $table, 213 'uid' => $row['uid'], 214 ]; 215 $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition); 216 $rowCountWithoutUpdate = 0; 217 } 218 } else { 219 $rowCountWithoutUpdate = 0; 220 $startPosition = [ 221 'table' => $table, 222 'uid' => $rowBefore['uid'], 223 ]; 224 if ($connectionForSysRegistry === $connectionForTable 225 && !($connectionForSysRegistry->getDatabasePlatform() instanceof SQLServerPlatform) 226 ) { 227 // Target table and sys_registry table are on the same connection and not mssql, use a transaction 228 $connectionForTable->beginTransaction(); 229 try { 230 $this->updateOrDeleteRow( 231 $connectionForTable, 232 $connectionForTable, 233 $table, 234 (int)$rowBefore['uid'], 235 $updatedFields, 236 $startPosition 237 ); 238 $connectionForTable->commit(); 239 } catch (\Exception $up) { 240 $connectionForTable->rollBack(); 241 throw $up; 242 } 243 } else { 244 // Either different connections for table and sys_registry, or mssql. 245 // SqlServer can not run a transaction for a table if the same table is queried 246 // currently - our above ->fetch() main loop. 247 // So, execute two distinct queries and hope for the best. 248 $this->updateOrDeleteRow( 249 $connectionForTable, 250 $connectionForSysRegistry, 251 $table, 252 (int)$rowBefore['uid'], 253 $updatedFields, 254 $startPosition 255 ); 256 } 257 } 258 } 259 } 260 261 // Ready with updates, remove position information from sys_registry 262 $registry->remove('installUpdateRows', 'rowUpdatePosition'); 263 // Mark row updaters that were executed as done 264 foreach ($rowUpdaterInstances as $updater) { 265 $this->setRowUpdaterExecuted($updater); 266 } 267 268 return true; 269 } 270 271 /** 272 * Return an array of class names that are not yet marked as done. 273 * 274 * @return array Class names 275 */ 276 protected function getRowUpdatersToExecute(): array 277 { 278 $doneRowUpdater = GeneralUtility::makeInstance(Registry::class)->get('installUpdateRows', 'rowUpdatersDone', []); 279 return array_diff($this->rowUpdater, $doneRowUpdater); 280 } 281 282 /** 283 * Mark a single updater as done 284 * 285 * @param RowUpdaterInterface $updater 286 */ 287 protected function setRowUpdaterExecuted(RowUpdaterInterface $updater) 288 { 289 $registry = GeneralUtility::makeInstance(Registry::class); 290 $doneRowUpdater = $registry->get('installUpdateRows', 'rowUpdatersDone', []); 291 $doneRowUpdater[] = get_class($updater); 292 $registry->set('installUpdateRows', 'rowUpdatersDone', $doneRowUpdater); 293 } 294 295 /** 296 * Return an array with table / uid combination that specifies the start position the 297 * update row process should start with. 298 * 299 * @param string $firstTable Table name of the first TCA in case the start position needs to be initialized 300 * @return array New start position 301 */ 302 protected function getStartPosition(string $firstTable): array 303 { 304 $registry = GeneralUtility::makeInstance(Registry::class); 305 $startPosition = $registry->get('installUpdateRows', 'rowUpdatePosition', []); 306 if (empty($startPosition)) { 307 $startPosition = [ 308 'table' => $firstTable, 309 'uid' => 0, 310 ]; 311 $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition); 312 } 313 return $startPosition; 314 } 315 316 /** 317 * @param Connection $connectionForTable 318 * @param string $table 319 * @param array $updatedFields 320 * @param int $uid 321 * @param Connection $connectionForSysRegistry 322 * @param array $startPosition 323 */ 324 protected function updateOrDeleteRow(Connection $connectionForTable, Connection $connectionForSysRegistry, string $table, int $uid, array $updatedFields, array $startPosition): void 325 { 326 $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null; 327 if ($deleteField === null && $updatedFields['deleted'] === 1) { 328 $connectionForTable->delete( 329 $table, 330 [ 331 'uid' => $uid, 332 ] 333 ); 334 } else { 335 $connectionForTable->update( 336 $table, 337 $updatedFields, 338 [ 339 'uid' => $uid, 340 ] 341 ); 342 } 343 $connectionForSysRegistry->update( 344 'sys_registry', 345 [ 346 'entry_value' => serialize($startPosition), 347 ], 348 [ 349 'entry_namespace' => 'installUpdateRows', 350 'entry_key' => 'rowUpdatePosition', 351 ], 352 [ 353 // Needs to be declared LOB, so MSSQL can handle the conversion from string (nvarchar) to blob (varbinary) 354 'entry_value' => \PDO::PARAM_LOB, 355 'entry_namespace' => \PDO::PARAM_STR, 356 'entry_key' => \PDO::PARAM_STR, 357 ] 358 ); 359 } 360} 361