diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index 3ff1883d..db13ed17 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -899,6 +899,20 @@ protected function getAddIndexInstructions(TableMetadata $table, Index $index): $this->getIndexSqlDefinition($index), ); + // FULLTEXT indexes use post-steps (raw SQL) which executeAlterSteps + // does not append algorithm/lock to, so we inline the clause here. + // Setting on instructions as well ensures validation still runs. + if ($index->getAlgorithm() !== null || $index->getLock() !== null) { + if ($index->getAlgorithm() !== null) { + $alter .= ', ALGORITHM=' . strtoupper($index->getAlgorithm()); + $instructions->setAlgorithm($index->getAlgorithm()); + } + if ($index->getLock() !== null) { + $alter .= ', LOCK=' . strtoupper($index->getLock()); + $instructions->setLock($index->getLock()); + } + } + $instructions->addPostStep($alter); } else { $alter = sprintf( @@ -907,6 +921,13 @@ protected function getAddIndexInstructions(TableMetadata $table, Index $index): ); $instructions->addAlter($alter); + + if ($index->getAlgorithm() !== null) { + $instructions->setAlgorithm($index->getAlgorithm()); + } + if ($index->getLock() !== null) { + $instructions->setLock($index->getLock()); + } } return $instructions; diff --git a/src/Db/Table/Index.php b/src/Db/Table/Index.php index f1ab782b..af965397 100644 --- a/src/Db/Table/Index.php +++ b/src/Db/Table/Index.php @@ -47,6 +47,8 @@ class Index extends DatabaseIndex * @param array|null $include The included columns for covering indexes. * @param ?string $where The where clause for partial indexes. * @param bool $concurrent Whether to create the index concurrently. + * @param ?string $algorithm The ALTER TABLE algorithm (MySQL-specific). + * @param ?string $lock The ALTER TABLE lock mode (MySQL-specific). */ public function __construct( protected string $name = '', @@ -57,6 +59,8 @@ public function __construct( protected ?array $include = null, protected ?string $where = null, protected bool $concurrent = false, + protected ?string $algorithm = null, + protected ?string $lock = null, ) { } @@ -149,6 +153,52 @@ public function getConcurrently(): bool return $this->concurrent; } + /** + * 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; + } + /** * Utility method that maps an array of index options to this object's methods. * @@ -159,7 +209,7 @@ public function getConcurrently(): bool public function setOptions(array $options) { // Valid Options - $validOptions = ['concurrently', 'type', 'unique', 'name', 'limit', 'order', 'include', 'where']; + $validOptions = ['concurrently', 'type', 'unique', 'name', 'limit', 'order', 'include', 'where', 'algorithm', 'lock']; foreach ($options as $option => $value) { if (!in_array($option, $validOptions, true)) { throw new RuntimeException(sprintf('"%s" is not a valid index option.', $option)); diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 17b1ef84..25649d50 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3127,6 +3127,282 @@ public function testAlgorithmWithMixedCase(): void $this->assertTrue($this->adapter->hasColumn('mixed_case', 'col2')); } + public function testAddColumnWithAlgorithmAndLockSqlContainsClause(): void + { + $table = new Table('col_sql_verify', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->io->level(ConsoleIo::VERBOSE); + $this->out->clear(); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + + $output = $this->out->output(); + $this->assertStringContainsString('ALGORITHM=INPLACE', $output); + $this->assertStringContainsString('LOCK=NONE', $output); + $this->assertTrue($this->adapter->hasColumn('col_sql_verify', 'col2')); + } + + public function testAddColumnWithFluentColumnBuilder(): void + { + $table = new Table('col_fluent', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $column = new Column(); + $column->setName('col2') + ->setType('string') + ->setNull(true) + ->setAlgorithm(MysqlAdapter::ALGORITHM_INPLACE) + ->setLock(MysqlAdapter::LOCK_NONE); + + $this->io->level(ConsoleIo::VERBOSE); + $this->out->clear(); + + $table->addColumn($column)->update(); + + $output = $this->out->output(); + $this->assertStringContainsString('ALGORITHM=INPLACE', $output); + $this->assertStringContainsString('LOCK=NONE', $output); + $this->assertTrue($this->adapter->hasColumn('col_fluent', 'col2')); + } + + public function testAddIndexWithAlgorithm(): void + { + $table = new Table('index_algo', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + ])->update(); + + $this->assertTrue($this->adapter->hasIndex('index_algo', ['email'])); + } + + public function testAddIndexWithAlgorithmAndLock(): void + { + $table = new Table('index_algo_lock', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + + $this->assertTrue($this->adapter->hasIndex('index_algo_lock', ['email'])); + } + + public function testAddIndexWithAlgorithmCopy(): void + { + $table = new Table('index_copy', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_COPY, + ])->update(); + + $this->assertTrue($this->adapter->hasIndex('index_copy', ['email'])); + } + + public function testAddIndexWithAlgorithmMixedCase(): void + { + $table = new Table('index_case', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $table->addIndex('email', [ + 'algorithm' => 'inplace', + 'lock' => 'none', + ])->update(); + + $this->assertTrue($this->adapter->hasIndex('index_case', ['email'])); + } + + public function testAddIndexWithInvalidAlgorithmThrowsException(): void + { + $table = new Table('index_invalid_algo', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid algorithm'); + + $table->addIndex('email', [ + 'algorithm' => 'INVALID', + ])->update(); + } + + public function testAddIndexWithInvalidLockThrowsException(): void + { + $table = new Table('index_invalid_lock', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid lock'); + + $table->addIndex('email', [ + 'lock' => 'INVALID', + ])->update(); + } + + public function testAddIndexWithAlgorithmInstantAndExplicitLockThrowsException(): void + { + $table = new Table('index_instant_lock', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ALGORITHM=INSTANT cannot be combined with LOCK=NONE'); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + } + + public function testBatchedIndexesWithSameAlgorithm(): void + { + $table = new Table('index_batch', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->create(); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ]) + ->addIndex('name', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ]) + ->update(); + + $this->assertTrue($this->adapter->hasIndex('index_batch', ['email'])); + $this->assertTrue($this->adapter->hasIndex('index_batch', ['name'])); + } + + public function testBatchedIndexesWithConflictingAlgorithmsThrowsException(): void + { + $table = new Table('index_batch_conflict', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Conflicting algorithm specifications'); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + ]) + ->addIndex('name', [ + 'algorithm' => MysqlAdapter::ALGORITHM_COPY, + ]) + ->update(); + } + + public function testBatchedIndexesWithConflictingLocksThrowsException(): void + { + $table = new Table('index_lock_conflict', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Conflicting lock specifications'); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ]) + ->addIndex('name', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_SHARED, + ]) + ->update(); + } + + public function testAddFulltextIndexWithAlgorithmAndLock(): void + { + $table = new Table('index_fulltext_algo', [], $this->adapter); + $table->addColumn('content', 'text') + ->create(); + + $table->addIndex('content', [ + 'type' => 'fulltext', + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_SHARED, + ])->update(); + + $this->assertTrue($this->adapter->hasIndex('index_fulltext_algo', ['content'])); + } + + public function testAddFulltextIndexWithInstantAndLockThrowsException(): void + { + $table = new Table('index_fulltext_instant', [], $this->adapter); + $table->addColumn('content', 'text') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ALGORITHM=INSTANT cannot be combined with LOCK=NONE'); + + $table->addIndex('content', [ + 'type' => 'fulltext', + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + } + + public function testAddIndexWithAlgorithmAndLockSqlContainsClause(): void + { + $table = new Table('idx_sql_verify', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $this->io->level(ConsoleIo::VERBOSE); + $this->out->clear(); + + $table->addIndex('email', [ + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + + $output = $this->out->output(); + $this->assertStringContainsString('ALGORITHM=INPLACE', $output); + $this->assertStringContainsString('LOCK=NONE', $output); + $this->assertTrue($this->adapter->hasIndex('idx_sql_verify', ['email'])); + } + + public function testAddIndexWithFluentIndexBuilder(): void + { + $table = new Table('idx_fluent', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $index = new Index(); + $index->setColumns('email') + ->setAlgorithm(MysqlAdapter::ALGORITHM_INPLACE) + ->setLock(MysqlAdapter::LOCK_NONE); + + $this->io->level(ConsoleIo::VERBOSE); + $this->out->clear(); + + $table->addIndex($index)->update(); + + $output = $this->out->output(); + $this->assertStringContainsString('ALGORITHM=INPLACE', $output); + $this->assertStringContainsString('LOCK=NONE', $output); + $this->assertTrue($this->adapter->hasIndex('idx_fluent', ['email'])); + } + public function testInsertOrUpdateWithDuplicates(): void { $table = new Table('currencies', [], $this->adapter);