diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index ca79798f..8aa67034 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -14,6 +14,18 @@ use Cake\Database\Schema\SchemaDialect; use Cake\Database\Schema\TableSchema; use InvalidArgumentException; +use Migrations\Db\Action\AddColumn; +use Migrations\Db\Action\AddForeignKey; +use Migrations\Db\Action\AddIndex; +use Migrations\Db\Action\ChangeColumn; +use Migrations\Db\Action\ChangeComment; +use Migrations\Db\Action\ChangePrimaryKey; +use Migrations\Db\Action\DropForeignKey; +use Migrations\Db\Action\DropIndex; +use Migrations\Db\Action\DropTable; +use Migrations\Db\Action\RemoveColumn; +use Migrations\Db\Action\RenameColumn; +use Migrations\Db\Action\RenameTable; use Migrations\Db\AlterInstructions; use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; @@ -107,6 +119,77 @@ class MysqlAdapter extends AbstractAdapter 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 */ @@ -1164,4 +1247,254 @@ protected function isMariaDb(): bool return stripos($version, 'mariadb') !== false; } + + /** + * {@inheritDoc} + * + * Overridden to support ALGORITHM and LOCK clauses for MySQL ALTER TABLE operations. + * + * @throws \InvalidArgumentException + * @return void + */ + public function executeActions(TableMetadata $table, array $actions): void + { + // Extract algorithm and lock specifications from all actions + $algorithm = null; + $lock = null; + + foreach ($actions as $action) { + if (!method_exists($action, 'getColumn')) { + continue; + } + + $column = $action->getColumn(); + if (!($column instanceof Column)) { + continue; + } + + $colAlgorithm = $column->getAlgorithm(); + $colLock = $column->getLock(); + + if ($colAlgorithm !== null) { + if ($algorithm !== null && $algorithm !== $colAlgorithm) { + 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.', + $algorithm, + $colAlgorithm, + )); + } + $algorithm = $colAlgorithm; + } + + if ($colLock !== null) { + if ($lock !== null && $lock !== $colLock) { + 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.', + $lock, + $colLock, + )); + } + $lock = $colLock; + } + } + + // If no algorithm/lock specified, use parent implementation + if ($algorithm === null && $lock === null) { + parent::executeActions($table, $actions); + + return; + } + + // Otherwise, execute with custom algorithm/lock support + $this->executeActionsWithAlgorithmAndLock($table, $actions, $algorithm, $lock); + } + + /** + * Executes actions with ALGORITHM and LOCK clauses. + * + * @param \Migrations\Db\Table\TableMetadata $table The table metadata + * @param array $actions The actions to execute + * @param string|null $algorithm The algorithm to use + * @param string|null $lock The lock mode to use + * @throws \InvalidArgumentException + * @return void + */ + protected function executeActionsWithAlgorithmAndLock( + TableMetadata $table, + array $actions, + ?string $algorithm, + ?string $lock, + ): void { + $instructions = new AlterInstructions(); + + // Build instructions (copied from AbstractAdapter::executeActions) + foreach ($actions as $action) { + switch (true) { + case $action instanceof AddColumn: + $instructions->merge($this->getAddColumnInstructions($table, $action->getColumn())); + break; + + case $action instanceof AddIndex: + $instructions->merge($this->getAddIndexInstructions($table, $action->getIndex())); + break; + + case $action instanceof AddForeignKey: + $instructions->merge($this->getAddForeignKeyInstructions($table, $action->getForeignKey())); + break; + + case $action instanceof ChangeColumn: + $instructions->merge($this->getChangeColumnInstructions( + $table->getName(), + $action->getColumnName(), + $action->getColumn(), + )); + break; + + case $action instanceof DropForeignKey && !$action->getForeignKey()->getName(): + $instructions->merge($this->getDropForeignKeyByColumnsInstructions( + $table->getName(), + $action->getForeignKey()->getColumns(), + )); + break; + + case $action instanceof DropForeignKey && $action->getForeignKey()->getName(): + $instructions->merge($this->getDropForeignKeyInstructions( + $table->getName(), + (string)$action->getForeignKey()->getName(), + )); + break; + + case $action instanceof DropIndex && $action->getIndex()->getName(): + $instructions->merge($this->getDropIndexByNameInstructions( + $table->getName(), + (string)$action->getIndex()->getName(), + )); + break; + + case $action instanceof DropIndex && !$action->getIndex()->getName(): + $instructions->merge($this->getDropIndexByColumnsInstructions( + $table->getName(), + (array)$action->getIndex()->getColumns(), + )); + break; + + case $action instanceof DropTable: + $instructions->merge($this->getDropTableInstructions($table->getName())); + break; + + case $action instanceof RemoveColumn: + $instructions->merge($this->getDropColumnInstructions( + $table->getName(), + (string)$action->getColumn()->getName(), + )); + break; + + case $action instanceof RenameColumn: + $instructions->merge($this->getRenameColumnInstructions( + $table->getName(), + (string)$action->getColumn()->getName(), + $action->getNewName(), + )); + break; + + case $action instanceof RenameTable: + $instructions->merge($this->getRenameTableInstructions( + $table->getName(), + $action->getNewName(), + )); + break; + + case $action instanceof ChangePrimaryKey: + $instructions->merge($this->getChangePrimaryKeyInstructions( + $table, + $action->getNewColumns(), + )); + break; + + case $action instanceof ChangeComment: + $instructions->merge($this->getChangeCommentInstructions( + $table, + $action->getNewComment(), + )); + break; + + default: + throw new InvalidArgumentException( + sprintf("Don't know how to execute action `%s`", get_class($action)), + ); + } + } + + // Build ALTER TABLE template with algorithm and lock + $alterTemplate = sprintf('ALTER TABLE %s %%s', $this->quoteTableName($table->getName())); + + // Add algorithm and lock clauses + $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; + } + + // MySQL restriction: ALGORITHM=INSTANT cannot be combined with explicit LOCK modes + // except LOCK=DEFAULT. See: https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html + 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.', + ); + } + + // Execute with custom template + if ($instructions->getAlterParts()) { + $alter = sprintf($alterTemplate, implode(', ', $instructions->getAlterParts()) . $algorithmLockClause); + $this->execute($alter); + } + + // Execute post-steps + $state = []; + foreach ($instructions->getPostSteps() as $instruction) { + if (is_callable($instruction)) { + $state = $instruction($state); + continue; + } + + $this->execute($instruction); + } + } } diff --git a/src/Db/Table/Column.php b/src/Db/Table/Column.php index 7d8733fe..73aaaf02 100644 --- a/src/Db/Table/Column.php +++ b/src/Db/Table/Column.php @@ -87,6 +87,16 @@ class Column extends DatabaseColumn */ protected ?array $values = null; + /** + * @var string|null + */ + protected ?string $algorithm = null; + + /** + * @var string|null + */ + protected ?string $lock = null; + /** * Column constructor * @@ -650,6 +660,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; + } + /** * Gets all allowed options. Each option must have a corresponding `setFoo` method. * @@ -677,6 +733,8 @@ protected function getValidOptions(): array 'seed', 'increment', 'generated', + 'algorithm', + 'lock', ]; } diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 27f68869..8019ac8d 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -2539,4 +2539,194 @@ public function testInsertOrSkipWithoutDuplicates() $rows = $this->adapter->fetchAll('SELECT * FROM categories'); $this->assertCount(2, $rows); } + + public function testAddColumnWithAlgorithmInstant() + { + $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() + { + $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() + { + $table = new Table('items', [], $this->adapter); + $table->addColumn('description', 'string', ['limit' => 100]) + ->create(); + + $table->changeColumn('description', 'string', [ + 'limit' => 255, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_SHARED, + ])->update(); + + $columns = $this->adapter->getColumns('items'); + foreach ($columns as $column) { + if ($column->getName() === 'description') { + $this->assertEquals(255, $column->getLimit()); + } + } + } + + public function testBatchedOperationsWithSameAlgorithm() + { + $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() + { + $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() + { + $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() + { + $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() + { + $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() + { + $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() + { + $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() + { + $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() + { + $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')); + } }