rowUpdater; } /** * @return string Title of this updater */ public function getTitle(): string { return 'Execute database migrations on single rows'; } /** * @return string Longer description of this updater * @throws \RuntimeException */ public function getDescription(): string { $rowUpdaterNotExecuted = $this->getRowUpdatersToExecute(); $description = 'Row updaters that have not been executed:'; foreach ($rowUpdaterNotExecuted as $rowUpdateClassName) { $rowUpdater = GeneralUtility::makeInstance($rowUpdateClassName); if (!$rowUpdater instanceof RowUpdaterInterface) { throw new \RuntimeException( 'Row updater must implement RowUpdaterInterface', 1484066647 ); } $description .= LF . $rowUpdater->getTitle(); } return $description; } /** * @return bool True if at least one row updater is not marked done */ public function updateNecessary(): bool { return !empty($this->getRowUpdatersToExecute()); } /** * @return string[] All new fields and tables must exist */ public function getPrerequisites(): array { return [ DatabaseUpdatedPrerequisite::class, ]; } /** * Performs the configuration update. * * @throws \Doctrine\DBAL\ConnectionException * @throws \Exception */ public function executeUpdate(): bool { $registry = GeneralUtility::makeInstance(Registry::class); // If rows from the target table that is updated and the sys_registry table are on the // same connection, the row update statement and sys_registry position update will be // handled in a transaction to have an atomic operation in case of errors during execution. $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); $connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry'); /** @var RowUpdaterInterface[] $rowUpdaterInstances */ $rowUpdaterInstances = []; // Single row updater instances are created only once for this method giving // them a chance to set up local properties during hasPotentialUpdateForTable() // and using that in updateTableRow() foreach ($this->getRowUpdatersToExecute() as $rowUpdater) { $rowUpdaterInstance = GeneralUtility::makeInstance($rowUpdater); if (!$rowUpdaterInstance instanceof RowUpdaterInterface) { throw new \RuntimeException( 'Row updater must implement RowUpdaterInterface', 1484071612 ); } $rowUpdaterInstances[] = $rowUpdaterInstance; } // Scope of the row updater is to update all rows that have TCA, // our list of tables is just the list of loaded TCA tables. /** @var string[] $listOfAllTables */ $listOfAllTables = array_keys($GLOBALS['TCA']); // In case the PHP ended for whatever reason, fetch the last position from registry // and throw away all tables before that start point. sort($listOfAllTables); reset($listOfAllTables); $firstTable = current($listOfAllTables) ?: ''; $startPosition = $this->getStartPosition($firstTable); foreach ($listOfAllTables as $key => $table) { if ($table === $startPosition['table']) { break; } unset($listOfAllTables[$key]); } // Ask each row updater if it potentially has field updates for rows of a table $tableToUpdaterList = []; foreach ($listOfAllTables as $table) { foreach ($rowUpdaterInstances as $updater) { if ($updater->hasPotentialUpdateForTable($table)) { $tableToUpdaterList[$table] ??= []; $tableToUpdaterList[$table][] = $updater; } } } // Iterate through all rows of all tables that have potential row updaters attached, // feed each single row to each updater and finally update each row in database if // a row updater changed a fields foreach ($tableToUpdaterList as $table => $updaters) { /** @var RowUpdaterInterface[] $updaters */ $connectionForTable = $connectionPool->getConnectionForTable($table); $queryBuilder = $connectionPool->getQueryBuilderForTable($table); $queryBuilder->getRestrictions()->removeAll(); $queryBuilder->select('*') ->from($table) ->orderBy('uid'); if ($table === $startPosition['table']) { $queryBuilder->where( $queryBuilder->expr()->gt('uid', $queryBuilder->createNamedParameter($startPosition['uid'])) ); } $statement = $queryBuilder->executeQuery(); $rowCountWithoutUpdate = 0; while ($row = $statement->fetchAssociative()) { $rowBefore = $row; foreach ($updaters as $updater) { $row = $updater->updateTableRow($table, $row); } $updatedFields = array_diff_assoc($row, $rowBefore); if (empty($updatedFields)) { // Updaters changed no field of that row $rowCountWithoutUpdate++; if ($rowCountWithoutUpdate >= 200) { // Update startPosition if there were many rows without data change $startPosition = [ 'table' => $table, 'uid' => $row['uid'], ]; $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition); $rowCountWithoutUpdate = 0; } } else { $rowCountWithoutUpdate = 0; $startPosition = [ 'table' => $table, 'uid' => $rowBefore['uid'], ]; if ($connectionForSysRegistry === $connectionForTable) { // Target table and sys_registry table are on the same connection, use a transaction $connectionForTable->beginTransaction(); try { $this->updateOrDeleteRow( $connectionForTable, $connectionForTable, $table, (int)$rowBefore['uid'], $updatedFields, $startPosition ); $connectionForTable->commit(); } catch (\Exception $up) { $connectionForTable->rollBack(); throw $up; } } else { // Different connections for table and sys_registry. // So, execute two distinct queries and hope for the best. $this->updateOrDeleteRow( $connectionForTable, $connectionForSysRegistry, $table, (int)$rowBefore['uid'], $updatedFields, $startPosition ); } } } } // Ready with updates, remove position information from sys_registry $registry->remove('installUpdateRows', 'rowUpdatePosition'); // Mark row updaters that were executed as done foreach ($rowUpdaterInstances as $updater) { $this->setRowUpdaterExecuted($updater); } return true; } /** * Return an array of class names that are not yet marked as done. * * @return array Class names */ protected function getRowUpdatersToExecute(): array { $doneRowUpdater = GeneralUtility::makeInstance(Registry::class)->get('installUpdateRows', 'rowUpdatersDone', []); return array_diff($this->rowUpdater, $doneRowUpdater); } /** * Mark a single updater as done */ protected function setRowUpdaterExecuted(RowUpdaterInterface $updater) { $registry = GeneralUtility::makeInstance(Registry::class); $doneRowUpdater = $registry->get('installUpdateRows', 'rowUpdatersDone', []); $doneRowUpdater[] = get_class($updater); $registry->set('installUpdateRows', 'rowUpdatersDone', $doneRowUpdater); } /** * Return an array with table / uid combination that specifies the start position the * update row process should start with. * * @param string $firstTable Table name of the first TCA in case the start position needs to be initialized * @return array New start position */ protected function getStartPosition(string $firstTable): array { $registry = GeneralUtility::makeInstance(Registry::class); $startPosition = $registry->get('installUpdateRows', 'rowUpdatePosition', []); if (empty($startPosition)) { $startPosition = [ 'table' => $firstTable, 'uid' => 0, ]; $registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition); } return $startPosition; } protected function updateOrDeleteRow(Connection $connectionForTable, Connection $connectionForSysRegistry, string $table, int $uid, array $updatedFields, array $startPosition): void { $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null; if ($deleteField === null && isset($updatedFields['deleted']) && $updatedFields['deleted'] === 1) { $connectionForTable->delete( $table, [ 'uid' => $uid, ] ); } else { $connectionForTable->update( $table, $updatedFields, [ 'uid' => $uid, ] ); } $connectionForSysRegistry->update( 'sys_registry', [ 'entry_value' => serialize($startPosition), ], [ 'entry_namespace' => 'installUpdateRows', 'entry_key' => 'rowUpdatePosition', ], [ // Needs to be declared LOB, so MSSQL can handle the conversion from string (nvarchar) to blob (varbinary) 'entry_value' => Connection::PARAM_LOB, 'entry_namespace' => Connection::PARAM_STR, 'entry_key' => Connection::PARAM_STR, ] ); } }