From: Matthias Schmidt Date: Tue, 13 Apr 2021 14:41:02 +0000 (+0200) Subject: Merge branch '5.3' X-Git-Tag: 5.4.0_Alpha_1~96 X-Git-Url: https://git.stricted.de/?a=commitdiff_plain;h=98eddc151b3cf948629a399f65e42e955f8556d3;p=GitHub%2FWoltLab%2FWCF.git Merge branch '5.3' --- 98eddc151b3cf948629a399f65e42e955f8556d3 diff --cc wcfsetup/install/files/lib/system/database/table/DatabaseTableChangeProcessor.class.php index 6df14452b9,318d517c89..36166a641f --- a/wcfsetup/install/files/lib/system/database/table/DatabaseTableChangeProcessor.class.php +++ b/wcfsetup/install/files/lib/system/database/table/DatabaseTableChangeProcessor.class.php @@@ -15,1263 -13,1204 +15,1295 @@@ 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 - * @package WoltLabSuite\Core\System\Database\Table - * @since 5.2 + * + * @author Matthias Schmidt + * @copyright 2001-2020 WoltLab GmbH + * @license GNU Lesser General Public License + * @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 the referenced database table does not already exists, delay the - // foreign key creation until after the referenced table has been created. - if (!in_array($foreignKey->getReferencedTable(), $this->existingTableNames)) { - continue; - } - - 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) { - // Only try to create the foreign key if the referenced database table already exists. - // If it will be created later on, delay the foreign key creation until after the - // referenced table has been created. - if ( - in_array($foreignKey->getReferencedTable(), $this->existingTableNames) - || $foreignKey->getReferencedTable() === $table->getName() - ) { - $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', - ]; - } - } - } - - foreach ($table->getForeignKeys() as $foreignKey) { - $referencedTableExists = in_array($foreignKey->getReferencedTable(), $this->existingTableNames); - foreach ($this->tables as $processedTable) { - if ($processedTable->getName() === $foreignKey->getReferencedTable()) { - $referencedTableExists = !$processedTable->willBeDropped(); - } - } - - if (!$referencedTableExists) { - $errors[] = [ - 'columnNames' => implode(',', $foreignKey->getColumns()), - 'referencedTableName' => $foreignKey->getReferencedTable(), - 'tableName' => $table->getName(), - 'type' => 'unknownTableInForeignKey', - ]; - } - } - } - } - - 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 the referenced database table does not already exists, delay the ++ // foreign key creation until after the referenced table has been created. ++ if (!\in_array($foreignKey->getReferencedTable(), $this->existingTableNames)) { ++ continue; ++ } ++ + 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][] = $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()); ++ // Only try to create the foreign key if the referenced database table already exists. ++ // If it will be created later on, delay the foreign key creation until after the ++ // referenced table has been created. ++ if ( ++ \in_array($foreignKey->getReferencedTable(), $this->existingTableNames) ++ || $foreignKey->getReferencedTable() === $table->getName() ++ ) { ++ $this->dbEditor->addForeignKey($table->getName(), $foreignKey->getName(), $foreignKey->getData()); + - // foreign keys need to be explicitly logged for proper uninstallation - $this->createForeignKeyLog($table->getName(), $foreignKey); ++ // 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', + ]; + } + } ++ } ++ ++ foreach ($table->getForeignKeys() as $foreignKey) { ++ $referencedTableExists = \in_array($foreignKey->getReferencedTable(), $this->existingTableNames); ++ foreach ($this->tables as $processedTable) { ++ if ($processedTable->getName() === $foreignKey->getReferencedTable()) { ++ $referencedTableExists = !$processedTable->willBeDropped(); ++ } ++ } ++ ++ if (!$referencedTableExists) { ++ $errors[] = [ ++ 'columnNames' => \implode(',', $foreignKey->getColumns()), ++ 'referencedTableName' => $foreignKey->getReferencedTable(), ++ 'tableName' => $table->getName(), ++ 'type' => 'unknownTableInForeignKey', ++ ]; ++ } + } + } + } + + 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; + } + } + } + } } diff --cc wcfsetup/install/files/lib/util/FileUtil.class.php index 4c34710856,405f1c6d2b..626cc18856 --- a/wcfsetup/install/files/lib/util/FileUtil.class.php +++ b/wcfsetup/install/files/lib/util/FileUtil.class.php @@@ -9,743 -6,688 +9,745 @@@ use wcf\system\io\GZipFile /** * Contains file-related functions. - * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @package WoltLabSuite\Core\Util + * + * @author Marcel Werk + * @copyright 2001-2019 WoltLab GmbH + * @license GNU Lesser General Public License + * @package WoltLabSuite\Core\Util */ -final class FileUtil { - /** - * finfo instance - * @var \finfo - */ - protected static $finfo = null; - - /** - * memory limit in bytes - * @var integer - */ - protected static $memoryLimit = null; - - /** - * chmod mode - * @var string - */ - protected static $mode = null; - - /** - * Tries to find the temp folder. - * - * @return string - * @throws SystemException - */ - public static function getTempFolder() { - try { - // This method does not contain any shut up operator by intent. - // Any operation that fails here is fatal. - $path = WCF_DIR.'tmp/'; - - if (is_file($path)) { - // wat - unlink($path); - } - - if (!file_exists($path)) { - mkdir($path, 0777); - } - - if (!is_dir($path)) { - throw new SystemException("Temporary folder '".$path."' does not exist and could not be created. Please check the permissions of the '".WCF_DIR."' folder using your favorite ftp program."); - } - - if (!is_writable($path)) { - self::makeWritable($path); - } - - if (!is_writable($path)) { - throw new SystemException("Temporary folder '".$path."' is not writable. Please check the permissions using your favorite ftp program."); - } - - if (md5_file($path . '/.htaccess') !== '5cc8a02be988615b049f5abecba2f3a0') { - file_put_contents($path.'/.htaccess', 'deny from all'); - } - - return $path; - } - catch (SystemException $e) { - // use tmp folder in document root by default - if (!empty($_SERVER['DOCUMENT_ROOT'])) { - if (strpos($_SERVER['DOCUMENT_ROOT'], 'strato') !== false) { - // strato bugfix - // create tmp folder in document root automatically - if (!@file_exists($_SERVER['DOCUMENT_ROOT'].'/tmp')) { - @mkdir($_SERVER['DOCUMENT_ROOT'].'/tmp/', 0777); - self::makeWritable($_SERVER['DOCUMENT_ROOT'].'/tmp/'); - } - } - if (@file_exists($_SERVER['DOCUMENT_ROOT'].'/tmp') && @is_writable($_SERVER['DOCUMENT_ROOT'].'/tmp')) { - return $_SERVER['DOCUMENT_ROOT'].'/tmp/'; - } - } - - if (isset($_ENV['TMP']) && @is_writable($_ENV['TMP'])) { - return $_ENV['TMP'] . '/'; - } - if (isset($_ENV['TEMP']) && @is_writable($_ENV['TEMP'])) { - return $_ENV['TEMP'] . '/'; - } - if (isset($_ENV['TMPDIR']) && @is_writable($_ENV['TMPDIR'])) { - return $_ENV['TMPDIR'] . '/'; - } - - if (($path = ini_get('upload_tmp_dir')) && @is_writable($path)) { - return $path . '/'; - } - if (@file_exists('/tmp/') && @is_writable('/tmp/')) { - return '/tmp/'; - } - if (function_exists('session_save_path') && ($path = session_save_path()) && @is_writable($path)) { - return $path . '/'; - } - - throw new SystemException('There is no access to the system temporary folder due to an unknown reason and no user specific temporary folder exists in '.WCF_DIR.'! This is a misconfiguration of your webserver software! Please create a folder called '.$path.' using your favorite ftp program, make it writable and then retry this installation.'); - } - } - - /** - * Generates a new temporary filename in TMP_DIR. - * - * @param string $prefix - * @param string $extension - * @param string $dir - * @return string - */ - public static function getTemporaryFilename($prefix = 'tmpFile_', $extension = '', $dir = TMP_DIR) { - $dir = self::addTrailingSlash($dir); - do { - $tmpFile = $dir.$prefix.bin2hex(\random_bytes(20)).$extension; - } - while (file_exists($tmpFile)); - - return $tmpFile; - } - - /** - * Removes a leading slash from the given path. - * - * @param string $path - * @return string - */ - public static function removeLeadingSlash($path) { - return ltrim($path, '/'); - } - - /** - * Removes a trailing slash from the given path. - * - * @param string $path - * @return string - */ - public static function removeTrailingSlash($path) { - return rtrim($path, '/'); - } - - /** - * Adds a trailing slash to the given path. - * - * @param string $path - * @return string - */ - public static function addTrailingSlash($path) { - return rtrim($path, '/').'/'; - } - - /** - * Adds a leading slash to the given path. - * - * @param string $path - * @return string - */ - public static function addLeadingSlash($path) { - return '/'.ltrim($path, '/'); - } - - /** - * Returns the relative path from the given absolute paths. - * - * @param string $currentDir - * @param string $targetDir - * @return string - */ - public static function getRelativePath($currentDir, $targetDir) { - // remove trailing slashes - $currentDir = self::removeTrailingSlash(self::unifyDirSeparator($currentDir)); - $targetDir = self::removeTrailingSlash(self::unifyDirSeparator($targetDir)); - - if ($currentDir == $targetDir) { - return './'; - } - - $current = explode('/', $currentDir); - $target = explode('/', $targetDir); - - $relPath = ''; - //for ($i = max(count($current), count($target)) - 1; $i >= 0; $i--) { - for ($i = 0, $max = max(count($current), count($target)); $i < $max; $i++) { - if (isset($current[$i]) && isset($target[$i])) { - if ($current[$i] != $target[$i]) { - for ($j = 0; $j < $i; $j++) { - unset($target[$j]); - } - $relPath .= str_repeat('../', count($current) - $i).implode('/', $target).'/'; - - break; - } - } - // go up one level - else if (isset($current[$i]) && !isset($target[$i])) { - $relPath .= '../'; - } - else if (!isset($current[$i]) && isset($target[$i])) { - $relPath .= $target[$i].'/'; - } - } - - return $relPath; - } - - /** - * Creates a path on the local filesystem and returns true on success. - * Parent directories do not need to exists as they will be created if - * necessary. - * - * @param string $path - * @return boolean - */ - public static function makePath($path) { - // directory already exists, abort - if (file_exists($path)) { - return false; - } - - // check if parent directory exists - $parent = dirname($path); - if ($parent != $path) { - // parent directory does not exist either - // we have to create the parent directory first - $parent = self::addTrailingSlash($parent); - if (!@file_exists($parent)) { - // could not create parent directory either => abort - if (!self::makePath($parent)) { - return false; - } - } - - // well, the parent directory exists or has been created - // lets create this path - if (!@mkdir($path)) { - return false; - } - - self::makeWritable($path); - - return true; - } - - return false; - } - - /** - * Unifies windows and unix directory separators. - * - * @param string $path - * @return string - */ - public static function unifyDirSeparator($path) { - $path = str_replace('\\\\', '/', $path); - $path = str_replace('\\', '/', $path); - return $path; - } - - /** - * Scans a folder (and subfolder) for a specific file. - * Returns the filename if found, otherwise false. - * - * @param string $folder - * @param string $searchfile - * @param boolean $recursive - * @return mixed - */ - public static function scanFolder($folder, $searchfile, $recursive = true) { - if (!@is_dir($folder)) { - return false; - } - if (!$searchfile) { - return false; - } - - $folder = self::addTrailingSlash($folder); - $dirh = @opendir($folder); - while ($filename = @readdir($dirh)) { - if ($filename == '.' || $filename == '..') { - continue; - } - if ($filename == $searchfile) { - @closedir($dirh); - return $folder.$filename; - } - - if ($recursive == true && @is_dir($folder.$filename)) { - if ($found = self::scanFolder($folder.$filename, $searchfile, $recursive)) { - @closedir($dirh); - return $found; - } - } - } - @closedir($dirh); - } - - /** - * Returns true if the given filename is an url (http or ftp). - * - * @param string $filename - * @return boolean - */ - public static function isURL($filename) { - return preg_match('!^(https?|ftp)://!', $filename); - } - - /** - * Returns canonicalized absolute pathname. - * - * @param string $path - * @return string path - */ - public static function getRealPath($path) { - $path = self::unifyDirSeparator($path); - - $result = []; - $pathA = explode('/', $path); - if ($pathA[0] === '') { - $result[] = ''; - } - - foreach ($pathA as $key => $dir) { - if ($dir == '..') { - if (end($result) == '..') { - $result[] = '..'; - } - else { - $lastValue = array_pop($result); - if ($lastValue === '' || $lastValue === null) { - $result[] = '..'; - } - } - } - else if ($dir !== '' && $dir != '.') { - $result[] = $dir; - } - } - - $lastValue = end($pathA); - if ($lastValue === '' || $lastValue === false) { - $result[] = ''; - } - - return implode('/', $result); - } - - /** - * Formats the given filesize. - * - * @param integer $byte - * @param integer $precision - * @return string - */ - public static function formatFilesize($byte, $precision = 2) { - $symbol = 'Byte'; - if ($byte >= 1000) { - $byte /= 1000; - $symbol = 'kB'; - } - if ($byte >= 1000) { - $byte /= 1000; - $symbol = 'MB'; - } - if ($byte >= 1000) { - $byte /= 1000; - $symbol = 'GB'; - } - if ($byte >= 1000) { - $byte /= 1000; - $symbol = 'TB'; - } - - return StringUtil::formatNumeric(round($byte, $precision)).' '.$symbol; - } - - /** - * Formats a filesize with binary prefix. - * - * For more information: - * - * @param integer $byte - * @param integer $precision - * @return string - */ - public static function formatFilesizeBinary($byte, $precision = 2) { - $symbol = 'Byte'; - if ($byte >= 1024) { - $byte /= 1024; - $symbol = 'KiB'; - } - if ($byte >= 1024) { - $byte /= 1024; - $symbol = 'MiB'; - } - if ($byte >= 1024) { - $byte /= 1024; - $symbol = 'GiB'; - } - if ($byte >= 1024) { - $byte /= 1024; - $symbol = 'TiB'; - } - - return StringUtil::formatNumeric(round($byte, $precision)).' '.$symbol; - } - - /** - * Downloads a package archive from an http URL and returns the path to - * the downloaded file. - * - * @param string $httpUrl - * @param string $prefix - * @param array $options - * @param array $postParameters - * @param array $headers empty array or a not initialized variable - * @return string - * @deprecated This method currently only is a wrapper around \wcf\util\HTTPRequest. Please use - * HTTPRequest from now on, as this method may be removed in the future. - */ - public static function downloadFileFromHttp($httpUrl, $prefix = 'package', array $options = [], array $postParameters = [], &$headers = []) { - $request = new HTTPRequest($httpUrl, $options, $postParameters); - $request->execute(); - $reply = $request->getReply(); - - $newFileName = self::getTemporaryFilename($prefix.'_'); - file_put_contents($newFileName, $reply['body']); // the file to write. - - $tmp = $reply['headers']; // copy variable, to avoid problems with the reference - $headers = $tmp; - - return $newFileName; - } - - /** - * Determines whether a file is text or binary by checking the first few bytes in the file. - * The exact number of bytes is system dependent, but it is typically several thousand. - * If every byte in that part of the file is non-null, considers the file to be text; - * otherwise it considers the file to be binary. - * - * @param string $file - * @return boolean - */ - public static function isBinary($file) { - // open file - $file = new File($file, 'rb'); - - // get block size - $stat = $file->stat(); - $blockSize = $stat['blksize']; - if ($blockSize < 0) $blockSize = 1024; - if ($blockSize > $file->filesize()) $blockSize = $file->filesize(); - if ($blockSize <= 0) return false; - - // get bytes - $block = $file->read($blockSize); - return (strlen($block) == 0 || strpos($block, "\0") !== false); - } - - /** - * Uncompresses a gzipped file and returns true if successful. - * - * @param string $gzipped - * @param string $destination - * @return boolean - */ - public static function uncompressFile($gzipped, $destination) { - if (!@is_file($gzipped)) { - return false; - } - - $sourceFile = new GZipFile($gzipped, 'rb'); - //$filesize = $sourceFile->getFileSize(); - $targetFile = new File($destination); - while (!$sourceFile->eof()) { - $targetFile->write($sourceFile->read(512), 512); - } - $targetFile->close(); - $sourceFile->close(); - - self::makeWritable($destination); - - return true; - } - - /** - * Returns true if php is running as apache module. - * - * @return boolean - */ - public static function isApacheModule() { - return function_exists('apache_get_version'); - } - - /** - * Returns the mime type of a file. - * - * @param string $filename - * @return string - */ - public static function getMimeType($filename) { - if (self::$finfo === null) { - if (!class_exists('\finfo', false)) return 'application/octet-stream'; - self::$finfo = new \finfo(FILEINFO_MIME_TYPE); - } - - // \finfo->file() can fail for files that contain only 1 byte, because libmagic expects at least - // a few bytes in order to determine the type. See https://bugs.php.net/bug.php?id=64684 - $mimeType = @self::$finfo->file($filename); - return $mimeType ?: 'application/octet-stream'; - } - - /** - * Tries to make a file or directory writable. It starts of with the least - * permissions and goes up until 0666 for files and 0777 for directories. - * - * @param string $filename - * @throws SystemException - */ - public static function makeWritable($filename) { - if (!file_exists($filename)) { - return; - } - - if (self::$mode === null) { - // WCFSetup - if (defined('INSTALL_SCRIPT') && file_exists(INSTALL_SCRIPT)) { - // do not use PHP_OS here, as this represents the system it was built on != running on - // php_uname() is forbidden on some strange hosts; PHP_EOL is reliable - if (PHP_EOL == "\r\n") { - // Windows - self::$mode = '0777'; - } - else { - // anything but Windows - clearstatcache(); - - self::$mode = '0666'; - - $tmpFilename = '__permissions_'.sha1((string) time()).'.txt'; - @touch($tmpFilename); - - // create a new file and check the file owner, if it is the same - // as this file (uploaded through FTP), we can safely grant write - // permissions exclusively to the owner rather than everyone - if (file_exists($tmpFilename)) { - $scriptOwner = fileowner(INSTALL_SCRIPT); - $fileOwner = fileowner($tmpFilename); - - if ($scriptOwner === $fileOwner) { - self::$mode = '0644'; - } - - @unlink($tmpFilename); - } - } - } - else { - // mirror permissions of WCF.class.php - if (!file_exists(WCF_DIR . 'lib/system/WCF.class.php')) { - throw new SystemException("Unable to find 'wcf/lib/system/WCF.class.php'."); - } - - self::$mode = '0' . substr(sprintf('%o', fileperms(WCF_DIR . 'lib/system/WCF.class.php')), -3); - } - } - - if (is_dir($filename)) { - if (self::$mode == '0644') { - @chmod($filename, 0755); - } - else { - @chmod($filename, 0777); - } - } - else { - @chmod($filename, octdec(self::$mode)); - } - - if (!is_writable($filename)) { - // does not work with 0777 - throw new SystemException("Unable to make '".$filename."' writable. This is a misconfiguration of your server, please contact your system administrator or hosting provider."); - } - } - - /** - * Returns memory limit in bytes. - * - * @return integer - */ - public static function getMemoryLimit() { - if (self::$memoryLimit === null) { - self::$memoryLimit = 0; - - $memoryLimit = ini_get('memory_limit'); - - // no limit - if ($memoryLimit == -1) { - self::$memoryLimit = -1; - } - - // completely numeric, PHP assumes byte - if (is_numeric($memoryLimit)) { - self::$memoryLimit = $memoryLimit; - } - - // PHP supports 'K', 'M' and 'G' shorthand notation - if (preg_match('~^(\d+)\s*([KMG])$~i', $memoryLimit, $matches)) { - switch (strtoupper($matches[2])) { - case 'K': - self::$memoryLimit = $matches[1] * 1024; - break; - - case 'M': - self::$memoryLimit = $matches[1] * 1024 * 1024; - break; - - case 'G': - self::$memoryLimit = $matches[1] * 1024 * 1024 * 1024; - break; - } - } - } - - return self::$memoryLimit; - } - - /** - * Returns true if the given amount of memory is available. - * - * @param integer $neededMemory - * @return boolean - */ - public static function checkMemoryLimit($neededMemory) { - return self::getMemoryLimit() == -1 || self::getMemoryLimit() > (memory_get_usage() + $neededMemory); - } - - /** - * Returns icon name for given filename. - * - * @param string $filename - * @return string - */ - public static function getIconNameByFilename($filename) { - static $mapping = [ - // archive - 'zip' => 'archive', 'rar' => 'archive', 'tar' => 'archive', 'gz' => 'archive', - // audio - 'mp3' => 'audio', 'ogg' => 'audio', 'wav' => 'audio', - // code - 'php' => 'code', 'html' => 'code', 'htm' => 'code', 'tpl' => 'code', 'js' => 'code', - // excel - 'xls' => 'excel', 'ods' => 'excel', 'xlsx' => 'excel', - // image - 'gif' => 'image', 'jpg' => 'image', 'jpeg' => 'image', 'png' => 'image', 'bmp' => 'image', - // video - 'avi' => 'video', 'wmv' => 'video', 'mov' => 'video', 'mp4' => 'video', 'mpg' => 'video', 'mpeg' => 'video', 'flv' => 'video', - // pdf - 'pdf' => 'pdf', - // powerpoint - 'ppt' => 'powerpoint', 'pptx' => 'powerpoint', - // text - 'txt' => 'text', - // word - 'doc' => 'word', 'docx' => 'word', 'odt' => 'word' - ]; - - $lastDotPosition = strrpos($filename, '.'); - if ($lastDotPosition !== false) { - $extension = substr($filename, $lastDotPosition + 1); - if (isset($mapping[$extension])) { - return $mapping[$extension]; - } - } - - return ''; - } - - /** - * Forbid creation of FileUtil objects. - */ - private function __construct() { - // does nothing - } +final class FileUtil +{ + /** + * finfo instance + * @var \finfo + */ + protected static $finfo = null; + + /** + * memory limit in bytes + * @var int + */ + protected static $memoryLimit = null; + + /** + * chmod mode + * @var string + */ + protected static $mode = null; + + /** + * Tries to find the temp folder. + * + * @return string + * @throws SystemException + */ + public static function getTempFolder() + { + try { + // This method does not contain any shut up operator by intent. + // Any operation that fails here is fatal. + $path = WCF_DIR . 'tmp/'; + + if (\is_file($path)) { + // wat + \unlink($path); + } + + if (!\file_exists($path)) { + \mkdir($path, 0777); + } + + if (!\is_dir($path)) { + throw new SystemException("Temporary folder '" . $path . "' does not exist and could not be created. Please check the permissions of the '" . WCF_DIR . "' folder using your favorite ftp program."); + } + + if (!\is_writable($path)) { + self::makeWritable($path); + } + + if (!\is_writable($path)) { + throw new SystemException("Temporary folder '" . $path . "' is not writable. Please check the permissions using your favorite ftp program."); + } + - \file_put_contents($path . '/.htaccess', 'deny from all'); ++ if (\md5_file($path . '/.htaccess') !== '5cc8a02be988615b049f5abecba2f3a0') { ++ \file_put_contents($path . '/.htaccess', 'deny from all'); ++ } + + return $path; + } catch (SystemException $e) { + // use tmp folder in document root by default + if (!empty($_SERVER['DOCUMENT_ROOT'])) { + if (\strpos($_SERVER['DOCUMENT_ROOT'], 'strato') !== false) { + // strato bugfix + // create tmp folder in document root automatically + if (!@\file_exists($_SERVER['DOCUMENT_ROOT'] . '/tmp')) { + @\mkdir($_SERVER['DOCUMENT_ROOT'] . '/tmp/', 0777); + self::makeWritable($_SERVER['DOCUMENT_ROOT'] . '/tmp/'); + } + } + if (@\file_exists($_SERVER['DOCUMENT_ROOT'] . '/tmp') && @\is_writable($_SERVER['DOCUMENT_ROOT'] . '/tmp')) { + return $_SERVER['DOCUMENT_ROOT'] . '/tmp/'; + } + } + + if (isset($_ENV['TMP']) && @\is_writable($_ENV['TMP'])) { + return $_ENV['TMP'] . '/'; + } + if (isset($_ENV['TEMP']) && @\is_writable($_ENV['TEMP'])) { + return $_ENV['TEMP'] . '/'; + } + if (isset($_ENV['TMPDIR']) && @\is_writable($_ENV['TMPDIR'])) { + return $_ENV['TMPDIR'] . '/'; + } + + if (($path = \ini_get('upload_tmp_dir')) && @\is_writable($path)) { + return $path . '/'; + } + if (@\file_exists('/tmp/') && @\is_writable('/tmp/')) { + return '/tmp/'; + } + if (\function_exists('session_save_path') && ($path = \session_save_path()) && @\is_writable($path)) { + return $path . '/'; + } + + throw new SystemException('There is no access to the system temporary folder due to an unknown reason and no user specific temporary folder exists in ' . WCF_DIR . '! This is a misconfiguration of your webserver software! Please create a folder called ' . $path . ' using your favorite ftp program, make it writable and then retry this installation.'); + } + } + + /** + * Generates a new temporary filename in TMP_DIR. + * + * @param string $prefix + * @param string $extension + * @param string $dir + * @return string + */ + public static function getTemporaryFilename($prefix = 'tmpFile_', $extension = '', $dir = TMP_DIR) + { + $dir = self::addTrailingSlash($dir); + do { + $tmpFile = $dir . $prefix . Hex::encode(\random_bytes(20)) . $extension; + } while (\file_exists($tmpFile)); + + return $tmpFile; + } + + /** + * Removes a leading slash from the given path. + * + * @param string $path + * @return string + */ + public static function removeLeadingSlash($path) + { + return \ltrim($path, '/'); + } + + /** + * Removes a trailing slash from the given path. + * + * @param string $path + * @return string + */ + public static function removeTrailingSlash($path) + { + return \rtrim($path, '/'); + } + + /** + * Adds a trailing slash to the given path. + * + * @param string $path + * @return string + */ + public static function addTrailingSlash($path) + { + return \rtrim($path, '/') . '/'; + } + + /** + * Adds a leading slash to the given path. + * + * @param string $path + * @return string + */ + public static function addLeadingSlash($path) + { + return '/' . \ltrim($path, '/'); + } + + /** + * Returns the relative path from the given absolute paths. + * + * @param string $currentDir + * @param string $targetDir + * @return string + */ + public static function getRelativePath($currentDir, $targetDir) + { + // remove trailing slashes + $currentDir = self::removeTrailingSlash(self::unifyDirSeparator($currentDir)); + $targetDir = self::removeTrailingSlash(self::unifyDirSeparator($targetDir)); + + if ($currentDir == $targetDir) { + return './'; + } + + $current = \explode('/', $currentDir); + $target = \explode('/', $targetDir); + + $relPath = ''; + //for ($i = max(count($current), count($target)) - 1; $i >= 0; $i--) { + for ($i = 0, $max = \max(\count($current), \count($target)); $i < $max; $i++) { + if (isset($current[$i]) && isset($target[$i])) { + if ($current[$i] != $target[$i]) { + for ($j = 0; $j < $i; $j++) { + unset($target[$j]); + } + $relPath .= \str_repeat('../', \count($current) - $i) . \implode('/', $target) . '/'; + + break; + } + } // go up one level + elseif (isset($current[$i]) && !isset($target[$i])) { + $relPath .= '../'; + } elseif (!isset($current[$i]) && isset($target[$i])) { + $relPath .= $target[$i] . '/'; + } + } + + return $relPath; + } + + /** + * Creates a path on the local filesystem and returns true on success. + * Parent directories do not need to exists as they will be created if + * necessary. + * + * @param string $path + * @return bool + */ + public static function makePath($path) + { + // directory already exists, abort + if (\file_exists($path)) { + return false; + } + + // check if parent directory exists + $parent = \dirname($path); + if ($parent != $path) { + // parent directory does not exist either + // we have to create the parent directory first + $parent = self::addTrailingSlash($parent); + if (!@\file_exists($parent)) { + // could not create parent directory either => abort + if (!self::makePath($parent)) { + return false; + } + } + + // well, the parent directory exists or has been created + // lets create this path + if (!@\mkdir($path)) { + return false; + } + + self::makeWritable($path); + + return true; + } + + return false; + } + + /** + * Unifies windows and unix directory separators. + * + * @param string $path + * @return string + */ + public static function unifyDirSeparator($path) + { + $path = \str_replace('\\\\', '/', $path); + + return \str_replace('\\', '/', $path); + } + + /** + * Scans a folder (and subfolder) for a specific file. + * Returns the filename if found, otherwise false. + * + * @param string $folder + * @param string $searchfile + * @param bool $recursive + * @return mixed + */ + public static function scanFolder($folder, $searchfile, $recursive = true) + { + if (!@\is_dir($folder)) { + return false; + } + if (!$searchfile) { + return false; + } + + $folder = self::addTrailingSlash($folder); + $dirh = @\opendir($folder); + while ($filename = @\readdir($dirh)) { + if ($filename == '.' || $filename == '..') { + continue; + } + if ($filename == $searchfile) { + @\closedir($dirh); + + return $folder . $filename; + } + + if ($recursive == true && @\is_dir($folder . $filename)) { + if ($found = self::scanFolder($folder . $filename, $searchfile, $recursive)) { + @\closedir($dirh); + + return $found; + } + } + } + @\closedir($dirh); + } + + /** + * Returns true if the given filename is an url (http or ftp). + * + * @param string $filename + * @return bool + */ + public static function isURL($filename) + { + return \preg_match('!^(https?|ftp)://!', $filename); + } + + /** + * Returns canonicalized absolute pathname. + * + * @param string $path + * @return string path + */ + public static function getRealPath($path) + { + $path = self::unifyDirSeparator($path); + + $result = []; + $pathA = \explode('/', $path); + if ($pathA[0] === '') { + $result[] = ''; + } + + foreach ($pathA as $key => $dir) { + if ($dir == '..') { + if (\end($result) == '..') { + $result[] = '..'; + } else { + $lastValue = \array_pop($result); + if ($lastValue === '' || $lastValue === null) { + $result[] = '..'; + } + } + } elseif ($dir !== '' && $dir != '.') { + $result[] = $dir; + } + } + + $lastValue = \end($pathA); + if ($lastValue === '' || $lastValue === false) { + $result[] = ''; + } + + return \implode('/', $result); + } + + /** + * Formats the given filesize. + * + * @param int $byte + * @param int $precision + * @return string + */ + public static function formatFilesize($byte, $precision = 2) + { + $symbol = 'Byte'; + if ($byte >= 1000) { + $byte /= 1000; + $symbol = 'kB'; + } + if ($byte >= 1000) { + $byte /= 1000; + $symbol = 'MB'; + } + if ($byte >= 1000) { + $byte /= 1000; + $symbol = 'GB'; + } + if ($byte >= 1000) { + $byte /= 1000; + $symbol = 'TB'; + } + + return StringUtil::formatNumeric(\round($byte, $precision)) . ' ' . $symbol; + } + + /** + * Formats a filesize with binary prefix. + * + * For more information: + * + * @param int $byte + * @param int $precision + * @return string + */ + public static function formatFilesizeBinary($byte, $precision = 2) + { + $symbol = 'Byte'; + if ($byte >= 1024) { + $byte /= 1024; + $symbol = 'KiB'; + } + if ($byte >= 1024) { + $byte /= 1024; + $symbol = 'MiB'; + } + if ($byte >= 1024) { + $byte /= 1024; + $symbol = 'GiB'; + } + if ($byte >= 1024) { + $byte /= 1024; + $symbol = 'TiB'; + } + + return StringUtil::formatNumeric(\round($byte, $precision)) . ' ' . $symbol; + } + + /** + * Downloads a package archive from an http URL and returns the path to + * the downloaded file. + * + * @param string $httpUrl + * @param string $prefix + * @param array $options + * @param array $postParameters + * @param array $headers empty array or a not initialized variable + * @return string + * @deprecated This method currently only is a wrapper around \wcf\util\HTTPRequest. Please use + * HTTPRequest from now on, as this method may be removed in the future. + */ + public static function downloadFileFromHttp( + $httpUrl, + $prefix = 'package', + array $options = [], + array $postParameters = [], + &$headers = [] + ) { + $request = new HTTPRequest($httpUrl, $options, $postParameters); + $request->execute(); + $reply = $request->getReply(); + + $newFileName = self::getTemporaryFilename($prefix . '_'); + \file_put_contents($newFileName, $reply['body']); // the file to write. + + $tmp = $reply['headers']; // copy variable, to avoid problems with the reference + $headers = $tmp; + + return $newFileName; + } + + /** + * Determines whether a file is text or binary by checking the first few bytes in the file. + * The exact number of bytes is system dependent, but it is typically several thousand. + * If every byte in that part of the file is non-null, considers the file to be text; + * otherwise it considers the file to be binary. + * + * @param string $file + * @return bool + */ + public static function isBinary($file) + { + // open file + $file = new File($file, 'rb'); + + // get block size + $stat = $file->stat(); + $blockSize = $stat['blksize']; + if ($blockSize < 0) { + $blockSize = 1024; + } + if ($blockSize > $file->filesize()) { + $blockSize = $file->filesize(); + } + if ($blockSize <= 0) { + return false; + } + + // get bytes + $block = $file->read($blockSize); + + return \strlen($block) == 0 || \strpos($block, "\0") !== false; + } + + /** + * Uncompresses a gzipped file and returns true if successful. + * + * @param string $gzipped + * @param string $destination + * @return bool + */ + public static function uncompressFile($gzipped, $destination) + { + if (!@\is_file($gzipped)) { + return false; + } + + $sourceFile = new GZipFile($gzipped, 'rb'); + //$filesize = $sourceFile->getFileSize(); + $targetFile = new File($destination); + while (!$sourceFile->eof()) { + $targetFile->write($sourceFile->read(512), 512); + } + $targetFile->close(); + $sourceFile->close(); + + self::makeWritable($destination); + + return true; + } + + /** + * Returns true if php is running as apache module. + * + * @return bool + */ + public static function isApacheModule() + { + return \function_exists('apache_get_version'); + } + + /** + * Returns the mime type of a file. + * + * @param string $filename + * @return string + */ + public static function getMimeType($filename) + { + if (self::$finfo === null) { + if (!\class_exists('\finfo', false)) { + return 'application/octet-stream'; + } + self::$finfo = new \finfo(\FILEINFO_MIME_TYPE); + } + + // \finfo->file() can fail for files that contain only 1 byte, because libmagic expects at least + // a few bytes in order to determine the type. See https://bugs.php.net/bug.php?id=64684 + $mimeType = @self::$finfo->file($filename); + + return $mimeType ?: 'application/octet-stream'; + } + + /** + * Tries to make a file or directory writable. It starts of with the least + * permissions and goes up until 0666 for files and 0777 for directories. + * + * @param string $filename + * @throws SystemException + */ + public static function makeWritable($filename) + { + if (!\file_exists($filename)) { + return; + } + + if (self::$mode === null) { + // WCFSetup + if (\defined('INSTALL_SCRIPT') && \file_exists(INSTALL_SCRIPT)) { + // do not use PHP_OS here, as this represents the system it was built on != running on + // php_uname() is forbidden on some strange hosts; PHP_EOL is reliable + if (\PHP_EOL == "\r\n") { + // Windows + self::$mode = '0777'; + } else { + // anything but Windows + \clearstatcache(); + + self::$mode = '0666'; + + $tmpFilename = '__permissions_' . \sha1((string)\time()) . '.txt'; + @\touch($tmpFilename); + + // create a new file and check the file owner, if it is the same + // as this file (uploaded through FTP), we can safely grant write + // permissions exclusively to the owner rather than everyone + if (\file_exists($tmpFilename)) { + $scriptOwner = \fileowner(INSTALL_SCRIPT); + $fileOwner = \fileowner($tmpFilename); + + if ($scriptOwner === $fileOwner) { + self::$mode = '0644'; + } + + @\unlink($tmpFilename); + } + } + } else { + // mirror permissions of WCF.class.php + if (!\file_exists(WCF_DIR . 'lib/system/WCF.class.php')) { + throw new SystemException("Unable to find 'wcf/lib/system/WCF.class.php'."); + } + + self::$mode = '0' . \substr(\sprintf('%o', \fileperms(WCF_DIR . 'lib/system/WCF.class.php')), -3); + } + } + + if (\is_dir($filename)) { + if (self::$mode == '0644') { + @\chmod($filename, 0755); + } else { + @\chmod($filename, 0777); + } + } else { + @\chmod($filename, \octdec(self::$mode)); + } + + if (!\is_writable($filename)) { + // does not work with 0777 + throw new SystemException("Unable to make '" . $filename . "' writable. This is a misconfiguration of your server, please contact your system administrator or hosting provider."); + } + } + + /** + * Returns memory limit in bytes. + * + * @return int + */ + public static function getMemoryLimit() + { + if (self::$memoryLimit === null) { + self::$memoryLimit = 0; + + $memoryLimit = \ini_get('memory_limit'); + + // no limit + if ($memoryLimit == -1) { + self::$memoryLimit = -1; + } + + // completely numeric, PHP assumes byte + if (\is_numeric($memoryLimit)) { + self::$memoryLimit = $memoryLimit; + } + + // PHP supports 'K', 'M' and 'G' shorthand notation + if (\preg_match('~^(\d+)\s*([KMG])$~i', $memoryLimit, $matches)) { + switch (\strtoupper($matches[2])) { + case 'K': + self::$memoryLimit = $matches[1] * 1024; + break; + + case 'M': + self::$memoryLimit = $matches[1] * 1024 * 1024; + break; + + case 'G': + self::$memoryLimit = $matches[1] * 1024 * 1024 * 1024; + break; + } + } + } + + return self::$memoryLimit; + } + + /** + * Returns true if the given amount of memory is available. + * + * @param int $neededMemory + * @return bool + */ + public static function checkMemoryLimit($neededMemory) + { + return self::getMemoryLimit() == -1 || self::getMemoryLimit() > (\memory_get_usage() + $neededMemory); + } + + /** + * Returns icon name for given filename. + * + * @param string $filename + * @return string + */ + public static function getIconNameByFilename($filename) + { + static $mapping = [ + // archive + 'zip' => 'archive', + 'rar' => 'archive', + 'tar' => 'archive', + 'gz' => 'archive', + // audio + 'mp3' => 'audio', + 'ogg' => 'audio', + 'wav' => 'audio', + // code + 'php' => 'code', + 'html' => 'code', + 'htm' => 'code', + 'tpl' => 'code', + 'js' => 'code', + // excel + 'xls' => 'excel', + 'ods' => 'excel', + 'xlsx' => 'excel', + // image + 'gif' => 'image', + 'jpg' => 'image', + 'jpeg' => 'image', + 'png' => 'image', + 'bmp' => 'image', + 'webp' => 'image', + // video + 'avi' => 'video', + 'wmv' => 'video', + 'mov' => 'video', + 'mp4' => 'video', + 'mpg' => 'video', + 'mpeg' => 'video', + 'flv' => 'video', + // pdf + 'pdf' => 'pdf', + // powerpoint + 'ppt' => 'powerpoint', + 'pptx' => 'powerpoint', + // text + 'txt' => 'text', + // word + 'doc' => 'word', + 'docx' => 'word', + 'odt' => 'word', + ]; + + $lastDotPosition = \strrpos($filename, '.'); + if ($lastDotPosition !== false) { + $extension = \substr($filename, $lastDotPosition + 1); + if (isset($mapping[$extension])) { + return $mapping[$extension]; + } + } + + return ''; + } + + /** + * Forbid creation of FileUtil objects. + */ + private function __construct() + { + // does nothing + } }