Merge branch '5.3'
authorTim Düsterhus <duesterhus@woltlab.com>
Fri, 29 Jan 2021 15:24:32 +0000 (16:24 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Fri, 29 Jan 2021 15:24:32 +0000 (16:24 +0100)
1  2 
wcfsetup/install/files/lib/system/database/table/DatabaseTableChangeProcessor.class.php

index bd908d44c93d164f74700a1d24e478873dee8262,87fc5051e438ac2f29cbda716c656f3746517aad..6df14452b969928ccb692fea3feb0e83c2241413
@@@ -15,1263 -13,1172 +15,1263 @@@ use wcf\system\WCF
  
  /**
   * Processes a given set of changes to database tables.
 - * 
 - * @author    Matthias Schmidt
 - * @copyright 2001-2020 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\System\Database\Table
 - * @since     5.2
 + *
 + * @author  Matthias Schmidt
 + * @copyright   2001-2020 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\System\Database\Table
 + * @since   5.2
   */
 -class DatabaseTableChangeProcessor {
 -      /**
 -       * maps the registered database table column names to the ids of the packages they belong to
 -       * @var int[][]
 -       */
 -      protected $columnPackageIDs = [];
 -      
 -      /**
 -       * database table columns that will be added grouped by the name of the table to which they
 -       * will be added
 -       * @var IDatabaseTableColumn[][]
 -       */
 -      protected $columnsToAdd = [];
 -      
 -      /**
 -       * database table columns that will be altered grouped by the name of the table to which
 -       * they belong
 -       * @var IDatabaseTableColumn[][]
 -       */
 -      protected $columnsToAlter = [];
 -      
 -      /**
 -       * database table columns that will be dropped grouped by the name of the table from which
 -       * they will be dropped
 -       * @var IDatabaseTableColumn[][]
 -       */
 -      protected $columnsToDrop = [];
 -      
 -      /**
 -       * database editor to apply the relevant changes to the table layouts
 -       * @var DatabaseEditor
 -       */
 -      protected $dbEditor;
 -
 -      /**
 -       * list of all existing tables in the used database
 -       * @var string[]
 -       */
 -      protected $existingTableNames = [];
 -      
 -      /**
 -       * existing database tables
 -       * @var DatabaseTable[]
 -       */
 -      protected $existingTables = [];
 -      
 -      /**
 -       * maps the registered database table index names to the ids of the packages they belong to
 -       * @var int[][]
 -       */
 -      protected $indexPackageIDs = [];
 -      
 -      /**
 -       * indices that will be added grouped by the name of the table to which they will be added
 -       * @var DatabaseTableIndex[][] 
 -       */
 -      protected $indicesToAdd = [];
 -      
 -      /**
 -       * indices that will be dropped grouped by the name of the table from which they will be dropped
 -       * @var DatabaseTableIndex[][]
 -       */
 -      protected $indicesToDrop = [];
 -      
 -      /**
 -       * maps the registered database table foreign key names to the ids of the packages they belong to
 -       * @var int[][]
 -       */
 -      protected $foreignKeyPackageIDs = [];
 -      
 -      /**
 -       * foreign keys that will be added grouped by the name of the table to which they will be
 -       * added
 -       * @var DatabaseTableForeignKey[][]
 -       */
 -      protected $foreignKeysToAdd = [];
 -      
 -      /**
 -       * foreign keys that will be dropped grouped by the name of the table from which they will
 -       * be dropped
 -       * @var DatabaseTableForeignKey[][]
 -       */
 -      protected $foreignKeysToDrop = [];
 -      
 -      /**
 -       * package that wants to apply the changes
 -       * @var Package
 -       */
 -      protected $package;
 -      
 -      /**
 -       * message for the split node exception thrown after the changes have been applied
 -       * @var string
 -       */
 -      protected $splitNodeMessage = '';
 -      
 -      /**
 -       * layouts/layout changes of the relevant database table
 -       * @var DatabaseTable[]
 -       */
 -      protected $tables;
 -      
 -      /**
 -       * maps the registered database table names to the ids of the packages they belong to
 -       * @var int[]
 -       */
 -      protected $tablePackageIDs = [];
 -      
 -      /**
 -       * database table that will be created
 -       * @var DatabaseTable[]
 -       */
 -      protected $tablesToCreate = [];
 -      
 -      /**
 -       * database tables that will be dropped
 -       * @var DatabaseTable[]
 -       */
 -      protected $tablesToDrop = [];
 -      
 -      /**
 -       * Creates a new instance of `DatabaseTableChangeProcessor`.
 -       * 
 -       * @param       Package                 $package
 -       * @param       DatabaseTable[]         $tables
 -       * @param       DatabaseEditor          $dbEditor
 -       */
 -      public function __construct(Package $package, array $tables, DatabaseEditor $dbEditor) {
 -              $this->package = $package;
 -              
 -              $tableNames = [];
 -              foreach ($tables as $table) {
 -                      if (!($table instanceof DatabaseTable)) {
 -                              throw new \InvalidArgumentException("Tables must be instance of '" . DatabaseTable::class . "'");
 -                      }
 -                      
 -                      $tableNames[] = $table->getName();
 -              }
 -              
 -              $this->tables = $tables;
 -              $this->dbEditor = $dbEditor;
 -              
 -              $this->existingTableNames = $dbEditor->getTableNames();
 -              
 -              $conditionBuilder = new PreparedStatementConditionBuilder();
 -              $conditionBuilder->add('sqlTable IN (?)', [$tableNames]);
 -              $conditionBuilder->add('isDone = ?', [1]);
 -              
 -              $sql = "SELECT  *
 -                      FROM    wcf" . WCF_N . "_package_installation_sql_log
 -                      " . $conditionBuilder;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute($conditionBuilder->getParameters());
 -              
 -              while ($row = $statement->fetchArray()) {
 -                      if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 -                              $this->tablePackageIDs[$row['sqlTable']] = $row['packageID'];
 -                      }
 -                      else if ($row['sqlIndex'] === '') {
 -                              $this->columnPackageIDs[$row['sqlTable']][$row['sqlColumn']] = $row['packageID'];
 -                      }
 -                      else if (substr($row['sqlIndex'], -3) === '_fk') {
 -                              $this->foreignKeyPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 -                      }
 -                      else {
 -                              $this->indexPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Adds the given index to the table.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function addForeignKey($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->dbEditor->addForeignKey($tableName, $foreignKey->getName(), $foreignKey->getData());
 -      }
 -      
 -      /**
 -       * Adds the given index to the table.
 -       *
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function addIndex($tableName, DatabaseTableIndex $index) {
 -              $this->dbEditor->addIndex($tableName, $index->getName(), $index->getData());
 -      }
 -      
 -      /**
 -       * Applies all of the previously determined changes to achieve the desired database layout.
 -       * 
 -       * @throws      SplitNodeException      if any change has been applied
 -       */
 -      protected function applyChanges() {
 -              $appliedAnyChange = false;
 -              
 -              foreach ($this->tablesToCreate as $table) {
 -                      $appliedAnyChange = true;
 -                      
 -                      $this->prepareTableLog($table);
 -                      $this->createTable($table);
 -                      $this->finalizeTableLog($table);
 -              }
 -              
 -              foreach ($this->tablesToDrop as $table) {
 -                      $appliedAnyChange = true;
 -                      
 -                      $this->dropTable($table);
 -                      $this->deleteTableLog($table);
 -              }
 -              
 -              $columnTables = array_unique(array_merge(
 -                      array_keys($this->columnsToAdd),
 -                      array_keys($this->columnsToAlter),
 -                      array_keys($this->columnsToDrop)
 -              ));
 -              foreach ($columnTables as $tableName) {
 -                      $appliedAnyChange = true;
 -                      
 -                      $columnsToAdd = $this->columnsToAdd[$tableName] ?? [];
 -                      $columnsToAlter = $this->columnsToAlter[$tableName] ?? [];
 -                      $columnsToDrop = $this->columnsToDrop[$tableName] ?? [];
 -                      
 -                      foreach ($columnsToAdd as $column) {
 -                              $this->prepareColumnLog($tableName, $column);
 -                      }
 -                      
 -                      $this->applyColumnChanges(
 -                              $tableName,
 -                              $columnsToAdd,
 -                              $columnsToAlter,
 -                              $columnsToDrop
 -                      );
 -                      
 -                      foreach ($columnsToAdd as $column) {
 -                              $this->finalizeColumnLog($tableName, $column);
 -                      }
 -                      
 -                      foreach ($columnsToDrop as $column) {
 -                              $this->deleteColumnLog($tableName, $column);
 -                      }
 -              }
 -              
 -              foreach ($this->foreignKeysToDrop as $tableName => $foreignKeys) {
 -                      foreach ($foreignKeys as $foreignKey) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->dropForeignKey($tableName, $foreignKey);
 -                              $this->deleteForeignKeyLog($tableName, $foreignKey);
 -                      }
 -              }
 -              
 -              foreach ($this->foreignKeysToAdd as $tableName => $foreignKeys) {
 -                      foreach ($foreignKeys as $foreignKey) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->prepareForeignKeyLog($tableName, $foreignKey);
 -                              $this->addForeignKey($tableName, $foreignKey);
 -                              $this->finalizeForeignKeyLog($tableName, $foreignKey);
 -                      }
 -              }
 -              
 -              foreach ($this->indicesToDrop as $tableName => $indices) {
 -                      foreach ($indices as $index) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->dropIndex($tableName, $index);
 -                              $this->deleteIndexLog($tableName, $index);
 -                      }
 -              }
 -              
 -              foreach ($this->indicesToAdd as $tableName => $indices) {
 -                      foreach ($indices as $index) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->prepareIndexLog($tableName, $index);
 -                              $this->addIndex($tableName, $index);
 -                              $this->finalizeIndexLog($tableName, $index);
 -                      }
 -              }
 -              
 -              if ($appliedAnyChange) {
 -                      throw new SplitNodeException($this->splitNodeMessage);
 -              }
 -      }
 -      
 -      /**
 -       * Adds, alters, and drop columns of the same table.
 -       * 
 -       * Before a column is dropped, all of its foreign keys are dropped.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       IDatabaseTableColumn[]  $addedColumns
 -       * @param       IDatabaseTableColumn[]  $alteredColumns
 -       * @param       IDatabaseTableColumn[]  $droppedColumns
 -       */
 -      protected function applyColumnChanges($tableName, array $addedColumns, array $alteredColumns, array $droppedColumns) {
 -              $dropForeignKeys = [];
 -              
 -              $columnData = [];
 -              foreach ($droppedColumns as $droppedColumn) {
 -                      $columnData[$droppedColumn->getName()] = [
 -                              'action' => 'drop'
 -                      ];
 -                      
 -                      foreach ($this->getExistingTable($tableName)->getForeignKeys() as $foreignKey) {
 -                              if (in_array($droppedColumn->getName(), $foreignKey->getColumns())) {
 -                                      $dropForeignKeys[] = $foreignKey;
 -                              }
 -                      }
 -              }
 -              foreach ($addedColumns as $addedColumn) {
 -                      $columnData[$addedColumn->getName()] = [
 -                              'action' => 'add',
 -                              'data' => $addedColumn->getData()
 -                      ];
 -              }
 -              foreach ($alteredColumns as $alteredColumn) {
 -                      $columnData[$alteredColumn->getName()] = [
 -                              'action' => 'alter',
 -                              'data' => $alteredColumn->getData(),
 -                              'oldColumnName' => $alteredColumn->getName()
 -                      ];
 -              }
 -              
 -              if (!empty($columnData)) {
 -                      foreach ($dropForeignKeys as $foreignKey) {
 -                              $this->dropForeignKey($tableName, $foreignKey);
 -                              $this->deleteForeignKeyLog($tableName, $foreignKey);
 -                      }
 -                      
 -                      $this->dbEditor->alterColumns($tableName, $columnData);
 -              }
 -      }
 -      
 -      /**
 -       * Calculates all of the necessary changes to be executed.
 -       */
 -      protected function calculateChanges() {
 -              foreach ($this->tables as $table) {
 -                      $tableName = $table->getName();
 -                      
 -                      if ($table->willBeDropped()) {
 -                              if (in_array($tableName, $this->existingTableNames)) {
 -                                      $this->tablesToDrop[] = $table;
 -                                      
 -                                      $this->splitNodeMessage .= "Dropped table '{$tableName}'.";
 -                                      break;
 -                              }
 -                              else if (isset($this->tablePackageIDs[$tableName])) {
 -                                      $this->deleteTableLog($table);
 -                              }
 -                      }
 -                      else if (!in_array($tableName, $this->existingTableNames)) {
 -                              if ($table instanceof PartialDatabaseTable) {
 -                                      throw new \LogicException("Partial table '{$tableName}' cannot be created.");
 -                              }
 -                              
 -                              $this->tablesToCreate[] = $table;
 -                              
 -                              $this->splitNodeMessage .= "Created table '{$tableName}'.";
 -                              break;
 -                      }
 -                      else {
 -                              // calculate difference between tables
 -                              $existingTable = $this->getExistingTable($tableName);
 -                              $existingColumns = $existingTable->getColumns();
 -                              
 -                              foreach ($table->getColumns() as $column) {
 -                                      if ($column->willBeDropped()) {
 -                                              if (isset($existingColumns[$column->getName()])) {
 -                                                      if (!isset($this->columnsToDrop[$tableName])) {
 -                                                              $this->columnsToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->columnsToDrop[$tableName][] = $column;
 -                                              }
 -                                              else if (isset($this->columnPackageIDs[$tableName][$column->getName()])) {
 -                                                      $this->deleteColumnLog($tableName, $column);
 -                                              }
 -                                      }
 -                                      else if (!isset($existingColumns[$column->getName()])) {
 -                                              if (!isset($this->columnsToAdd[$tableName])) {
 -                                                      $this->columnsToAdd[$tableName] = [];
 -                                              }
 -                                              $this->columnsToAdd[$tableName][] = $column;
 -                                      }
 -                                      else if ($this->diffColumns($existingColumns[$column->getName()], $column)) {
 -                                              if (!isset($this->columnsToAlter[$tableName])) {
 -                                                      $this->columnsToAlter[$tableName] = [];
 -                                              }
 -                                              $this->columnsToAlter[$tableName][] = $column;
 -                                      }
 -                              }
 -                              
 -                              // all column-related changes are executed in one query thus break
 -                              // here and not within the previous loop
 -                              if (!empty($this->columnsToAdd) || !empty($this->columnsToAlter) || !empty($this->columnsToDrop)) {
 -                                      $this->splitNodeMessage .= "Altered columns of table '{$tableName}'.";
 -                                      break;
 -                              }
 -                              
 -                              $existingForeignKeys = $existingTable->getForeignKeys();
 -                              foreach ($table->getForeignKeys() as $foreignKey) {
 -                                      $matchingExistingForeignKey = null;
 -                                      foreach ($existingForeignKeys as $existingForeignKey) {
 -                                              if (empty(array_diff($foreignKey->getDiffData(), $existingForeignKey->getDiffData()))) {
 -                                                      $matchingExistingForeignKey = $existingForeignKey;
 -                                                      break;
 -                                              }
 -                                      }
 -                                      
 -                                      if ($foreignKey->willBeDropped()) {
 -                                              if ($matchingExistingForeignKey !== null) {
 -                                                      if (!isset($this->foreignKeysToDrop[$tableName])) {
 -                                                              $this->foreignKeysToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->foreignKeysToDrop[$tableName][] = $foreignKey;
 -                                                      
 -                                                      $this->splitNodeMessage .= "Dropped foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
 -                                                      break 2;
 -                                              }
 -                                              else if (isset($this->foreignKeyPackageIDs[$tableName][$foreignKey->getName()])) {
 -                                                      $this->deleteForeignKeyLog($tableName, $foreignKey);
 -                                              }
 -                                      }
 -                                      else if ($matchingExistingForeignKey === null) {
 -                                              if (!isset($this->foreignKeysToAdd[$tableName])) {
 -                                                      $this->foreignKeysToAdd[$tableName] = [];
 -                                              }
 -                                              $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 -                                              
 -                                              $this->splitNodeMessage .= "Added foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
 -                                              break 2;
 -                                      }
 -                                      else if (!empty(array_diff($foreignKey->getData(), $matchingExistingForeignKey->getData()))) {
 -                                              if (!isset($this->foreignKeysToDrop[$tableName])) {
 -                                                      $this->foreignKeysToDrop[$tableName] = [];
 -                                              }
 -                                              $this->foreignKeysToDrop[$tableName][] = $matchingExistingForeignKey;
 -                                              
 -                                              if (!isset($this->foreignKeysToAdd[$tableName])) {
 -                                                      $this->foreignKeysToAdd[$tableName] = [];
 -                                              }
 -                                              $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 -                                              
 -                                              $this->splitNodeMessage .= "Replaced foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
 -                                              break 2;
 -                                      }
 -                              }
 -                              
 -                              $existingIndices = $existingTable->getIndices();
 -                              foreach ($table->getIndices() as $index) {
 -                                      $matchingExistingIndex = null;
 -                                      foreach ($existingIndices as $existingIndex) {
 -                                              if (!$this->diffIndices($existingIndex, $index)) {
 -                                                      $matchingExistingIndex = $existingIndex;
 -                                                      break;
 -                                              }
 -                                      }
 -                                      
 -                                      if ($index->willBeDropped()) {
 -                                              if ($matchingExistingIndex !== null) {
 -                                                      if (!isset($this->indicesToDrop[$tableName])) {
 -                                                              $this->indicesToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 -                                                      
 -                                                      $this->splitNodeMessage .= "Dropped index '{$tableName}." . implode(',', $index->getColumns()) . "'.";
 -                                                      break 2;
 -                                              }
 -                                              else if (isset($this->indexPackageIDs[$tableName][$index->getName()])) {
 -                                                      $this->deleteIndexLog($tableName, $index);
 -                                              }
 -                                      }
 -                                      else if ($matchingExistingIndex !== null) {
 -                                              // updating index type and index columns is supported with an
 -                                              // explicit index name is given (automatically generated index
 -                                              // names are not deterministic)
 -                                              if (!$index->hasGeneratedName() && !empty(array_diff($matchingExistingIndex->getData(), $index->getData()))) {
 -                                                      if (!isset($this->indicesToDrop[$tableName])) {
 -                                                              $this->indicesToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 -                                                      
 -                                                      if (!isset($this->indicesToAdd[$tableName])) {
 -                                                              $this->indicesToAdd[$tableName] = [];
 -                                                      }
 -                                                      $this->indicesToAdd[$tableName][] = $index;
 -                                              }
 -                                      }
 -                                      else {
 -                                              if (!isset($this->indicesToAdd[$tableName])) {
 -                                                      $this->indicesToAdd[$tableName] = [];
 -                                              }
 -                                              $this->indicesToAdd[$tableName][] = $index;
 -                                              
 -                                              $this->splitNodeMessage .= "Added index '{$tableName}." . implode(',', $index->getColumns()) . "'.";
 -                                              break 2;
 -                                      }
 -                              }
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Checks for any pending log entries for the package and either marks them as done or
 -       * deletes them so that after this method finishes, there are no more undone log entries
 -       * for the package.
 -       */
 -      protected function checkPendingLogEntries() {
 -              $sql = "SELECT  *
 -                      FROM    wcf" . WCF_N . "_package_installation_sql_log
 -                      WHERE   packageID = ?
 -                              AND isDone = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([$this->package->packageID, 0]);
 -              
 -              $doneEntries = $undoneEntries = [];
 -              while ($row = $statement->fetchArray()) {
 -                      // table
 -                      if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 -                              if (in_array($row['sqlTable'], $this->existingTableNames)) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -                      // column
 -                      else if ($row['sqlIndex'] === '') {
 -                              if (isset($this->getExistingTable($row['sqlTable'])->getColumns()[$row['sqlColumn']])) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -                      // foreign key
 -                      else if (substr($row['sqlIndex'], -3) === '_fk') {
 -                              if (isset($this->getExistingTable($row['sqlTable'])->getForeignKeys()[$row['sqlIndex']])) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -                      // index
 -                      else {
 -                              if (isset($this->getExistingTable($row['sqlTable'])->getIndices()[$row['sqlIndex']])) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -              }
 -              
 -              WCF::getDB()->beginTransaction();
 -              foreach ($doneEntries as $entry) {
 -                      $this->finalizeLog($entry);
 -              }
 -              
 -              // to achieve a consistent state, undone log entries will be deleted here even though
 -              // they might be re-created later to ensure that after this method finishes, there are
 -              // no more undone entries in the log for the relevant package
 -              foreach ($undoneEntries as $entry) {
 -                      $this->deleteLog($entry);
 -              }
 -              WCF::getDB()->commitTransaction();
 -      }
 -      
 -      /**
 -       * Creates a done log entry for the given foreign key.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function createForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $sql = "INSERT INTO     wcf" . WCF_N . "_package_installation_sql_log
 -                                      (packageID, sqlTable, sqlIndex, isDone)
 -                      VALUES          (?, ?, ?, ?)";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $tableName,
 -                      $foreignKey->getName(),
 -                      1
 -              ]);
 -      }
 -      
 -      /**
 -       * Creates the given table.
 -       * 
 -       * @param       DatabaseTable           $table
 -       */
 -      protected function createTable(DatabaseTable $table) {
 -              $hasPrimaryKey = false;
 -              $columnData = array_map(function(IDatabaseTableColumn $column) use (&$hasPrimaryKey) {
 -                      $data = $column->getData();
 -                      if (isset($data['key']) && $data['key'] === 'PRIMARY') {
 -                              $hasPrimaryKey = true;
 -                      }
 -                      
 -                      return [
 -                              'data' => $data,
 -                              'name' => $column->getName()
 -                      ];
 -              }, $table->getColumns());
 -              $indexData = array_map(function(DatabaseTableIndex $index) {
 -                      return [
 -                              'data' => $index->getData(),
 -                              'name' => $index->getName()
 -                      ];
 -              }, $table->getIndices());
 -              
 -              // Auto columns are implicitly defined as the primary key by MySQL.
 -              if ($hasPrimaryKey) {
 -                      $indexData = array_filter($indexData, function($key) {
 -                              return $key !== 'PRIMARY';
 -                      }, ARRAY_FILTER_USE_KEY);
 -              }
 -              
 -              $this->dbEditor->createTable($table->getName(), $columnData, $indexData);
 -              
 -              foreach ($table->getForeignKeys() as $foreignKey) {
 -                      $this->dbEditor->addForeignKey($table->getName(), $foreignKey->getName(), $foreignKey->getData());
 -                      
 -                      // foreign keys need to be explicitly logged for proper uninstallation
 -                      $this->createForeignKeyLog($table->getName(), $foreignKey);
 -              }
 -      }
 -      
 -      /**
 -       * Deletes the log entry for the given column.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       IDatabaseTableColumn            $column
 -       */
 -      protected function deleteColumnLog($tableName, IDatabaseTableColumn $column) {
 -              $this->deleteLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 -      }
 -      
 -      /**
 -       * Deletes the log entry for the given foreign key.
 -       *
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function deleteForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 -      }
 -      
 -      /**
 -       * Deletes the log entry for the given index.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function deleteIndexLog($tableName, DatabaseTableIndex $index) {
 -              $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 -      }
 -      
 -      /**
 -       * Deletes a log entry.
 -       * 
 -       * @param       array   $data
 -       */
 -      protected function deleteLog(array $data) {
 -              $sql = "DELETE FROM     wcf" . WCF_N . "_package_installation_sql_log
 -                      WHERE           packageID = ?
 -                                      AND sqlTable = ?
 -                                      AND sqlColumn = ?
 -                                      AND sqlIndex = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $data['sqlTable'],
 -                      $data['sqlColumn'] ?? '',
 -                      $data['sqlIndex'] ?? ''
 -              ]);
 -      }
 -      
 -      /**
 -       * Deletes all log entry related to the given table.
 -       * 
 -       * @param       DatabaseTable   $table
 -       */
 -      protected function deleteTableLog(DatabaseTable $table) {
 -              $sql = "DELETE FROM     wcf" . WCF_N . "_package_installation_sql_log
 -                      WHERE           packageID = ?
 -                                      AND sqlTable = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $table->getName()
 -              ]);
 -      }
 -      
 -      /**
 -       * Returns `true` if the two columns differ.
 -       * 
 -       * @param       IDatabaseTableColumn    $oldColumn
 -       * @param       IDatabaseTableColumn    $newColumn
 -       * @return      bool
 -       */
 -      protected function diffColumns(IDatabaseTableColumn $oldColumn, IDatabaseTableColumn $newColumn) {
 -              $diff = array_diff($oldColumn->getData(), $newColumn->getData());
 -              if (!empty($diff)) {
 -                      // see https://github.com/WoltLab/WCF/pull/3167
 -                      if (
 -                              array_key_exists('length', $diff)
 -                              && $oldColumn instanceof AbstractIntDatabaseTableColumn
 -                              && (
 -                                      !($oldColumn instanceof TinyintDatabaseTableColumn)
 -                                      || $oldColumn->getLength() != 1
 -                              )
 -                      ) {
 -                              unset($diff['length']);
 -                      }
 -                      
 -                      if (!empty($diff)) {
 -                              return true;
 -                      }
 -              }
 -              
 -              // default type has to be checked explicitly for `null` to properly detect changing
 -              // from no default value (`null`) and to an empty string as default value (and vice
 -              // versa)
 -              if ($oldColumn->getDefaultValue() === null || $newColumn->getDefaultValue() === null) {
 -                      return $oldColumn->getDefaultValue() !== $newColumn->getDefaultValue();
 -              }
 -              
 -              // for all other cases, use weak comparison so that `'1'` (from database) and `1`
 -              // (from script PIP) match, for example 
 -              return $oldColumn->getDefaultValue() != $newColumn->getDefaultValue();
 -      }
 -      
 -      /**
 -       * Returns `true` if the two indices differ.
 -       * 
 -       * @param       DatabaseTableIndex      $oldIndex
 -       * @param       DatabaseTableIndex      $newIndex
 -       * @return      bool
 -       */
 -      protected function diffIndices(DatabaseTableIndex $oldIndex, DatabaseTableIndex $newIndex) {
 -              if ($newIndex->hasGeneratedName()) {
 -                      return !empty(array_diff($oldIndex->getData(), $newIndex->getData()));
 -              }
 -              
 -              return $oldIndex->getName() !== $newIndex->getName();
 -      }
 -      
 -      /**
 -       * Drops the given foreign key.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function dropForeignKey($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->dbEditor->dropForeignKey($tableName, $foreignKey->getName());
 -              $this->dbEditor->dropIndex($tableName, $foreignKey->getName());
 -      }
 -      
 -      /**
 -       * Drops the given index.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function dropIndex($tableName, DatabaseTableIndex $index) {
 -              $this->dbEditor->dropIndex($tableName, $index->getName());
 -      }
 -      
 -      /**
 -       * Drops the given table.
 -       * 
 -       * @param       DatabaseTable           $table
 -       */
 -      protected function dropTable(DatabaseTable $table) {
 -              $this->dbEditor->dropTable($table->getName());
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for the creation of the given column.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       IDatabaseTableColumn    $column
 -       */
 -      protected function finalizeColumnLog($tableName, IDatabaseTableColumn $column) {
 -              $this->finalizeLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for adding the given index.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function finalizeForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for adding the given index.
 -       *
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function finalizeIndexLog($tableName, DatabaseTableIndex $index) {
 -              $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 -      }
 -      
 -      /**
 -       * Finalizes a log entry after the relevant change has been executed.
 -       * 
 -       * @param       array   $data
 -       */
 -      protected function finalizeLog(array $data) {
 -              $sql = "UPDATE  wcf" . WCF_N . "_package_installation_sql_log
 -                      SET     isDone = ?
 -                      WHERE   packageID = ?
 -                              AND sqlTable = ?
 -                              AND sqlColumn = ?
 -                              AND sqlIndex = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      1,
 -                      $this->package->packageID,
 -                      $data['sqlTable'],
 -                      $data['sqlColumn'] ?? '',
 -                      $data['sqlIndex'] ?? ''
 -              ]);
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for the creation of the given table.
 -       * 
 -       * @param       DatabaseTable   $table
 -       */
 -      protected function finalizeTableLog(DatabaseTable $table) {
 -              $this->finalizeLog(['sqlTable' => $table->getName()]);
 -      }
 -      
 -      /**
 -       * Returns the id of the package to with the given column belongs to. If there is no specific
 -       * log entry for the given column, the table log is checked and the relevant package id of
 -       * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 -       * 
 -       * @param       DatabaseTable           $table
 -       * @param       IDatabaseTableColumn    $column
 -       * @return      null|int
 -       */
 -      protected function getColumnPackageID(DatabaseTable $table, IDatabaseTableColumn $column) {
 -              if (isset($this->columnPackageIDs[$table->getName()][$column->getName()])) {
 -                      return $this->columnPackageIDs[$table->getName()][$column->getName()];
 -              }
 -              else if (isset($this->tablePackageIDs[$table->getName()])) {
 -                      return $this->tablePackageIDs[$table->getName()];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Returns the `DatabaseTable` object for the table with the given name.
 -       * 
 -       * @param       string          $tableName
 -       * @return      DatabaseTable
 -       */
 -      protected function getExistingTable($tableName) {
 -              if (!isset($this->existingTables[$tableName])) {
 -                      $this->existingTables[$tableName] = DatabaseTable::createFromExistingTable($this->dbEditor, $tableName);
 -              }
 -              
 -              return $this->existingTables[$tableName];
 -      }
 -      
 -      /**
 -       * Returns the id of the package to with the given foreign key belongs to. If there is no specific
 -       * log entry for the given foreign key, the table log is checked and the relevant package id of
 -       * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 -       * 
 -       * @param       DatabaseTable                   $table
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       * @return      null|int
 -       */
 -      protected function getForeignKeyPackageID(DatabaseTable $table, DatabaseTableForeignKey $foreignKey) {
 -              if (isset($this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()])) {
 -                      return $this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()];
 -              }
 -              else if (isset($this->tablePackageIDs[$table->getName()])) {
 -                      return $this->tablePackageIDs[$table->getName()];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Returns the id of the package to with the given index belongs to. If there is no specific
 -       * log entry for the given index, the table log is checked and the relevant package id of
 -       * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 -       * 
 -       * @param       DatabaseTable           $table
 -       * @param       DatabaseTableIndex      $index
 -       * @return      null|int
 -       */
 -      protected function getIndexPackageID(DatabaseTable $table, DatabaseTableIndex $index) {
 -              if (isset($this->indexPackageIDs[$table->getName()][$index->getName()])) {
 -                      return $this->indexPackageIDs[$table->getName()][$index->getName()];
 -              }
 -              else if (isset($this->tablePackageIDs[$table->getName()])) {
 -                      return $this->tablePackageIDs[$table->getName()];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Prepares the log entry for the creation of the given column.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       IDatabaseTableColumn    $column
 -       */
 -      protected function prepareColumnLog($tableName, IDatabaseTableColumn $column) {
 -              $this->prepareLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 -      }
 -      
 -      /**
 -       * Prepares the log entry for adding the given foreign key.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function prepareForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 -      }
 -      
 -      /**
 -       * Prepares the log entry for adding the given index.
 -       *
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function prepareIndexLog($tableName, DatabaseTableIndex $index) {
 -              $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 -      }
 -      
 -      /**
 -       * Prepares a log entry before the relevant change has been executed.
 -       *
 -       * @param       array   $data
 -       */
 -      protected function prepareLog(array $data) {
 -              $sql = "INSERT INTO     wcf" . WCF_N . "_package_installation_sql_log
 -                                      (packageID, sqlTable, sqlColumn, sqlIndex, isDone)
 -                      VALUES          (?, ?, ?, ?, ?)";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $data['sqlTable'],
 -                      $data['sqlColumn'] ?? '',
 -                      $data['sqlIndex'] ?? '',
 -                      0
 -              ]);
 -      }
 -      
 -      /**
 -       * Prepares the log entry for the creation of the given table.
 -       *
 -       * @param       DatabaseTable   $table
 -       */
 -      protected function prepareTableLog(DatabaseTable $table) {
 -              $this->prepareLog(['sqlTable' => $table->getName()]);
 -      }
 -      
 -      /**
 -       * Processes all tables and updates the current table layouts to match the specified layouts. 
 -       * 
 -       * @throws      \RuntimeException       if validation of the required layout changes fails
 -       */
 -      public function process() {
 -              $this->checkPendingLogEntries();
 -              
 -              $errors = $this->validate();
 -              if (!empty($errors)) {
 -                      throw new \RuntimeException(WCF::getLanguage()->getDynamicVariable('wcf.acp.package.error.databaseChange', [
 -                              'errors' => $errors
 -                      ]));
 -              }
 -              
 -              $this->calculateChanges();
 -              
 -              $this->applyChanges();
 -      }
 -      
 -      /**
 -       * Checks if the relevant table layout changes can be executed and returns an array with information
 -       * on all validation errors.
 -       * 
 -       * @return      array
 -       */
 -      public function validate() {
 -              $errors = [];
 -              foreach ($this->tables as $table) {
 -                      if ($table->willBeDropped()) {
 -                              if (in_array($table->getName(), $this->existingTableNames)) {
 -                                      if (!isset($this->tablePackageIDs[$table->getName()])) {
 -                                              $errors[] = [
 -                                                      'tableName' => $table->getName(),
 -                                                      'type' => 'unregisteredTableDrop'
 -                                              ];
 -                                      }
 -                                      else if ($this->tablePackageIDs[$table->getName()] !== $this->package->packageID) {
 -                                              $errors[] = [
 -                                                      'tableName' => $table->getName(),
 -                                                      'type' => 'foreignTableDrop'
 -                                              ];
 -                                      }
 -                              }
 -                      }
 -                      else {
 -                              $existingTable = null;
 -                              if (in_array($table->getName(), $this->existingTableNames)) {
 -                                      if (!isset($this->tablePackageIDs[$table->getName()])) {
 -                                              $errors[] = [
 -                                                      'tableName' => $table->getName(),
 -                                                      'type' => 'unregisteredTableChange',
 -                                              ];
 -                                      }
 -                                      else {
 -                                              $existingTable = DatabaseTable::createFromExistingTable($this->dbEditor, $table->getName());
 -                                              $existingColumns = $existingTable->getColumns();
 -                                              $existingIndices = $existingTable->getIndices();
 -                                              $existingForeignKeys = $existingTable->getForeignKeys();
 -                                              
 -                                              foreach ($table->getColumns() as $column) {
 -                                                      if (isset($existingColumns[$column->getName()])) {
 -                                                              $columnPackageID = $this->getColumnPackageID($table, $column);
 -                                                              if ($column->willBeDropped()) {
 -                                                                      if ($columnPackageID !== $this->package->packageID) {
 -                                                                              $errors[] = [
 -                                                                                      'columnName' => $column->getName(),
 -                                                                                      'tableName' => $table->getName(),
 -                                                                                      'type' => 'foreignColumnDrop',
 -                                                                              ];
 -                                                                      }
 -                                                              }
 -                                                              else if ($columnPackageID !== $this->package->packageID) {
 -                                                                      $errors[] = [
 -                                                                              'columnName' => $column->getName(),
 -                                                                              'tableName' => $table->getName(),
 -                                                                              'type' => 'foreignColumnChange',
 -                                                                      ];
 -                                                              }
 -                                                      }
 -                                              }
 -                                              
 -                                              foreach ($table->getIndices() as $index) {
 -                                                      foreach ($existingIndices as $existingIndex) {
 -                                                              if (empty(array_diff($index->getData(), $existingIndex->getData()))) {
 -                                                                      if ($index->willBeDropped()) {
 -                                                                              if ($this->getIndexPackageID($table, $index) !== $this->package->packageID) {
 -                                                                                      $errors[] = [
 -                                                                                              'columnNames' => implode(',', $existingIndex->getColumns()),
 -                                                                                              'tableName' => $table->getName(),
 -                                                                                              'type' => 'foreignIndexDrop',
 -                                                                                      ];
 -                                                                              }
 -                                                                      }
 -                                                                      
 -                                                                      continue 2;
 -                                                              }
 -                                                      }
 -                                              }
 -                                              
 -                                              foreach ($table->getForeignKeys() as $foreignKey) {
 -                                                      foreach ($existingForeignKeys as $existingForeignKey) {
 -                                                              if (empty(array_diff($foreignKey->getData(), $existingForeignKey->getData()))) {
 -                                                                      if ($foreignKey->willBeDropped()) {
 -                                                                              if ($this->getForeignKeyPackageID($table, $foreignKey) !== $this->package->packageID) {
 -                                                                                      $errors[] = [
 -                                                                                              'columnNames' => implode(',', $existingForeignKey->getColumns()),
 -                                                                                              'tableName' => $table->getName(),
 -                                                                                              'type' => 'foreignForeignKeyDrop',
 -                                                                                      ];
 -                                                                              }
 -                                                                      }
 -                                                                      
 -                                                                      continue 2;
 -                                                              }
 -                                                      }
 -                                              }
 -                                      }
 -                              }
 -                              
 -                              foreach ($table->getIndices() as $index) {
 -                                      foreach ($index->getColumns() as $indexColumn) {
 -                                              $column = $this->getColumnByName($indexColumn, $table, $existingTable);
 -                                              if ($column === null) {
 -                                                      if (!$index->willBeDropped()) {
 -                                                              $errors[] = [
 -                                                                      'columnName' => $indexColumn,
 -                                                                      'columnNames' => implode(',', $index->getColumns()),
 -                                                                      'tableName' => $table->getName(),
 -                                                                      'type' => 'nonexistingColumnInIndex',
 -                                                              ];
 -                                                      }
 -                                              }
 -                                              else if (
 -                                                      $index->getType() === DatabaseTableIndex::PRIMARY_TYPE
 -                                                      && !$index->willBeDropped()
 -                                                      && !$column->isNotNull()
 -                                              ) {
 -                                                      $errors[] = [
 -                                                              'columnName' => $indexColumn,
 -                                                              'columnNames' => implode(',', $index->getColumns()),
 -                                                              'tableName' => $table->getName(),
 -                                                              'type' => 'nullColumnInPrimaryIndex',
 -                                                      ];
 -                                              }
 -                                      }
 -                              }
 -                      }
 -              }
 -              
 -              return $errors;
 -      }
 -      
 -      /**
 -       * Returns the column with the given name from the given table.
 -       * 
 -       * @param       string                  $columnName
 -       * @param       DatabaseTable           $updateTable
 -       * @param       DatabaseTable|null      $existingTable
 -       * @return      IDatabaseTableColumn|null
 -       * @since       5.2.10
 -       */
 -      protected function getColumnByName($columnName, DatabaseTable $updateTable, DatabaseTable $existingTable = null) {
 -              foreach ($updateTable->getColumns() as $column) {
 -                      if ($column->getName() === $columnName) {
 -                              return $column;
 -                      }
 -              }
 -              
 -              if ($existingTable) {
 -                      foreach ($existingTable->getColumns() as $column) {
 -                              if ($column->getName() === $columnName) {
 -                                      return $column;
 -                              }
 -                      }
 -              }
 -              
 -              return null;
 -      }
 +class DatabaseTableChangeProcessor
 +{
 +    /**
 +     * maps the registered database table column names to the ids of the packages they belong to
 +     * @var int[][]
 +     */
 +    protected $columnPackageIDs = [];
 +
 +    /**
 +     * database table columns that will be added grouped by the name of the table to which they
 +     * will be added
 +     * @var IDatabaseTableColumn[][]
 +     */
 +    protected $columnsToAdd = [];
 +
 +    /**
 +     * database table columns that will be altered grouped by the name of the table to which
 +     * they belong
 +     * @var IDatabaseTableColumn[][]
 +     */
 +    protected $columnsToAlter = [];
 +
 +    /**
 +     * database table columns that will be dropped grouped by the name of the table from which
 +     * they will be dropped
 +     * @var IDatabaseTableColumn[][]
 +     */
 +    protected $columnsToDrop = [];
 +
 +    /**
 +     * database editor to apply the relevant changes to the table layouts
 +     * @var DatabaseEditor
 +     */
 +    protected $dbEditor;
 +
 +    /**
 +     * list of all existing tables in the used database
 +     * @var string[]
 +     */
 +    protected $existingTableNames = [];
 +
 +    /**
 +     * existing database tables
 +     * @var DatabaseTable[]
 +     */
 +    protected $existingTables = [];
 +
 +    /**
 +     * maps the registered database table index names to the ids of the packages they belong to
 +     * @var int[][]
 +     */
 +    protected $indexPackageIDs = [];
 +
 +    /**
 +     * indices that will be added grouped by the name of the table to which they will be added
 +     * @var DatabaseTableIndex[][]
 +     */
 +    protected $indicesToAdd = [];
 +
 +    /**
 +     * indices that will be dropped grouped by the name of the table from which they will be dropped
 +     * @var DatabaseTableIndex[][]
 +     */
 +    protected $indicesToDrop = [];
 +
 +    /**
 +     * maps the registered database table foreign key names to the ids of the packages they belong to
 +     * @var int[][]
 +     */
 +    protected $foreignKeyPackageIDs = [];
 +
 +    /**
 +     * foreign keys that will be added grouped by the name of the table to which they will be
 +     * added
 +     * @var DatabaseTableForeignKey[][]
 +     */
 +    protected $foreignKeysToAdd = [];
 +
 +    /**
 +     * foreign keys that will be dropped grouped by the name of the table from which they will
 +     * be dropped
 +     * @var DatabaseTableForeignKey[][]
 +     */
 +    protected $foreignKeysToDrop = [];
 +
 +    /**
 +     * package that wants to apply the changes
 +     * @var Package
 +     */
 +    protected $package;
 +
 +    /**
 +     * message for the split node exception thrown after the changes have been applied
 +     * @var string
 +     */
 +    protected $splitNodeMessage = '';
 +
 +    /**
 +     * layouts/layout changes of the relevant database table
 +     * @var DatabaseTable[]
 +     */
 +    protected $tables;
 +
 +    /**
 +     * maps the registered database table names to the ids of the packages they belong to
 +     * @var int[]
 +     */
 +    protected $tablePackageIDs = [];
 +
 +    /**
 +     * database table that will be created
 +     * @var DatabaseTable[]
 +     */
 +    protected $tablesToCreate = [];
 +
 +    /**
 +     * database tables that will be dropped
 +     * @var DatabaseTable[]
 +     */
 +    protected $tablesToDrop = [];
 +
 +    /**
 +     * Creates a new instance of `DatabaseTableChangeProcessor`.
 +     *
 +     * @param Package $package
 +     * @param DatabaseTable[] $tables
 +     * @param DatabaseEditor $dbEditor
 +     */
 +    public function __construct(Package $package, array $tables, DatabaseEditor $dbEditor)
 +    {
 +        $this->package = $package;
 +
 +        $tableNames = [];
 +        foreach ($tables as $table) {
 +            if (!($table instanceof DatabaseTable)) {
 +                throw new \InvalidArgumentException("Tables must be instance of '" . DatabaseTable::class . "'");
 +            }
 +
 +            $tableNames[] = $table->getName();
 +        }
 +
 +        $this->tables = $tables;
 +        $this->dbEditor = $dbEditor;
 +
 +        $this->existingTableNames = $dbEditor->getTableNames();
 +
 +        $conditionBuilder = new PreparedStatementConditionBuilder();
 +        $conditionBuilder->add('sqlTable IN (?)', [$tableNames]);
 +        $conditionBuilder->add('isDone = ?', [1]);
 +
 +        $sql = "SELECT  *
 +                FROM    wcf" . WCF_N . "_package_installation_sql_log
 +                " . $conditionBuilder;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute($conditionBuilder->getParameters());
 +
 +        while ($row = $statement->fetchArray()) {
 +            if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 +                $this->tablePackageIDs[$row['sqlTable']] = $row['packageID'];
 +            } elseif ($row['sqlIndex'] === '') {
 +                $this->columnPackageIDs[$row['sqlTable']][$row['sqlColumn']] = $row['packageID'];
 +            } elseif (\substr($row['sqlIndex'], -3) === '_fk') {
 +                $this->foreignKeyPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 +            } else {
 +                $this->indexPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Adds the given index to the table.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function addForeignKey($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->dbEditor->addForeignKey($tableName, $foreignKey->getName(), $foreignKey->getData());
 +    }
 +
 +    /**
 +     * Adds the given index to the table.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function addIndex($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->dbEditor->addIndex($tableName, $index->getName(), $index->getData());
 +    }
 +
 +    /**
 +     * Applies all of the previously determined changes to achieve the desired database layout.
 +     *
 +     * @throws  SplitNodeException  if any change has been applied
 +     */
 +    protected function applyChanges()
 +    {
 +        $appliedAnyChange = false;
 +
 +        foreach ($this->tablesToCreate as $table) {
 +            $appliedAnyChange = true;
 +
 +            $this->prepareTableLog($table);
 +            $this->createTable($table);
 +            $this->finalizeTableLog($table);
 +        }
 +
 +        foreach ($this->tablesToDrop as $table) {
 +            $appliedAnyChange = true;
 +
 +            $this->dropTable($table);
 +            $this->deleteTableLog($table);
 +        }
 +
 +        $columnTables = \array_unique(\array_merge(
 +            \array_keys($this->columnsToAdd),
 +            \array_keys($this->columnsToAlter),
 +            \array_keys($this->columnsToDrop)
 +        ));
 +        foreach ($columnTables as $tableName) {
 +            $appliedAnyChange = true;
 +
 +            $columnsToAdd = $this->columnsToAdd[$tableName] ?? [];
 +            $columnsToAlter = $this->columnsToAlter[$tableName] ?? [];
 +            $columnsToDrop = $this->columnsToDrop[$tableName] ?? [];
 +
 +            foreach ($columnsToAdd as $column) {
 +                $this->prepareColumnLog($tableName, $column);
 +            }
 +
 +            $renamedColumnsWithLog = [];
 +            foreach ($columnsToAlter as $column) {
 +                if ($column->getNewName() && $this->getColumnLog($tableName, $column) !== null) {
 +                    $this->prepareColumnLog($tableName, $column, true);
 +                    $renamedColumnsWithLog[] = $column;
 +                }
 +            }
 +
 +            $this->applyColumnChanges(
 +                $tableName,
 +                $columnsToAdd,
 +                $columnsToAlter,
 +                $columnsToDrop
 +            );
 +
 +            foreach ($columnsToAdd as $column) {
 +                $this->finalizeColumnLog($tableName, $column);
 +            }
 +
 +            foreach ($renamedColumnsWithLog as $column) {
 +                $this->finalizeColumnLog($tableName, $column, true);
 +                $this->deleteColumnLog($tableName, $column);
 +            }
 +
 +            foreach ($columnsToDrop as $column) {
 +                $this->deleteColumnLog($tableName, $column);
 +            }
 +        }
 +
 +        foreach ($this->foreignKeysToDrop as $tableName => $foreignKeys) {
 +            foreach ($foreignKeys as $foreignKey) {
 +                $appliedAnyChange = true;
 +
 +                $this->dropForeignKey($tableName, $foreignKey);
 +                $this->deleteForeignKeyLog($tableName, $foreignKey);
 +            }
 +        }
 +
 +        foreach ($this->foreignKeysToAdd as $tableName => $foreignKeys) {
 +            foreach ($foreignKeys as $foreignKey) {
 +                $appliedAnyChange = true;
 +
 +                $this->prepareForeignKeyLog($tableName, $foreignKey);
 +                $this->addForeignKey($tableName, $foreignKey);
 +                $this->finalizeForeignKeyLog($tableName, $foreignKey);
 +            }
 +        }
 +
 +        foreach ($this->indicesToDrop as $tableName => $indices) {
 +            foreach ($indices as $index) {
 +                $appliedAnyChange = true;
 +
 +                $this->dropIndex($tableName, $index);
 +                $this->deleteIndexLog($tableName, $index);
 +            }
 +        }
 +
 +        foreach ($this->indicesToAdd as $tableName => $indices) {
 +            foreach ($indices as $index) {
 +                $appliedAnyChange = true;
 +
 +                $this->prepareIndexLog($tableName, $index);
 +                $this->addIndex($tableName, $index);
 +                $this->finalizeIndexLog($tableName, $index);
 +            }
 +        }
 +
 +        if ($appliedAnyChange) {
 +            throw new SplitNodeException($this->splitNodeMessage);
 +        }
 +    }
 +
 +    /**
 +     * Adds, alters, and drop columns of the same table.
 +     *
 +     * Before a column is dropped, all of its foreign keys are dropped.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn[] $addedColumns
 +     * @param IDatabaseTableColumn[] $alteredColumns
 +     * @param IDatabaseTableColumn[] $droppedColumns
 +     */
 +    protected function applyColumnChanges($tableName, array $addedColumns, array $alteredColumns, array $droppedColumns)
 +    {
 +        $dropForeignKeys = [];
 +
 +        $columnData = [];
 +        foreach ($droppedColumns as $droppedColumn) {
 +            $columnData[$droppedColumn->getName()] = [
 +                'action' => 'drop',
 +            ];
 +
 +            foreach ($this->getExistingTable($tableName)->getForeignKeys() as $foreignKey) {
 +                if (\in_array($droppedColumn->getName(), $foreignKey->getColumns())) {
 +                    $dropForeignKeys[] = $foreignKey;
 +                }
 +            }
 +        }
 +        foreach ($addedColumns as $addedColumn) {
 +            $columnData[$addedColumn->getName()] = [
 +                'action' => 'add',
 +                'data' => $addedColumn->getData(),
 +            ];
 +        }
 +        foreach ($alteredColumns as $alteredColumn) {
 +            $columnData[$alteredColumn->getName()] = [
 +                'action' => 'alter',
 +                'data' => $alteredColumn->getData(),
 +                'newColumnName' => $alteredColumn->getNewName() ?? $alteredColumn->getName(),
 +            ];
 +        }
 +
 +        if (!empty($columnData)) {
 +            foreach ($dropForeignKeys as $foreignKey) {
 +                $this->dropForeignKey($tableName, $foreignKey);
 +                $this->deleteForeignKeyLog($tableName, $foreignKey);
 +            }
 +
 +            $this->dbEditor->alterColumns($tableName, $columnData);
 +        }
 +    }
 +
 +    /**
 +     * Calculates all of the necessary changes to be executed.
 +     */
 +    protected function calculateChanges()
 +    {
 +        foreach ($this->tables as $table) {
 +            $tableName = $table->getName();
 +
 +            if ($table->willBeDropped()) {
 +                if (\in_array($tableName, $this->existingTableNames)) {
 +                    $this->tablesToDrop[] = $table;
 +
 +                    $this->splitNodeMessage .= "Dropped table '{$tableName}'.";
 +                    break;
 +                } elseif (isset($this->tablePackageIDs[$tableName])) {
 +                    $this->deleteTableLog($table);
 +                }
 +            } elseif (!\in_array($tableName, $this->existingTableNames)) {
 +                if ($table instanceof PartialDatabaseTable) {
 +                    throw new \LogicException("Partial table '{$tableName}' cannot be created.");
 +                }
 +
 +                $this->tablesToCreate[] = $table;
 +
 +                $this->splitNodeMessage .= "Created table '{$tableName}'.";
 +                break;
 +            } else {
 +                // calculate difference between tables
 +                $existingTable = $this->getExistingTable($tableName);
 +                $existingColumns = $existingTable->getColumns();
 +
 +                foreach ($table->getColumns() as $column) {
 +                    if ($column->willBeDropped()) {
 +                        if (isset($existingColumns[$column->getName()])) {
 +                            if (!isset($this->columnsToDrop[$tableName])) {
 +                                $this->columnsToDrop[$tableName] = [];
 +                            }
 +                            $this->columnsToDrop[$tableName][] = $column;
 +                        } elseif (isset($this->columnPackageIDs[$tableName][$column->getName()])) {
 +                            $this->deleteColumnLog($tableName, $column);
 +                        }
 +                    } elseif (!isset($existingColumns[$column->getName()])) {
 +                        if (!isset($this->columnsToAdd[$tableName])) {
 +                            $this->columnsToAdd[$tableName] = [];
 +                        }
 +                        $this->columnsToAdd[$tableName][] = $column;
 +                    } elseif ($this->diffColumns($existingColumns[$column->getName()], $column)) {
 +                        if (!isset($this->columnsToAlter[$tableName])) {
 +                            $this->columnsToAlter[$tableName] = [];
 +                        }
 +                        $this->columnsToAlter[$tableName][] = $column;
 +                    }
 +                }
 +
 +                // all column-related changes are executed in one query thus break
 +                // here and not within the previous loop
 +                if (!empty($this->columnsToAdd) || !empty($this->columnsToAlter) || !empty($this->columnsToDrop)) {
 +                    $this->splitNodeMessage .= "Altered columns of table '{$tableName}'.";
 +                    break;
 +                }
 +
 +                $existingForeignKeys = $existingTable->getForeignKeys();
 +                foreach ($table->getForeignKeys() as $foreignKey) {
 +                    $matchingExistingForeignKey = null;
 +                    foreach ($existingForeignKeys as $existingForeignKey) {
 +                        if (empty(\array_diff($foreignKey->getDiffData(), $existingForeignKey->getDiffData()))) {
 +                            $matchingExistingForeignKey = $existingForeignKey;
 +                            break;
 +                        }
 +                    }
 +
 +                    if ($foreignKey->willBeDropped()) {
 +                        if ($matchingExistingForeignKey !== null) {
 +                            if (!isset($this->foreignKeysToDrop[$tableName])) {
 +                                $this->foreignKeysToDrop[$tableName] = [];
 +                            }
 +                            $this->foreignKeysToDrop[$tableName][] = $foreignKey;
 +
 +                            $this->splitNodeMessage .= "Dropped foreign key '{$tableName}." . \implode(
 +                                ',',
 +                                $foreignKey->getColumns()
 +                            ) . "'.";
 +                            break 2;
 +                        } elseif (isset($this->foreignKeyPackageIDs[$tableName][$foreignKey->getName()])) {
 +                            $this->deleteForeignKeyLog($tableName, $foreignKey);
 +                        }
 +                    } elseif ($matchingExistingForeignKey === null) {
 +                        if (!isset($this->foreignKeysToAdd[$tableName])) {
 +                            $this->foreignKeysToAdd[$tableName] = [];
 +                        }
 +                        $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 +
 +                        $this->splitNodeMessage .= "Added foreign key '{$tableName}." . \implode(
 +                            ',',
 +                            $foreignKey->getColumns()
 +                        ) . "'.";
 +                        break 2;
 +                    } elseif (!empty(\array_diff($foreignKey->getData(), $matchingExistingForeignKey->getData()))) {
 +                        if (!isset($this->foreignKeysToDrop[$tableName])) {
 +                            $this->foreignKeysToDrop[$tableName] = [];
 +                        }
 +                        $this->foreignKeysToDrop[$tableName][] = $matchingExistingForeignKey;
 +
 +                        if (!isset($this->foreignKeysToAdd[$tableName])) {
 +                            $this->foreignKeysToAdd[$tableName] = [];
 +                        }
 +                        $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 +
 +                        $this->splitNodeMessage .= "Replaced foreign key '{$tableName}." . \implode(
 +                            ',',
 +                            $foreignKey->getColumns()
 +                        ) . "'.";
 +                        break 2;
 +                    }
 +                }
 +
 +                $existingIndices = $existingTable->getIndices();
 +                foreach ($table->getIndices() as $index) {
 +                    $matchingExistingIndex = null;
 +                    foreach ($existingIndices as $existingIndex) {
 +                        if (!$this->diffIndices($existingIndex, $index)) {
 +                            $matchingExistingIndex = $existingIndex;
 +                            break;
 +                        }
 +                    }
 +
 +                    if ($index->willBeDropped()) {
 +                        if ($matchingExistingIndex !== null) {
 +                            if (!isset($this->indicesToDrop[$tableName])) {
 +                                $this->indicesToDrop[$tableName] = [];
 +                            }
-                             $this->indicesToDrop[$tableName][] = $index;
++                            $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 +
 +                            $this->splitNodeMessage .= "Dropped index '{$tableName}." . \implode(
 +                                ',',
 +                                $index->getColumns()
 +                            ) . "'.";
 +                            break 2;
 +                        } elseif (isset($this->indexPackageIDs[$tableName][$index->getName()])) {
 +                            $this->deleteIndexLog($tableName, $index);
 +                        }
 +                    } elseif ($matchingExistingIndex !== null) {
 +                        // updating index type and index columns is supported with an
 +                        // explicit index name is given (automatically generated index
 +                        // names are not deterministic)
 +                        if (
 +                            !$index->hasGeneratedName()
 +                            && !empty(\array_diff($matchingExistingIndex->getData(), $index->getData()))
 +                        ) {
 +                            if (!isset($this->indicesToDrop[$tableName])) {
 +                                $this->indicesToDrop[$tableName] = [];
 +                            }
 +                            $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 +
 +                            if (!isset($this->indicesToAdd[$tableName])) {
 +                                $this->indicesToAdd[$tableName] = [];
 +                            }
 +                            $this->indicesToAdd[$tableName][] = $index;
 +                        }
 +                    } else {
 +                        if (!isset($this->indicesToAdd[$tableName])) {
 +                            $this->indicesToAdd[$tableName] = [];
 +                        }
 +                        $this->indicesToAdd[$tableName][] = $index;
 +
 +                        $this->splitNodeMessage .= "Added index '{$tableName}." . \implode(
 +                            ',',
 +                            $index->getColumns()
 +                        ) . "'.";
 +                        break 2;
 +                    }
 +                }
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Checks for any pending log entries for the package and either marks them as done or
 +     * deletes them so that after this method finishes, there are no more undone log entries
 +     * for the package.
 +     */
 +    protected function checkPendingLogEntries()
 +    {
 +        $sql = "SELECT  *
 +                FROM    wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE   packageID = ?
 +                    AND isDone = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([$this->package->packageID, 0]);
 +
 +        $doneEntries = $undoneEntries = [];
 +        while ($row = $statement->fetchArray()) {
 +            // table
 +            if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 +                if (\in_array($row['sqlTable'], $this->existingTableNames)) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            } // column
 +            elseif ($row['sqlIndex'] === '') {
 +                if (isset($this->getExistingTable($row['sqlTable'])->getColumns()[$row['sqlColumn']])) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            } // foreign key
 +            elseif (\substr($row['sqlIndex'], -3) === '_fk') {
 +                if (isset($this->getExistingTable($row['sqlTable'])->getForeignKeys()[$row['sqlIndex']])) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            } // index
 +            else {
 +                if (isset($this->getExistingTable($row['sqlTable'])->getIndices()[$row['sqlIndex']])) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            }
 +        }
 +
 +        WCF::getDB()->beginTransaction();
 +        foreach ($doneEntries as $entry) {
 +            $this->finalizeLog($entry);
 +        }
 +
 +        // to achieve a consistent state, undone log entries will be deleted here even though
 +        // they might be re-created later to ensure that after this method finishes, there are
 +        // no more undone entries in the log for the relevant package
 +        foreach ($undoneEntries as $entry) {
 +            $this->deleteLog($entry);
 +        }
 +        WCF::getDB()->commitTransaction();
 +    }
 +
 +    /**
 +     * Creates a done log entry for the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function createForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_sql_log
 +                            (packageID, sqlTable, sqlIndex, isDone)
 +                VALUES      (?, ?, ?, ?)";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $tableName,
 +            $foreignKey->getName(),
 +            1,
 +        ]);
 +    }
 +
 +    /**
 +     * Creates the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function createTable(DatabaseTable $table)
 +    {
 +        $hasPrimaryKey = false;
 +        $columnData = \array_map(static function (IDatabaseTableColumn $column) use (&$hasPrimaryKey) {
 +            $data = $column->getData();
 +            if (isset($data['key']) && $data['key'] === 'PRIMARY') {
 +                $hasPrimaryKey = true;
 +            }
 +
 +            return [
 +                'data' => $data,
 +                'name' => $column->getName(),
 +            ];
 +        }, $table->getColumns());
 +        $indexData = \array_map(static function (DatabaseTableIndex $index) {
 +            return [
 +                'data' => $index->getData(),
 +                'name' => $index->getName(),
 +            ];
 +        }, $table->getIndices());
 +
 +        // Auto columns are implicitly defined as the primary key by MySQL.
 +        if ($hasPrimaryKey) {
 +            $indexData = \array_filter($indexData, static function ($key) {
 +                return $key !== 'PRIMARY';
 +            }, \ARRAY_FILTER_USE_KEY);
 +        }
 +
 +        $this->dbEditor->createTable($table->getName(), $columnData, $indexData);
 +
 +        foreach ($table->getForeignKeys() as $foreignKey) {
 +            $this->dbEditor->addForeignKey($table->getName(), $foreignKey->getName(), $foreignKey->getData());
 +
 +            // foreign keys need to be explicitly logged for proper uninstallation
 +            $this->createForeignKeyLog($table->getName(), $foreignKey);
 +        }
 +    }
 +
 +    /**
 +     * Deletes the log entry for the given column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     */
 +    protected function deleteColumnLog($tableName, IDatabaseTableColumn $column)
 +    {
 +        $this->deleteLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 +    }
 +
 +    /**
 +     * Deletes the log entry for the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function deleteForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 +    }
 +
 +    /**
 +     * Deletes the log entry for the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function deleteIndexLog($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 +    }
 +
 +    /**
 +     * Deletes a log entry.
 +     *
 +     * @param array $data
 +     */
 +    protected function deleteLog(array $data)
 +    {
 +        $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE       packageID = ?
 +                        AND sqlTable = ?
 +                        AND sqlColumn = ?
 +                        AND sqlIndex = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $data['sqlTable'],
 +            $data['sqlColumn'] ?? '',
 +            $data['sqlIndex'] ?? '',
 +        ]);
 +    }
 +
 +    /**
 +     * Deletes all log entry related to the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function deleteTableLog(DatabaseTable $table)
 +    {
 +        $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE       packageID = ?
 +                        AND sqlTable = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $table->getName(),
 +        ]);
 +    }
 +
 +    /**
 +     * Returns `true` if the two columns differ.
 +     *
 +     * @param IDatabaseTableColumn $oldColumn
 +     * @param IDatabaseTableColumn $newColumn
 +     * @return  bool
 +     */
 +    protected function diffColumns(IDatabaseTableColumn $oldColumn, IDatabaseTableColumn $newColumn)
 +    {
 +        $diff = \array_diff($oldColumn->getData(), $newColumn->getData());
 +        if (!empty($diff)) {
 +            // see https://github.com/WoltLab/WCF/pull/3167
 +            if (
 +                \array_key_exists('length', $diff)
 +                && $oldColumn instanceof AbstractIntDatabaseTableColumn
 +                && (
 +                    !($oldColumn instanceof TinyintDatabaseTableColumn)
 +                    || $oldColumn->getLength() != 1
 +                )
 +            ) {
 +                unset($diff['length']);
 +            }
 +
 +            if (!empty($diff)) {
 +                return true;
 +            }
 +        }
 +
 +        if ($newColumn->getNewName()) {
 +            return true;
 +        }
 +
 +        // default type has to be checked explicitly for `null` to properly detect changing
 +        // from no default value (`null`) and to an empty string as default value (and vice
 +        // versa)
 +        if ($oldColumn->getDefaultValue() === null || $newColumn->getDefaultValue() === null) {
 +            return $oldColumn->getDefaultValue() !== $newColumn->getDefaultValue();
 +        }
 +
 +        // for all other cases, use weak comparison so that `'1'` (from database) and `1`
 +        // (from script PIP) match, for example
 +        return $oldColumn->getDefaultValue() != $newColumn->getDefaultValue();
 +    }
 +
 +    /**
 +     * Returns `true` if the two indices differ.
 +     *
 +     * @param DatabaseTableIndex $oldIndex
 +     * @param DatabaseTableIndex $newIndex
 +     * @return  bool
 +     */
 +    protected function diffIndices(DatabaseTableIndex $oldIndex, DatabaseTableIndex $newIndex)
 +    {
 +        if ($newIndex->hasGeneratedName()) {
 +            return !empty(\array_diff($oldIndex->getData(), $newIndex->getData()));
 +        }
 +
 +        return $oldIndex->getName() !== $newIndex->getName();
 +    }
 +
 +    /**
 +     * Drops the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function dropForeignKey($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->dbEditor->dropForeignKey($tableName, $foreignKey->getName());
 +        $this->dbEditor->dropIndex($tableName, $foreignKey->getName());
 +    }
 +
 +    /**
 +     * Drops the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function dropIndex($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->dbEditor->dropIndex($tableName, $index->getName());
 +    }
 +
 +    /**
 +     * Drops the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function dropTable(DatabaseTable $table)
 +    {
 +        $this->dbEditor->dropTable($table->getName());
 +    }
 +
 +    /**
 +     * Finalizes the log entry for the creation of the given column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     * @param bool $useNewName
 +     */
 +    protected function finalizeColumnLog($tableName, IDatabaseTableColumn $column, bool $useNewName = false)
 +    {
 +        $this->finalizeLog([
 +            'sqlTable' => $tableName,
 +            'sqlColumn' => $useNewName ? $column->getNewName() : $column->getName(),
 +        ]);
 +    }
 +
 +    /**
 +     * Finalizes the log entry for adding the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function finalizeForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 +    }
 +
 +    /**
 +     * Finalizes the log entry for adding the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function finalizeIndexLog($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 +    }
 +
 +    /**
 +     * Finalizes a log entry after the relevant change has been executed.
 +     *
 +     * @param array $data
 +     */
 +    protected function finalizeLog(array $data)
 +    {
 +        $sql = "UPDATE  wcf" . WCF_N . "_package_installation_sql_log
 +                SET     isDone = ?
 +                WHERE   packageID = ?
 +                    AND sqlTable = ?
 +                    AND sqlColumn = ?
 +                    AND sqlIndex = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            1,
 +            $this->package->packageID,
 +            $data['sqlTable'],
 +            $data['sqlColumn'] ?? '',
 +            $data['sqlIndex'] ?? '',
 +        ]);
 +    }
 +
 +    /**
 +     * Finalizes the log entry for the creation of the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function finalizeTableLog(DatabaseTable $table)
 +    {
 +        $this->finalizeLog(['sqlTable' => $table->getName()]);
 +    }
 +
 +    /**
 +     * Returns the log entry for the given column or `null` if there is no explicit entry for
 +     * this column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     * @return      array|null
 +     * @since       5.4
 +     */
 +    protected function getColumnLog(string $tableName, IDatabaseTableColumn $column): ?array
 +    {
 +        $sql = "SELECT  *
 +                FROM    wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE   packageID = ?
 +                        AND sqlTable = ?
 +                        AND sqlColumn = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $tableName,
 +            $column->getName(),
 +        ]);
 +
 +        $row = $statement->fetchSingleRow();
 +        if ($row === false) {
 +            return null;
 +        }
 +
 +        return $row;
 +    }
 +
 +    /**
 +     * Returns the id of the package to with the given column belongs to. If there is no specific
 +     * log entry for the given column, the table log is checked and the relevant package id of
 +     * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 +     *
 +     * @param DatabaseTable $table
 +     * @param IDatabaseTableColumn $column
 +     * @return  null|int
 +     */
 +    protected function getColumnPackageID(DatabaseTable $table, IDatabaseTableColumn $column)
 +    {
 +        if (isset($this->columnPackageIDs[$table->getName()][$column->getName()])) {
 +            return $this->columnPackageIDs[$table->getName()][$column->getName()];
 +        } elseif (isset($this->tablePackageIDs[$table->getName()])) {
 +            return $this->tablePackageIDs[$table->getName()];
 +        }
 +    }
 +
 +    /**
 +     * Returns the `DatabaseTable` object for the table with the given name.
 +     *
 +     * @param string $tableName
 +     * @return  DatabaseTable
 +     */
 +    protected function getExistingTable($tableName)
 +    {
 +        if (!isset($this->existingTables[$tableName])) {
 +            $this->existingTables[$tableName] = DatabaseTable::createFromExistingTable($this->dbEditor, $tableName);
 +        }
 +
 +        return $this->existingTables[$tableName];
 +    }
 +
 +    /**
 +     * Returns the id of the package to with the given foreign key belongs to. If there is no specific
 +     * log entry for the given foreign key, the table log is checked and the relevant package id of
 +     * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 +     *
 +     * @param DatabaseTable $table
 +     * @param DatabaseTableForeignKey $foreignKey
 +     * @return  null|int
 +     */
 +    protected function getForeignKeyPackageID(DatabaseTable $table, DatabaseTableForeignKey $foreignKey)
 +    {
 +        if (isset($this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()])) {
 +            return $this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()];
 +        } elseif (isset($this->tablePackageIDs[$table->getName()])) {
 +            return $this->tablePackageIDs[$table->getName()];
 +        }
 +    }
 +
 +    /**
 +     * Returns the id of the package to with the given index belongs to. If there is no specific
 +     * log entry for the given index, the table log is checked and the relevant package id of
 +     * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 +     *
 +     * @param DatabaseTable $table
 +     * @param DatabaseTableIndex $index
 +     * @return  null|int
 +     */
 +    protected function getIndexPackageID(DatabaseTable $table, DatabaseTableIndex $index)
 +    {
 +        if (isset($this->indexPackageIDs[$table->getName()][$index->getName()])) {
 +            return $this->indexPackageIDs[$table->getName()][$index->getName()];
 +        } elseif (isset($this->tablePackageIDs[$table->getName()])) {
 +            return $this->tablePackageIDs[$table->getName()];
 +        }
 +    }
 +
 +    /**
 +     * Prepares the log entry for the creation of the given column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     * @param bool $useNewName
 +     */
 +    protected function prepareColumnLog($tableName, IDatabaseTableColumn $column, bool $useNewName = false)
 +    {
 +        $this->prepareLog([
 +            'sqlTable' => $tableName,
 +            'sqlColumn' => $useNewName ? $column->getNewName() : $column->getName(),
 +        ]);
 +    }
 +
 +    /**
 +     * Prepares the log entry for adding the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function prepareForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 +    }
 +
 +    /**
 +     * Prepares the log entry for adding the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function prepareIndexLog($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 +    }
 +
 +    /**
 +     * Prepares a log entry before the relevant change has been executed.
 +     *
 +     * @param array $data
 +     */
 +    protected function prepareLog(array $data)
 +    {
 +        $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_sql_log
 +                            (packageID, sqlTable, sqlColumn, sqlIndex, isDone)
 +                VALUES      (?, ?, ?, ?, ?)";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $data['sqlTable'],
 +            $data['sqlColumn'] ?? '',
 +            $data['sqlIndex'] ?? '',
 +            0,
 +        ]);
 +    }
 +
 +    /**
 +     * Prepares the log entry for the creation of the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function prepareTableLog(DatabaseTable $table)
 +    {
 +        $this->prepareLog(['sqlTable' => $table->getName()]);
 +    }
 +
 +    /**
 +     * Processes all tables and updates the current table layouts to match the specified layouts.
 +     *
 +     * @throws  \RuntimeException   if validation of the required layout changes fails
 +     */
 +    public function process()
 +    {
 +        $this->checkPendingLogEntries();
 +
 +        $errors = $this->validate();
 +        if (!empty($errors)) {
 +            throw new \RuntimeException(WCF::getLanguage()->getDynamicVariable(
 +                'wcf.acp.package.error.databaseChange',
 +                [
 +                    'errors' => $errors,
 +                ]
 +            ));
 +        }
 +
 +        $this->calculateChanges();
 +
 +        $this->applyChanges();
 +    }
 +
 +    /**
 +     * Checks if the relevant table layout changes can be executed and returns an array with information
 +     * on all validation errors.
 +     *
 +     * @return  array
 +     */
 +    public function validate()
 +    {
 +        $errors = [];
 +        foreach ($this->tables as $table) {
 +            if ($table->willBeDropped()) {
 +                if (\in_array($table->getName(), $this->existingTableNames)) {
 +                    if (!isset($this->tablePackageIDs[$table->getName()])) {
 +                        $errors[] = [
 +                            'tableName' => $table->getName(),
 +                            'type' => 'unregisteredTableDrop',
 +                        ];
 +                    } elseif ($this->tablePackageIDs[$table->getName()] !== $this->package->packageID) {
 +                        $errors[] = [
 +                            'tableName' => $table->getName(),
 +                            'type' => 'foreignTableDrop',
 +                        ];
 +                    }
 +                }
 +            } else {
 +                $existingTable = null;
 +                if (\in_array($table->getName(), $this->existingTableNames)) {
 +                    if (!isset($this->tablePackageIDs[$table->getName()])) {
 +                        $errors[] = [
 +                            'tableName' => $table->getName(),
 +                            'type' => 'unregisteredTableChange',
 +                        ];
 +                    } else {
 +                        $existingTable = DatabaseTable::createFromExistingTable($this->dbEditor, $table->getName());
 +                        $existingColumns = $existingTable->getColumns();
 +                        $existingIndices = $existingTable->getIndices();
 +                        $existingForeignKeys = $existingTable->getForeignKeys();
 +
 +                        foreach ($table->getColumns() as $column) {
 +                            if (isset($existingColumns[$column->getName()])) {
 +                                $columnPackageID = $this->getColumnPackageID($table, $column);
 +                                if ($column->willBeDropped()) {
 +                                    if ($columnPackageID !== $this->package->packageID) {
 +                                        $errors[] = [
 +                                            'columnName' => $column->getName(),
 +                                            'tableName' => $table->getName(),
 +                                            'type' => 'foreignColumnDrop',
 +                                        ];
 +                                    }
 +                                } elseif ($columnPackageID !== $this->package->packageID) {
 +                                    $errors[] = [
 +                                        'columnName' => $column->getName(),
 +                                        'tableName' => $table->getName(),
 +                                        'type' => 'foreignColumnChange',
 +                                    ];
 +                                }
 +                            } elseif ($column->getNewName()) {
 +                                $errors[] = [
 +                                    'columnName' => $column->getName(),
 +                                    'tableName' => $table->getName(),
 +                                    'type' => 'renameNonexistingColumn',
 +                                ];
 +                            }
 +                        }
 +
 +                        foreach ($table->getIndices() as $index) {
 +                            foreach ($existingIndices as $existingIndex) {
 +                                if (empty(\array_diff($index->getData(), $existingIndex->getData()))) {
 +                                    if ($index->willBeDropped()) {
 +                                        if ($this->getIndexPackageID($table, $index) !== $this->package->packageID) {
 +                                            $errors[] = [
 +                                                'columnNames' => \implode(',', $existingIndex->getColumns()),
 +                                                'tableName' => $table->getName(),
 +                                                'type' => 'foreignIndexDrop',
 +                                            ];
 +                                        }
 +                                    }
 +
 +                                    continue 2;
 +                                }
 +                            }
 +                        }
 +
 +                        foreach ($table->getForeignKeys() as $foreignKey) {
 +                            foreach ($existingForeignKeys as $existingForeignKey) {
 +                                if (empty(\array_diff($foreignKey->getData(), $existingForeignKey->getData()))) {
 +                                    if ($foreignKey->willBeDropped()) {
 +                                        if (
 +                                            $this->getForeignKeyPackageID(
 +                                                $table,
 +                                                $foreignKey
 +                                            ) !== $this->package->packageID
 +                                        ) {
 +                                            $errors[] = [
 +                                                'columnNames' => \implode(',', $existingForeignKey->getColumns()),
 +                                                'tableName' => $table->getName(),
 +                                                'type' => 'foreignForeignKeyDrop',
 +                                            ];
 +                                        }
 +                                    }
 +
 +                                    continue 2;
 +                                }
 +                            }
 +                        }
 +                    }
 +                }
 +
 +                foreach ($table->getIndices() as $index) {
 +                    foreach ($index->getColumns() as $indexColumn) {
 +                        $column = $this->getColumnByName($indexColumn, $table, $existingTable);
 +                        if ($column === null) {
 +                            if (!$index->willBeDropped()) {
 +                                $errors[] = [
 +                                    'columnName' => $indexColumn,
 +                                    'columnNames' => \implode(',', $index->getColumns()),
 +                                    'tableName' => $table->getName(),
 +                                    'type' => 'nonexistingColumnInIndex',
 +                                ];
 +                            }
 +                        } elseif (
 +                            $index->getType() === DatabaseTableIndex::PRIMARY_TYPE
 +                            && !$index->willBeDropped()
 +                            && !$column->isNotNull()
 +                        ) {
 +                            $errors[] = [
 +                                'columnName' => $indexColumn,
 +                                'columnNames' => \implode(',', $index->getColumns()),
 +                                'tableName' => $table->getName(),
 +                                'type' => 'nullColumnInPrimaryIndex',
 +                            ];
 +                        }
 +                    }
 +                }
 +            }
 +        }
 +
 +        return $errors;
 +    }
 +
 +    /**
 +     * Returns the column with the given name from the given table.
 +     *
 +     * @param string $columnName
 +     * @param DatabaseTable $updateTable
 +     * @param DatabaseTable|null $existingTable
 +     * @return      IDatabaseTableColumn|null
 +     * @since       5.2.10
 +     */
 +    protected function getColumnByName($columnName, DatabaseTable $updateTable, ?DatabaseTable $existingTable = null)
 +    {
 +        foreach ($updateTable->getColumns() as $column) {
 +            if (
 +                ($column->getNewName() === $columnName)
 +                || ($column->getName() === $columnName && !$column->getNewName())
 +            ) {
 +                return $column;
 +            }
 +        }
 +
 +        if ($existingTable) {
 +            foreach ($existingTable->getColumns() as $column) {
 +                if ($column->getName() === $columnName) {
 +                    return $column;
 +                }
 +            }
 +        }
 +    }
  }