From fef6787a8f3a2fe0772aa5d4d0b846a2f6ab7851 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Mon, 23 Mar 2026 16:54:33 -0400 Subject: [PATCH 1/3] Add ALGORITHM/LOCK support to Index operations Extend the ALGORITHM and LOCK clause support added in PR #955 for Column operations to also cover Index operations. This allows addIndex to pass algorithm and lock options through the fluent API on MySQL. Changes: - Add algorithm/lock properties, getters/setters to Index class - Wire algorithm/lock through getAddIndexInstructions in MysqlAdapter - Handle FULLTEXT indexes which use post-steps (inline the clause) - Add 9 tests mirroring the Column algorithm/lock test coverage --- src/Db/Adapter/MysqlAdapter.php | 21 +++ src/Db/Table/Index.php | 52 ++++++- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 137 ++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) 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..415af4f5 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3127,6 +3127,143 @@ public function testAlgorithmWithMixedCase(): void $this->assertTrue($this->adapter->hasColumn('mixed_case', '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() + { + $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 testInsertOrUpdateWithDuplicates(): void { $table = new Table('currencies', [], $this->adapter); From bad2b9059e5b8d3c7c005bccfd5d5a993bff0ad8 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 24 Mar 2026 11:23:54 -0400 Subject: [PATCH 2/3] Add tests for FULLTEXT index and batched lock conflict paths --- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 415af4f5..07c63e1e 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3264,6 +3264,58 @@ public function testBatchedIndexesWithConflictingAlgorithmsThrowsException() ->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 testInsertOrUpdateWithDuplicates(): void { $table = new Table('currencies', [], $this->adapter); From 0c5aa5b58e3e589f3f4da0572b6dcd4233564b4e Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Fri, 27 Mar 2026 09:06:27 -0400 Subject: [PATCH 3/3] Add SQL validation and fluent builder tests for ALGORITHM/LOCK Verify that ALGORITHM and LOCK clauses appear in generated SQL using verbose logging, and exercise the fluent Column/Index builder path for both column and index operations. --- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 07c63e1e..25649d50 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3127,6 +3127,51 @@ 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); @@ -3245,7 +3290,7 @@ public function testBatchedIndexesWithSameAlgorithm(): void $this->assertTrue($this->adapter->hasIndex('index_batch', ['name'])); } - public function testBatchedIndexesWithConflictingAlgorithmsThrowsException() + public function testBatchedIndexesWithConflictingAlgorithmsThrowsException(): void { $table = new Table('index_batch_conflict', [], $this->adapter); $table->addColumn('email', 'string') @@ -3316,6 +3361,48 @@ public function testAddFulltextIndexWithInstantAndLockThrowsException(): void ])->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);