diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e97854bab..7dbf055e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,13 +77,16 @@ install [docker-compose](https://docs.docker.com/compose/) for your platform. docker-compose run --rm phinx ``` -1. Install dependencies: + If you use Mac with Apple Silicon add `platform: linux/amd64` for `mysql` and `postgres` services first. Otherwise, + you might have an error `no matching manifest for linux/arm64/v8 in the manifest list entries` + +2. Install dependencies: ``` composer update ``` -1. Run unittest: +3. Run unittest: ``` vendor/bin/phpunit diff --git a/Dockerfile b/Dockerfile index c01e191cc..7f82a4832 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:7.3 +FROM php:8.1 # system dependecies RUN apt-get update && apt-get install -y \ diff --git a/src/Phinx/Db/Adapter/MysqlAdapter.php b/src/Phinx/Db/Adapter/MysqlAdapter.php index 8a767a74b..53a7981a9 100644 --- a/src/Phinx/Db/Adapter/MysqlAdapter.php +++ b/src/Phinx/Db/Adapter/MysqlAdapter.php @@ -93,6 +93,77 @@ class MysqlAdapter extends PdoAdapter public const FIRST = 'FIRST'; + /** + * MySQL ALTER TABLE ALGORITHM options + * + * These constants control how MySQL performs ALTER TABLE operations: + * - ALGORITHM_DEFAULT: Let MySQL choose the best algorithm + * - ALGORITHM_INSTANT: Instant operation (no table copy, MySQL 8.0+ / MariaDB 10.3+) + * - ALGORITHM_INPLACE: In-place operation (no full table copy) + * - ALGORITHM_COPY: Traditional table copy algorithm + * + * Usage: + * ```php + * use Migrations\Db\Adapter\MysqlAdapter; + * + * // ALGORITHM=INSTANT alone (recommended) + * $table->addColumn('status', 'string', [ + * 'null' => true, + * 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + * ]); + * + * // Or with ALGORITHM=INPLACE and explicit LOCK + * $table->addColumn('status', 'string', [ + * 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + * 'lock' => MysqlAdapter::LOCK_NONE, + * ]); + * ``` + * + * Important: ALGORITHM=INSTANT cannot be combined with LOCK=NONE, LOCK=SHARED, + * or LOCK=EXCLUSIVE (MySQL restriction). Use ALGORITHM=INSTANT alone or with + * LOCK=DEFAULT only. + * + * Note: ALGORITHM_INSTANT requires MySQL 8.0+ or MariaDB 10.3+ and only works for + * compatible operations (adding nullable columns, dropping columns, etc.). + * If the operation cannot be performed instantly, MySQL will return an error. + * + * @see https://dev.mysql.com/doc/refman/8.0/en/alter-table.html + * @see https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html + * @see https://mariadb.com/kb/en/alter-table/#algorithm + */ + public const ALGORITHM_DEFAULT = 'DEFAULT'; + public const ALGORITHM_INSTANT = 'INSTANT'; + public const ALGORITHM_INPLACE = 'INPLACE'; + public const ALGORITHM_COPY = 'COPY'; + + /** + * MySQL ALTER TABLE LOCK options + * + * These constants control the locking behavior during ALTER TABLE operations: + * - LOCK_DEFAULT: Let MySQL choose the appropriate lock level + * - LOCK_NONE: Allow concurrent reads and writes (least restrictive) + * - LOCK_SHARED: Allow concurrent reads, block writes + * - LOCK_EXCLUSIVE: Block all concurrent access (most restrictive) + * + * Usage: + * ```php + * use Migrations\Db\Adapter\MysqlAdapter; + * + * $table->changeColumn('name', 'string', [ + * 'limit' => 500, + * 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + * 'lock' => MysqlAdapter::LOCK_NONE, + * ]); + * ``` + * + * @see https://dev.mysql.com/doc/refman/8.0/en/alter-table.html + * @see https://mariadb.com/kb/en/alter-table/#lock + */ + public const LOCK_DEFAULT = 'DEFAULT'; + public const LOCK_NONE = 'NONE'; + public const LOCK_SHARED = 'SHARED'; + public const LOCK_EXCLUSIVE = 'EXCLUSIVE'; + /** * {@inheritDoc} * @@ -533,7 +604,16 @@ protected function getAddColumnInstructions(Table $table, Column $column): Alter $alter .= $this->afterClause($column); - return new AlterInstructions([$alter]); + $instructions = new AlterInstructions([$alter]); + + if ($column->getAlgorithm() !== null) { + $instructions->setAlgorithm($column->getAlgorithm()); + } + if ($column->getLock() !== null) { + $instructions->setLock($column->getLock()); + } + + return $instructions; } /** @@ -616,7 +696,16 @@ protected function getChangeColumnInstructions(string $tableName, string $column $this->afterClause($newColumn), ); - return new AlterInstructions([$alter]); + $instructions = new AlterInstructions([$alter]); + + if ($newColumn->getAlgorithm() !== null) { + $instructions->setAlgorithm($newColumn->getAlgorithm()); + } + if ($newColumn->getLock() !== null) { + $instructions->setLock($newColumn->getLock()); + } + + return $instructions; } /** @@ -1510,6 +1599,92 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string return $def; } + /** + * {@inheritDoc} + * + * Overridden to support ALGORITHM and LOCK clauses from AlterInstructions. + * + * @param string $tableName The table name + * @param \Phinx\Db\Util\AlterInstructions $instructions The alter instructions + * @throws \InvalidArgumentException + * @return void + */ + protected function executeAlterSteps(string $tableName, AlterInstructions $instructions): void + { + $algorithm = $instructions->getAlgorithm(); + $lock = $instructions->getLock(); + + if ($algorithm === null && $lock === null) { + parent::executeAlterSteps($tableName, $instructions); + + return; + } + + $algorithmLockClause = ''; + $upperAlgorithm = null; + $upperLock = null; + + if ($algorithm !== null) { + $upperAlgorithm = strtoupper($algorithm); + $validAlgorithms = [ + self::ALGORITHM_DEFAULT, + self::ALGORITHM_INSTANT, + self::ALGORITHM_INPLACE, + self::ALGORITHM_COPY, + ]; + if (!in_array($upperAlgorithm, $validAlgorithms, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid algorithm "%s". Valid options: %s', + $algorithm, + implode(', ', $validAlgorithms), + )); + } + $algorithmLockClause .= ', ALGORITHM=' . $upperAlgorithm; + } + + if ($lock !== null) { + $upperLock = strtoupper($lock); + $validLocks = [ + self::LOCK_DEFAULT, + self::LOCK_NONE, + self::LOCK_SHARED, + self::LOCK_EXCLUSIVE, + ]; + if (!in_array($upperLock, $validLocks, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid lock "%s". Valid options: %s', + $lock, + implode(', ', $validLocks), + )); + } + $algorithmLockClause .= ', LOCK=' . $upperLock; + } + + if ($upperAlgorithm === self::ALGORITHM_INSTANT && $upperLock !== null && $upperLock !== self::LOCK_DEFAULT) { + throw new InvalidArgumentException( + 'ALGORITHM=INSTANT cannot be combined with LOCK=NONE, LOCK=SHARED, or LOCK=EXCLUSIVE. ' . + 'Either use ALGORITHM=INSTANT alone, or use ALGORITHM=INSTANT with LOCK=DEFAULT.', + ); + } + + $alterTemplate = sprintf('ALTER TABLE %s %%s', $this->quoteTableName($tableName)); + + if ($instructions->getAlterParts()) { + $alter = sprintf($alterTemplate, implode(', ', $instructions->getAlterParts()) . $algorithmLockClause); + $this->execute($alter); + } + + $state = []; + foreach ($instructions->getPostSteps() as $instruction) { + if (is_callable($instruction)) { + $state = $instruction($state); + continue; + } + + $this->execute($instruction); + } + } + /** * Describes a database table. This is a MySQL adapter specific method. * diff --git a/src/Phinx/Db/Table/Column.php b/src/Phinx/Db/Table/Column.php index 3941f037e..9ce50de3c 100644 --- a/src/Phinx/Db/Table/Column.php +++ b/src/Phinx/Db/Table/Column.php @@ -162,6 +162,16 @@ class Column */ protected ?array $values = null; + /** + * @var string|null + */ + protected ?string $algorithm = null; + + /** + * @var string|null + */ + protected ?string $lock = null; + /** * Column constructor */ @@ -708,6 +718,52 @@ public function getEncoding(): ?string return $this->encoding; } + /** + * Sets the ALTER TABLE algorithm (MySQL-specific). + * + * @param string $algorithm Algorithm + * @return $this + */ + public function setAlgorithm(string $algorithm) + { + $this->algorithm = $algorithm; + + return $this; + } + + /** + * Gets the ALTER TABLE algorithm. + * + * @return string|null + */ + public function getAlgorithm(): ?string + { + return $this->algorithm; + } + + /** + * Sets the ALTER TABLE lock mode (MySQL-specific). + * + * @param string $lock Lock mode + * @return $this + */ + public function setLock(string $lock) + { + $this->lock = $lock; + + return $this; + } + + /** + * Gets the ALTER TABLE lock mode. + * + * @return string|null + */ + public function getLock(): ?string + { + return $this->lock; + } + /** * Sets the column SRID. * @@ -757,6 +813,8 @@ protected function getValidOptions(): array 'seed', 'increment', 'generated', + 'algorithm', + 'lock', ]; } diff --git a/src/Phinx/Db/Util/AlterInstructions.php b/src/Phinx/Db/Util/AlterInstructions.php index bbde1b947..19f4a1962 100644 --- a/src/Phinx/Db/Util/AlterInstructions.php +++ b/src/Phinx/Db/Util/AlterInstructions.php @@ -8,6 +8,8 @@ namespace Phinx\Db\Util; +use InvalidArgumentException; + /** * Contains all the information for running an ALTER command for a table, * and any post-steps required after the fact. @@ -24,6 +26,16 @@ class AlterInstructions */ protected array $postSteps = []; + /** + * @var string|null MySQL-specific: ALGORITHM clause + */ + protected ?string $algorithm = null; + + /** + * @var string|null MySQL-specific: LOCK clause + */ + protected ?string $lock = null; + /** * Constructor * @@ -83,16 +95,82 @@ public function getPostSteps(): array return $this->postSteps; } + /** + * Sets the ALGORITHM clause (MySQL-specific) + * + * @param string|null $algorithm The algorithm to use + * @return void + */ + public function setAlgorithm(?string $algorithm): void + { + $this->algorithm = $algorithm; + } + + /** + * Gets the ALGORITHM clause (MySQL-specific) + * + * @return string|null + */ + public function getAlgorithm(): ?string + { + return $this->algorithm; + } + + /** + * Sets the LOCK clause (MySQL-specific) + * + * @param string|null $lock The lock mode to use + * @return void + */ + public function setLock(?string $lock): void + { + $this->lock = $lock; + } + + /** + * Gets the LOCK clause (MySQL-specific) + * + * @return string|null + */ + public function getLock(): ?string + { + return $this->lock; + } + /** * Merges another AlterInstructions object to this one * * @param \Phinx\Db\Util\AlterInstructions $other The other collection of instructions to merge in + * @throws \InvalidArgumentException When algorithm or lock specifications conflict * @return void */ public function merge(AlterInstructions $other): void { $this->alterParts = array_merge($this->alterParts, $other->getAlterParts()); $this->postSteps = array_merge($this->postSteps, $other->getPostSteps()); + + if ($other->getAlgorithm() !== null) { + if ($this->algorithm !== null && $this->algorithm !== $other->getAlgorithm()) { + throw new InvalidArgumentException(sprintf( + 'Conflicting algorithm specifications in batched operations: "%s" and "%s". ' . + 'All operations in a batch must use the same algorithm, or specify it on only one operation.', + $this->algorithm, + $other->getAlgorithm(), + )); + } + $this->algorithm = $other->getAlgorithm(); + } + if ($other->getLock() !== null) { + if ($this->lock !== null && $this->lock !== $other->getLock()) { + throw new InvalidArgumentException(sprintf( + 'Conflicting lock specifications in batched operations: "%s" and "%s". ' . + 'All operations in a batch must use the same lock mode, or specify it on only one operation.', + $this->lock, + $other->getLock(), + )); + } + $this->lock = $other->getLock(); + } } /** diff --git a/tests/Phinx/Db/Adapter/MysqlAdapterTest.php b/tests/Phinx/Db/Adapter/MysqlAdapterTest.php index 1b6ec3d47..25b3e5e9c 100644 --- a/tests/Phinx/Db/Adapter/MysqlAdapterTest.php +++ b/tests/Phinx/Db/Adapter/MysqlAdapterTest.php @@ -2797,4 +2797,230 @@ public function testPdoNotPersistentConnection() $adapter = new MysqlAdapter(MYSQL_DB_CONFIG); $this->assertFalse($adapter->getConnection()->getAttribute(PDO::ATTR_PERSISTENT)); } + + public function testAddColumnWithAlgorithmInstant(): void + { + $this->adapter->connect(); + + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test Instant algorithm on mysql versions less than 8'); + } + + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $table->addColumn('status', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ])->update(); + + $this->assertTrue($this->adapter->hasColumn('users', 'status')); + } + + public function testAddColumnWithAlgorithmAndLock(): void + { + $this->adapter->connect(); + + $table = new Table('products', [], $this->adapter); + $table->addColumn('name', 'string') + ->create(); + + // Use ALGORITHM=INPLACE with LOCK=NONE (INSTANT can't have explicit locks) + $table->addColumn('price', 'decimal', [ + 'precision' => 10, + 'scale' => 2, + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + + $this->assertTrue($this->adapter->hasColumn('products', 'price')); + } + + public function testChangeColumnWithAlgorithm(): void + { + $this->adapter->connect(); + + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test inplace algorithm on mysql versions less than 8'); + } + + $table = new Table('items', [], $this->adapter); + $table->addColumn('description', 'string', ['limit' => 100]) + ->create(); + + $table->changeColumn('description', 'string', [ + 'limit' => 250, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_SHARED, + ])->update(); + + $columns = $this->adapter->getColumns('items'); + foreach ($columns as $column) { + if ($column->getName() === 'description') { + $this->assertEquals(250, $column->getLimit()); + } + } + } + + public function testBatchedOperationsWithSameAlgorithm(): void + { + $this->adapter->connect(); + + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test Instant algorithm on mysql versions less than 8'); + } + + $table = new Table('batch_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ]) + ->addColumn('col3', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ]) + ->update(); + + $this->assertTrue($this->adapter->hasColumn('batch_test', 'col2')); + $this->assertTrue($this->adapter->hasColumn('batch_test', 'col3')); + } + + public function testBatchedOperationsWithConflictingAlgorithmsThrowsException(): void + { + $this->adapter->connect(); + + $table = new Table('conflict_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Conflicting algorithm specifications'); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ]) + ->addColumn('col3', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_COPY, + ]) + ->update(); + } + + public function testBatchedOperationsWithConflictingLocksThrowsException(): void + { + $this->adapter->connect(); + + $table = new Table('lock_conflict_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Conflicting lock specifications'); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ]) + ->addColumn('col3', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_SHARED, + ]) + ->update(); + } + + public function testInvalidAlgorithmThrowsException(): void + { + $this->adapter->connect(); + + $table = new Table('invalid_algo', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid algorithm'); + + $table->addColumn('col2', 'string', [ + 'algorithm' => 'INVALID', + ])->update(); + } + + public function testInvalidLockThrowsException(): void + { + $this->adapter->connect(); + + $table = new Table('invalid_lock', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid lock'); + + $table->addColumn('col2', 'string', [ + 'lock' => 'INVALID', + ])->update(); + } + + public function testAlgorithmInstantWithExplicitLockThrowsException(): void + { + $this->adapter->connect(); + + $table = new Table('instant_lock_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ALGORITHM=INSTANT cannot be combined with LOCK=NONE'); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + } + + public function testAlgorithmConstantsAreDefined(): void + { + $this->adapter->connect(); + + $this->assertEquals('DEFAULT', MysqlAdapter::ALGORITHM_DEFAULT); + $this->assertEquals('INSTANT', MysqlAdapter::ALGORITHM_INSTANT); + $this->assertEquals('INPLACE', MysqlAdapter::ALGORITHM_INPLACE); + $this->assertEquals('COPY', MysqlAdapter::ALGORITHM_COPY); + } + + public function testLockConstantsAreDefined(): void + { + $this->adapter->connect(); + + $this->assertEquals('DEFAULT', MysqlAdapter::LOCK_DEFAULT); + $this->assertEquals('NONE', MysqlAdapter::LOCK_NONE); + $this->assertEquals('SHARED', MysqlAdapter::LOCK_SHARED); + $this->assertEquals('EXCLUSIVE', MysqlAdapter::LOCK_EXCLUSIVE); + } + + public function testAlgorithmWithMixedCase(): void + { + $this->adapter->connect(); + + $table = new Table('mixed_case', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + // Should work with lowercase (use INPLACE with LOCK, not INSTANT) + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => 'inplace', + 'lock' => 'none', + ])->update(); + + $this->assertTrue($this->adapter->hasColumn('mixed_case', 'col2')); + } } diff --git a/tests/Phinx/Db/Table/ColumnTest.php b/tests/Phinx/Db/Table/ColumnTest.php index 6489e4e59..ce932cb87 100644 --- a/tests/Phinx/Db/Table/ColumnTest.php +++ b/tests/Phinx/Db/Table/ColumnTest.php @@ -4,6 +4,7 @@ namespace Test\Phinx\Db\Table; use Phinx\Config\FeatureFlags; +use Phinx\Db\Adapter\MysqlAdapter; use Phinx\Db\Table\Column; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -50,4 +51,22 @@ public function testColumnNullFeatureFlag() $column = new Column(); $this->assertFalse($column->isNull()); } + + public function testSetAlgorithm(): void + { + $column = new Column(); + $this->assertNull($column->getAlgorithm()); + + $column->setOptions(['algorithm' => MysqlAdapter::ALGORITHM_INPLACE]); + $this->assertSame(MysqlAdapter::ALGORITHM_INPLACE, $column->getAlgorithm()); + } + + public function testSetLock(): void + { + $column = new Column(); + $this->assertNull($column->getLock()); + + $column->setOptions(['lock' => MysqlAdapter::LOCK_NONE]); + $this->assertSame(MysqlAdapter::LOCK_NONE, $column->getLock()); + } }