diff --git a/src/Phinx/Db/Adapter/AbstractAdapter.php b/src/Phinx/Db/Adapter/AbstractAdapter.php index abc223ddb..7391d12d1 100644 --- a/src/Phinx/Db/Adapter/AbstractAdapter.php +++ b/src/Phinx/Db/Adapter/AbstractAdapter.php @@ -410,4 +410,19 @@ protected function hasCreatedTable(string $tableName): bool return in_array($tableName, $this->createdTables, true); } + + /** + * {@inheritDoc} + */ + public function preExecuteActions(array $updateSequences): array + { + return []; + } + + /** + * {@inheritDoc} + */ + public function postExecuteActions(array $tableNames, array $preOptions): void + { + } } diff --git a/src/Phinx/Db/Adapter/AdapterInterface.php b/src/Phinx/Db/Adapter/AdapterInterface.php index ecce738af..1060aa455 100644 --- a/src/Phinx/Db/Adapter/AdapterInterface.php +++ b/src/Phinx/Db/Adapter/AdapterInterface.php @@ -275,6 +275,14 @@ public function rollbackTransaction(): void; */ public function execute(string $sql, array $params = []): int; + /** + * Function to be called before executing any migration actions. + * + * @param \Phinx\Db\Plan\AlterTable[][] $updateSequences List of update sequences to be executed + * @return array + */ + public function preExecuteActions(array $updateSequences): array; + /** * Executes a list of migration actions for the given table * @@ -284,6 +292,15 @@ public function execute(string $sql, array $params = []): int; */ public function executeActions(Table $table, array $actions): void; + /** + * Function to be called after executing any migration actions. + * + * @param array $tableNames List of table names that were affected by the actions + * @param array $preOptions Options that were set before executing the actions + * @return void + */ + public function postExecuteActions(array $tableNames, array $preOptions): void; + /** * Returns a new Query object * diff --git a/src/Phinx/Db/Adapter/AdapterWrapper.php b/src/Phinx/Db/Adapter/AdapterWrapper.php index 09ee619dc..dbdfe1fcb 100644 --- a/src/Phinx/Db/Adapter/AdapterWrapper.php +++ b/src/Phinx/Db/Adapter/AdapterWrapper.php @@ -474,6 +474,14 @@ public function getConnection(): PDO return $this->getAdapter()->getConnection(); } + /** + * {@inheritDoc} + */ + public function preExecuteActions(array $updateSequences): array + { + return $this->getAdapter()->preExecuteActions($updateSequences); + } + /** * @inheritDoc */ @@ -482,6 +490,14 @@ public function executeActions(Table $table, array $actions): void $this->getAdapter()->executeActions($table, $actions); } + /** + * {@inheritDoc} + */ + public function postExecuteActions(array $tableNames, array $preOptions): void + { + $this->getAdapter()->postExecuteActions($tableNames, $preOptions); + } + /** * @inheritDoc */ diff --git a/src/Phinx/Db/Adapter/SQLiteAdapter.php b/src/Phinx/Db/Adapter/SQLiteAdapter.php index 1c7c76561..e4f9b3a02 100644 --- a/src/Phinx/Db/Adapter/SQLiteAdapter.php +++ b/src/Phinx/Db/Adapter/SQLiteAdapter.php @@ -14,6 +14,7 @@ use InvalidArgumentException; use PDO; use PDOException; +use Phinx\Db\Action\AddForeignKey; use Phinx\Db\Table\Column; use Phinx\Db\Table\ForeignKey; use Phinx\Db\Table\Index; @@ -1034,57 +1035,52 @@ protected function recreateIndicesAndTriggers(AlterInstructions $instructions): * the given table, and of those tables whose constraints are * targeting it. * - * @param \Phinx\Db\Util\AlterInstructions $instructions The instructions to process - * @param string $tableName The name of the table for which to check constraints. - * @return \Phinx\Db\Util\AlterInstructions + * @param string|array $tableNames The name of the table for which to check constraints. + * @return void */ - protected function validateForeignKeys(AlterInstructions $instructions, string $tableName): AlterInstructions + protected function validateForeignKeys(string|array $tableNames): void { - $instructions->addPostStep(function ($state) use ($tableName) { - $tablesToCheck = [ - $tableName, - ]; - - $otherTables = $this - ->query( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name != ?", - [$tableName], - ) - ->fetchAll(); - - foreach ($otherTables as $otherTable) { - $foreignKeyList = $this->getTableInfo($otherTable['name'], 'foreign_key_list'); - foreach ($foreignKeyList as $foreignKey) { - if (strcasecmp($foreignKey['table'], $tableName) === 0) { - $tablesToCheck[] = $otherTable['name']; - break; - } - } - } - - $tablesToCheck = array_unique(array_map('strtolower', $tablesToCheck)); + if (!is_array($tableNames)) { + $tableNames = [$tableNames]; + } - foreach ($tablesToCheck as $tableToCheck) { - $schema = $this->getSchemaName($tableToCheck, true)['schema']; + $tablesToCheck = $tableNames; - $stmt = $this->query( - sprintf('PRAGMA %sforeign_key_check(%s)', $schema, $this->quoteTableName($tableToCheck)), - ); - $row = $stmt->fetch(); - $stmt->closeCursor(); + $otherTables = $this + ->query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT IN (" . implode(',', array_fill(0, count($tableNames), '?')) . ')', + $tableNames, + ) + ->fetchAll(); - if (is_array($row)) { - throw new RuntimeException(sprintf( - 'Integrity constraint violation: FOREIGN KEY constraint on `%s` failed.', - $tableToCheck, - )); + foreach ($otherTables as $otherTable) { + $foreignKeyList = $this->getTableInfo($otherTable['name'], 'foreign_key_list'); + foreach ($foreignKeyList as $foreignKey) { + if (in_array(strtolower($foreignKey['table']), $tableNames)) { + $tablesToCheck[] = $otherTable['name']; + break; } } + } - return $state; - }); + $tablesToCheck = array_unique(array_map('strtolower', $tablesToCheck)); - return $instructions; + foreach ($tablesToCheck as $tableToCheck) { + $schema = $this->getSchemaName($tableToCheck, true)['schema']; + + $stmt = $this->query( + sprintf('PRAGMA %sforeign_key_check(%s)', $schema, $this->quoteTableName($tableToCheck)), + ); + $row = $stmt->fetch(); + $stmt->closeCursor(); + + if (is_array($row)) { + throw new RuntimeException(sprintf( + 'Integrity constraint violation: FOREIGN KEY constraint on `%s` failed.', + $tableToCheck, + )); + } + } } /** @@ -1230,8 +1226,6 @@ protected function beginAlterByCopyTable(string $tableName): AlterInstructions * @param ?string $renamedOrRemovedColumnName The name of the renamed or removed column when part of a column * rename/drop operation. * @param ?string $newColumnName The new column name when part of a column rename operation. - * @param bool $validateForeignKeys Whether to validate foreign keys after the copy and drop operations. Note that - * enabling this option only has an effect when the `foreign_keys` PRAGMA is set to `ON`! * @return \Phinx\Db\Util\AlterInstructions */ protected function endAlterByCopyTable( @@ -1239,7 +1233,6 @@ protected function endAlterByCopyTable( string $tableName, ?string $renamedOrRemovedColumnName = null, ?string $newColumnName = null, - bool $validateForeignKeys = true, ): AlterInstructions { $instructions = $this->bufferIndicesAndTriggers($instructions, $tableName); @@ -1251,26 +1244,9 @@ protected function endAlterByCopyTable( } } - $foreignKeysEnabled = (bool)$this->fetchRow('PRAGMA foreign_keys')['foreign_keys']; - - if ($foreignKeysEnabled) { - $instructions->addPostStep('PRAGMA foreign_keys = OFF'); - } - $instructions = $this->copyAndDropTmpTable($instructions, $tableName); $instructions = $this->recreateIndicesAndTriggers($instructions); - if ($foreignKeysEnabled) { - $instructions->addPostStep('PRAGMA foreign_keys = ON'); - } - - if ( - $foreignKeysEnabled && - $validateForeignKeys - ) { - $instructions = $this->validateForeignKeys($instructions, $tableName); - } - return $instructions; } @@ -1661,7 +1637,7 @@ protected function getDropPrimaryKeyInstructions(Table $table, string $column): return $newState + $state; }); - return $this->endAlterByCopyTable($instructions, $tableName, null, null, false); + return $this->endAlterByCopyTable($instructions, $tableName, null, null); } /** @@ -1673,7 +1649,6 @@ protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreig $tableName = $table->getName(); $instructions->addPostStep(function ($state) use ($foreignKey, $tableName) { - $this->execute('pragma foreign_keys = ON'); $sql = substr($state['createSQL'], 0, -1) . ',' . $this->getForeignKeySqlDefinition($foreignKey) . '); '; //Delete indexes from original table and recreate them in temporary table @@ -2013,4 +1988,44 @@ public function getDecoratedConnection(): Connection return $this->decoratedConnection = $this->buildConnection(SqliteDriver::class, $options); } + + /** + * {@inheritDoc} + */ + public function preExecuteActions(array $updateSequences): array + { + $foreignKeysEnabled = (bool)$this->fetchRow('PRAGMA foreign_keys')['foreign_keys']; + + if (!$foreignKeysEnabled) { + foreach ($updateSequences as $updates) { + foreach ($updates as $update) { + foreach ($update->getActions() as $action) { + if ($action instanceof AddForeignKey) { + $foreignKeysEnabled = true; + break 3; + } + } + } + } + } + + if ($foreignKeysEnabled) { + $this->execute('PRAGMA foreign_keys = OFF'); + } + + return [ + 'foreignKeysEnabled' => $foreignKeysEnabled, + ]; + } + + /** + * {@inheritDoc} + */ + public function postExecuteActions(array $tableNames, array $preOptions): void + { + if ($preOptions['foreignKeysEnabled']) { + $this->execute('PRAGMA foreign_keys = ON'); + $this->validateForeignKeys($tableNames); + } + } } diff --git a/src/Phinx/Db/Plan/Plan.php b/src/Phinx/Db/Plan/Plan.php index d95221428..c0c9ac231 100644 --- a/src/Phinx/Db/Plan/Plan.php +++ b/src/Phinx/Db/Plan/Plan.php @@ -143,15 +143,22 @@ protected function inverseUpdatesSequence(): array */ public function execute(AdapterInterface $executor): void { + $updatesSequence = $this->updatesSequence(); + $preOptions = $executor->preExecuteActions($updatesSequence); + foreach ($this->tableCreates as $newTable) { $executor->createTable($newTable->getTable(), $newTable->getColumns(), $newTable->getIndexes()); } - foreach ($this->updatesSequence() as $updates) { + $tables = []; + foreach ($updatesSequence as $updates) { foreach ($updates as $update) { + $tables[] = $update->getTable()->getName(); $executor->executeActions($update->getTable(), $update->getActions()); } } + + $executor->postExecuteActions(array_unique($tables), $preOptions); } /** @@ -162,8 +169,12 @@ public function execute(AdapterInterface $executor): void */ public function executeInverse(AdapterInterface $executor): void { + $preOptions = $executor->preExecuteActions($this->inverseUpdatesSequence()); + $tables = []; + foreach ($this->inverseUpdatesSequence() as $updates) { foreach ($updates as $update) { + $tables[] = $update->getTable()->getName(); $executor->executeActions($update->getTable(), $update->getActions()); } } @@ -171,6 +182,8 @@ public function executeInverse(AdapterInterface $executor): void foreach ($this->tableCreates as $newTable) { $executor->createTable($newTable->getTable(), $newTable->getColumns(), $newTable->getIndexes()); } + + $executor->postExecuteActions(array_unique($tables), $preOptions); } /** diff --git a/tests/Phinx/Db/Adapter/SQLiteAdapterTest.php b/tests/Phinx/Db/Adapter/SQLiteAdapterTest.php index 622583a8e..1c2bd74f3 100644 --- a/tests/Phinx/Db/Adapter/SQLiteAdapterTest.php +++ b/tests/Phinx/Db/Adapter/SQLiteAdapterTest.php @@ -2387,6 +2387,7 @@ public function testAlterTableDoesViolateForeignKeyConstraintOnTargetTableChange */ public function testAlterTableDoesViolateForeignKeyConstraintOnSourceTableChange() { + /** @var \Phinx\Db\Adapter\AdapterInterface&\PHPUnit\Framework\MockObject\MockObject $adapter */ $adapter = $this ->getMockBuilder(SQLiteAdapter::class) ->setConstructorArgs([SQLITE_DB_CONFIG, new ArrayInput([]), new NullOutput()]) @@ -2396,14 +2397,19 @@ public function testAlterTableDoesViolateForeignKeyConstraintOnSourceTableChange $adapterReflection = new ReflectionObject($adapter); $queryReflection = $adapterReflection->getParentClass()->getMethod('query'); + $count = 0; $adapter ->expects($this->atLeastOnce()) ->method('query') - ->willReturnCallback(function (string $sql, array $params = []) use ($adapter, $queryReflection) { + ->willReturnCallback(function (string $sql, array $params = []) use ($adapter, &$count, $queryReflection) { if ($sql === 'PRAGMA foreign_key_check(`comments`)') { - $adapter->execute('PRAGMA foreign_keys = OFF'); - $adapter->execute('DELETE FROM articles'); - $adapter->execute('PRAGMA foreign_keys = ON'); + $count++; + + if ($count > 1) { + $adapter->execute('PRAGMA foreign_keys = OFF'); + $adapter->execute('DELETE FROM articles'); + $adapter->execute('PRAGMA foreign_keys = ON'); + } } return $queryReflection->invoke($adapter, $sql, $params);