/**
* Processes a given set of changes to database tables.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @package WoltLabSuite\Core\System\Database\Table
- * @since 5.2
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Database\Table
+ * @since 5.2
*/
-class DatabaseTableChangeProcessor {
- /**
- * maps the registered database table column names to the ids of the packages they belong to
- * @var int[][]
- */
- protected $columnPackageIDs = [];
-
- /**
- * database table columns that will be added grouped by the name of the table to which they
- * will be added
- * @var IDatabaseTableColumn[][]
- */
- protected $columnsToAdd = [];
-
- /**
- * database table columns that will be altered grouped by the name of the table to which
- * they belong
- * @var IDatabaseTableColumn[][]
- */
- protected $columnsToAlter = [];
-
- /**
- * database table columns that will be dropped grouped by the name of the table from which
- * they will be dropped
- * @var IDatabaseTableColumn[][]
- */
- protected $columnsToDrop = [];
-
- /**
- * database editor to apply the relevant changes to the table layouts
- * @var DatabaseEditor
- */
- protected $dbEditor;
-
- /**
- * list of all existing tables in the used database
- * @var string[]
- */
- protected $existingTableNames = [];
-
- /**
- * existing database tables
- * @var DatabaseTable[]
- */
- protected $existingTables = [];
-
- /**
- * maps the registered database table index names to the ids of the packages they belong to
- * @var int[][]
- */
- protected $indexPackageIDs = [];
-
- /**
- * indices that will be added grouped by the name of the table to which they will be added
- * @var DatabaseTableIndex[][]
- */
- protected $indicesToAdd = [];
-
- /**
- * indices that will be dropped grouped by the name of the table from which they will be dropped
- * @var DatabaseTableIndex[][]
- */
- protected $indicesToDrop = [];
-
- /**
- * maps the registered database table foreign key names to the ids of the packages they belong to
- * @var int[][]
- */
- protected $foreignKeyPackageIDs = [];
-
- /**
- * foreign keys that will be added grouped by the name of the table to which they will be
- * added
- * @var DatabaseTableForeignKey[][]
- */
- protected $foreignKeysToAdd = [];
-
- /**
- * foreign keys that will be dropped grouped by the name of the table from which they will
- * be dropped
- * @var DatabaseTableForeignKey[][]
- */
- protected $foreignKeysToDrop = [];
-
- /**
- * package that wants to apply the changes
- * @var Package
- */
- protected $package;
-
- /**
- * message for the split node exception thrown after the changes have been applied
- * @var string
- */
- protected $splitNodeMessage = '';
-
- /**
- * layouts/layout changes of the relevant database table
- * @var DatabaseTable[]
- */
- protected $tables;
-
- /**
- * maps the registered database table names to the ids of the packages they belong to
- * @var int[]
- */
- protected $tablePackageIDs = [];
-
- /**
- * database table that will be created
- * @var DatabaseTable[]
- */
- protected $tablesToCreate = [];
-
- /**
- * database tables that will be dropped
- * @var DatabaseTable[]
- */
- protected $tablesToDrop = [];
-
- /**
- * Creates a new instance of `DatabaseTableChangeProcessor`.
- *
- * @param Package $package
- * @param DatabaseTable[] $tables
- * @param DatabaseEditor $dbEditor
- */
- public function __construct(Package $package, array $tables, DatabaseEditor $dbEditor) {
- $this->package = $package;
-
- $tableNames = [];
- foreach ($tables as $table) {
- if (!($table instanceof DatabaseTable)) {
- throw new \InvalidArgumentException("Tables must be instance of '" . DatabaseTable::class . "'");
- }
-
- $tableNames[] = $table->getName();
- }
-
- $this->tables = $tables;
- $this->dbEditor = $dbEditor;
-
- $this->existingTableNames = $dbEditor->getTableNames();
-
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add('sqlTable IN (?)', [$tableNames]);
- $conditionBuilder->add('isDone = ?', [1]);
-
- $sql = "SELECT *
- FROM wcf" . WCF_N . "_package_installation_sql_log
- " . $conditionBuilder;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute($conditionBuilder->getParameters());
-
- while ($row = $statement->fetchArray()) {
- if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
- $this->tablePackageIDs[$row['sqlTable']] = $row['packageID'];
- }
- else if ($row['sqlIndex'] === '') {
- $this->columnPackageIDs[$row['sqlTable']][$row['sqlColumn']] = $row['packageID'];
- }
- else if (substr($row['sqlIndex'], -3) === '_fk') {
- $this->foreignKeyPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
- }
- else {
- $this->indexPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
- }
- }
- }
-
- /**
- * Adds the given index to the table.
- *
- * @param string $tableName
- * @param DatabaseTableForeignKey $foreignKey
- */
- protected function addForeignKey($tableName, DatabaseTableForeignKey $foreignKey) {
- $this->dbEditor->addForeignKey($tableName, $foreignKey->getName(), $foreignKey->getData());
- }
-
- /**
- * Adds the given index to the table.
- *
- * @param string $tableName
- * @param DatabaseTableIndex $index
- */
- protected function addIndex($tableName, DatabaseTableIndex $index) {
- $this->dbEditor->addIndex($tableName, $index->getName(), $index->getData());
- }
-
- /**
- * Applies all of the previously determined changes to achieve the desired database layout.
- *
- * @throws SplitNodeException if any change has been applied
- */
- protected function applyChanges() {
- $appliedAnyChange = false;
-
- foreach ($this->tablesToCreate as $table) {
- $appliedAnyChange = true;
-
- $this->prepareTableLog($table);
- $this->createTable($table);
- $this->finalizeTableLog($table);
- }
-
- foreach ($this->tablesToDrop as $table) {
- $appliedAnyChange = true;
-
- $this->dropTable($table);
- $this->deleteTableLog($table);
- }
-
- $columnTables = array_unique(array_merge(
- array_keys($this->columnsToAdd),
- array_keys($this->columnsToAlter),
- array_keys($this->columnsToDrop)
- ));
- foreach ($columnTables as $tableName) {
- $appliedAnyChange = true;
-
- $columnsToAdd = $this->columnsToAdd[$tableName] ?? [];
- $columnsToAlter = $this->columnsToAlter[$tableName] ?? [];
- $columnsToDrop = $this->columnsToDrop[$tableName] ?? [];
-
- foreach ($columnsToAdd as $column) {
- $this->prepareColumnLog($tableName, $column);
- }
-
- $this->applyColumnChanges(
- $tableName,
- $columnsToAdd,
- $columnsToAlter,
- $columnsToDrop
- );
-
- foreach ($columnsToAdd as $column) {
- $this->finalizeColumnLog($tableName, $column);
- }
-
- foreach ($columnsToDrop as $column) {
- $this->deleteColumnLog($tableName, $column);
- }
- }
-
- foreach ($this->foreignKeysToDrop as $tableName => $foreignKeys) {
- foreach ($foreignKeys as $foreignKey) {
- $appliedAnyChange = true;
-
- $this->dropForeignKey($tableName, $foreignKey);
- $this->deleteForeignKeyLog($tableName, $foreignKey);
- }
- }
-
- foreach ($this->foreignKeysToAdd as $tableName => $foreignKeys) {
- foreach ($foreignKeys as $foreignKey) {
- $appliedAnyChange = true;
-
- $this->prepareForeignKeyLog($tableName, $foreignKey);
- $this->addForeignKey($tableName, $foreignKey);
- $this->finalizeForeignKeyLog($tableName, $foreignKey);
- }
- }
-
- foreach ($this->indicesToDrop as $tableName => $indices) {
- foreach ($indices as $index) {
- $appliedAnyChange = true;
-
- $this->dropIndex($tableName, $index);
- $this->deleteIndexLog($tableName, $index);
- }
- }
-
- foreach ($this->indicesToAdd as $tableName => $indices) {
- foreach ($indices as $index) {
- $appliedAnyChange = true;
-
- $this->prepareIndexLog($tableName, $index);
- $this->addIndex($tableName, $index);
- $this->finalizeIndexLog($tableName, $index);
- }
- }
-
- if ($appliedAnyChange) {
- throw new SplitNodeException($this->splitNodeMessage);
- }
- }
-
- /**
- * Adds, alters, and drop columns of the same table.
- *
- * Before a column is dropped, all of its foreign keys are dropped.
- *
- * @param string $tableName
- * @param IDatabaseTableColumn[] $addedColumns
- * @param IDatabaseTableColumn[] $alteredColumns
- * @param IDatabaseTableColumn[] $droppedColumns
- */
- protected function applyColumnChanges($tableName, array $addedColumns, array $alteredColumns, array $droppedColumns) {
- $dropForeignKeys = [];
-
- $columnData = [];
- foreach ($droppedColumns as $droppedColumn) {
- $columnData[$droppedColumn->getName()] = [
- 'action' => 'drop'
- ];
-
- foreach ($this->getExistingTable($tableName)->getForeignKeys() as $foreignKey) {
- if (in_array($droppedColumn->getName(), $foreignKey->getColumns())) {
- $dropForeignKeys[] = $foreignKey;
- }
- }
- }
- foreach ($addedColumns as $addedColumn) {
- $columnData[$addedColumn->getName()] = [
- 'action' => 'add',
- 'data' => $addedColumn->getData()
- ];
- }
- foreach ($alteredColumns as $alteredColumn) {
- $columnData[$alteredColumn->getName()] = [
- 'action' => 'alter',
- 'data' => $alteredColumn->getData(),
- 'oldColumnName' => $alteredColumn->getName()
- ];
- }
-
- if (!empty($columnData)) {
- foreach ($dropForeignKeys as $foreignKey) {
- $this->dropForeignKey($tableName, $foreignKey);
- $this->deleteForeignKeyLog($tableName, $foreignKey);
- }
-
- $this->dbEditor->alterColumns($tableName, $columnData);
- }
- }
-
- /**
- * Calculates all of the necessary changes to be executed.
- */
- protected function calculateChanges() {
- foreach ($this->tables as $table) {
- $tableName = $table->getName();
-
- if ($table->willBeDropped()) {
- if (in_array($tableName, $this->existingTableNames)) {
- $this->tablesToDrop[] = $table;
-
- $this->splitNodeMessage .= "Dropped table '{$tableName}'.";
- break;
- }
- else if (isset($this->tablePackageIDs[$tableName])) {
- $this->deleteTableLog($table);
- }
- }
- else if (!in_array($tableName, $this->existingTableNames)) {
- if ($table instanceof PartialDatabaseTable) {
- throw new \LogicException("Partial table '{$tableName}' cannot be created.");
- }
-
- $this->tablesToCreate[] = $table;
-
- $this->splitNodeMessage .= "Created table '{$tableName}'.";
- break;
- }
- else {
- // calculate difference between tables
- $existingTable = $this->getExistingTable($tableName);
- $existingColumns = $existingTable->getColumns();
-
- foreach ($table->getColumns() as $column) {
- if ($column->willBeDropped()) {
- if (isset($existingColumns[$column->getName()])) {
- if (!isset($this->columnsToDrop[$tableName])) {
- $this->columnsToDrop[$tableName] = [];
- }
- $this->columnsToDrop[$tableName][] = $column;
- }
- else if (isset($this->columnPackageIDs[$tableName][$column->getName()])) {
- $this->deleteColumnLog($tableName, $column);
- }
- }
- else if (!isset($existingColumns[$column->getName()])) {
- if (!isset($this->columnsToAdd[$tableName])) {
- $this->columnsToAdd[$tableName] = [];
- }
- $this->columnsToAdd[$tableName][] = $column;
- }
- else if ($this->diffColumns($existingColumns[$column->getName()], $column)) {
- if (!isset($this->columnsToAlter[$tableName])) {
- $this->columnsToAlter[$tableName] = [];
- }
- $this->columnsToAlter[$tableName][] = $column;
- }
- }
-
- // all column-related changes are executed in one query thus break
- // here and not within the previous loop
- if (!empty($this->columnsToAdd) || !empty($this->columnsToAlter) || !empty($this->columnsToDrop)) {
- $this->splitNodeMessage .= "Altered columns of table '{$tableName}'.";
- break;
- }
-
- $existingForeignKeys = $existingTable->getForeignKeys();
- foreach ($table->getForeignKeys() as $foreignKey) {
- $matchingExistingForeignKey = null;
- foreach ($existingForeignKeys as $existingForeignKey) {
- if (empty(array_diff($foreignKey->getDiffData(), $existingForeignKey->getDiffData()))) {
- $matchingExistingForeignKey = $existingForeignKey;
- break;
- }
- }
-
- if ($foreignKey->willBeDropped()) {
- if ($matchingExistingForeignKey !== null) {
- if (!isset($this->foreignKeysToDrop[$tableName])) {
- $this->foreignKeysToDrop[$tableName] = [];
- }
- $this->foreignKeysToDrop[$tableName][] = $foreignKey;
-
- $this->splitNodeMessage .= "Dropped foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
- break 2;
- }
- else if (isset($this->foreignKeyPackageIDs[$tableName][$foreignKey->getName()])) {
- $this->deleteForeignKeyLog($tableName, $foreignKey);
- }
- }
- else if ($matchingExistingForeignKey === null) {
- // If 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;
+ }
+ }
+ }
+ }
}
/**
* Contains file-related functions.
- *
- * @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @package WoltLabSuite\Core\Util
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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: <http://en.wikipedia.org/wiki/Binary_prefix>
- *
- * @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: <http://en.wikipedia.org/wiki/Binary_prefix>
+ *
+ * @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
+ }
}