Skip to content

Commit 9fe4430

Browse files
authored
Add ALGORITHM and LOCK support for MySQL ALTER TABLE operations (#955)
Add ALGORITHM and LOCK support for MySQL ALTER TABLE operations Implements support for MySQL's ALGORITHM and LOCK clauses in ALTER TABLE operations, enabling zero-downtime schema migrations for compatible operations. Key additions: - Class constants for ALGORITHM (DEFAULT, INSTANT, INPLACE, COPY) and LOCK (DEFAULT, NONE, SHARED, EXCLUSIVE) options to avoid magic strings - Column class now supports algorithm and lock options via setAlgorithm()/setLock() - MysqlAdapter validates and applies algorithm/lock clauses to ALTER operations - Batched operations detect conflicts and throw clear error messages - Comprehensive test coverage (11 new test cases) Benefits: - Near-zero downtime migrations on large tables with ALGORITHM=INSTANT - Production-friendly migrations with explicit locking control - Improved performance for compatible schema changes on MySQL 8.0+/MariaDB 10.3+ Usage: ```php use Migrations\Db\Adapter\MysqlAdapter; $table->addColumn('status', 'string', [ 'null' => true, 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, 'lock' => MysqlAdapter::LOCK_NONE, ])->update(); ```
1 parent cc5b6cc commit 9fe4430

4 files changed

Lines changed: 503 additions & 2 deletions

File tree

src/Db/Adapter/MysqlAdapter.php

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,77 @@ class MysqlAdapter extends AbstractAdapter
107107

108108
public const FIRST = 'FIRST';
109109

110+
/**
111+
* MySQL ALTER TABLE ALGORITHM options
112+
*
113+
* These constants control how MySQL performs ALTER TABLE operations:
114+
* - ALGORITHM_DEFAULT: Let MySQL choose the best algorithm
115+
* - ALGORITHM_INSTANT: Instant operation (no table copy, MySQL 8.0+ / MariaDB 10.3+)
116+
* - ALGORITHM_INPLACE: In-place operation (no full table copy)
117+
* - ALGORITHM_COPY: Traditional table copy algorithm
118+
*
119+
* Usage:
120+
* ```php
121+
* use Migrations\Db\Adapter\MysqlAdapter;
122+
*
123+
* // ALGORITHM=INSTANT alone (recommended)
124+
* $table->addColumn('status', 'string', [
125+
* 'null' => true,
126+
* 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT,
127+
* ]);
128+
*
129+
* // Or with ALGORITHM=INPLACE and explicit LOCK
130+
* $table->addColumn('status', 'string', [
131+
* 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE,
132+
* 'lock' => MysqlAdapter::LOCK_NONE,
133+
* ]);
134+
* ```
135+
*
136+
* Important: ALGORITHM=INSTANT cannot be combined with LOCK=NONE, LOCK=SHARED,
137+
* or LOCK=EXCLUSIVE (MySQL restriction). Use ALGORITHM=INSTANT alone or with
138+
* LOCK=DEFAULT only.
139+
*
140+
* Note: ALGORITHM_INSTANT requires MySQL 8.0+ or MariaDB 10.3+ and only works for
141+
* compatible operations (adding nullable columns, dropping columns, etc.).
142+
* If the operation cannot be performed instantly, MySQL will return an error.
143+
*
144+
* @see https://dev.mysql.com/doc/refman/8.0/en/alter-table.html
145+
* @see https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html
146+
* @see https://mariadb.com/kb/en/alter-table/#algorithm
147+
*/
148+
public const ALGORITHM_DEFAULT = 'DEFAULT';
149+
public const ALGORITHM_INSTANT = 'INSTANT';
150+
public const ALGORITHM_INPLACE = 'INPLACE';
151+
public const ALGORITHM_COPY = 'COPY';
152+
153+
/**
154+
* MySQL ALTER TABLE LOCK options
155+
*
156+
* These constants control the locking behavior during ALTER TABLE operations:
157+
* - LOCK_DEFAULT: Let MySQL choose the appropriate lock level
158+
* - LOCK_NONE: Allow concurrent reads and writes (least restrictive)
159+
* - LOCK_SHARED: Allow concurrent reads, block writes
160+
* - LOCK_EXCLUSIVE: Block all concurrent access (most restrictive)
161+
*
162+
* Usage:
163+
* ```php
164+
* use Migrations\Db\Adapter\MysqlAdapter;
165+
*
166+
* $table->changeColumn('name', 'string', [
167+
* 'limit' => 500,
168+
* 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE,
169+
* 'lock' => MysqlAdapter::LOCK_NONE,
170+
* ]);
171+
* ```
172+
*
173+
* @see https://dev.mysql.com/doc/refman/8.0/en/alter-table.html
174+
* @see https://mariadb.com/kb/en/alter-table/#lock
175+
*/
176+
public const LOCK_DEFAULT = 'DEFAULT';
177+
public const LOCK_NONE = 'NONE';
178+
public const LOCK_SHARED = 'SHARED';
179+
public const LOCK_EXCLUSIVE = 'EXCLUSIVE';
180+
110181
/**
111182
* @inheritDoc
112183
*/
@@ -577,7 +648,16 @@ protected function getAddColumnInstructions(TableMetadata $table, Column $column
577648

578649
$alter .= $this->afterClause($column);
579650

580-
return new AlterInstructions([$alter]);
651+
$instructions = new AlterInstructions([$alter]);
652+
653+
if ($column->getAlgorithm() !== null) {
654+
$instructions->setAlgorithm($column->getAlgorithm());
655+
}
656+
if ($column->getLock() !== null) {
657+
$instructions->setLock($column->getLock());
658+
}
659+
660+
return $instructions;
581661
}
582662

583663
/**
@@ -677,7 +757,16 @@ protected function getChangeColumnInstructions(string $tableName, string $column
677757
$this->afterClause($newColumn),
678758
);
679759

680-
return new AlterInstructions([$alter]);
760+
$instructions = new AlterInstructions([$alter]);
761+
762+
if ($newColumn->getAlgorithm() !== null) {
763+
$instructions->setAlgorithm($newColumn->getAlgorithm());
764+
}
765+
if ($newColumn->getLock() !== null) {
766+
$instructions->setLock($newColumn->getLock());
767+
}
768+
769+
return $instructions;
681770
}
682771

683772
/**
@@ -1164,4 +1253,90 @@ protected function isMariaDb(): bool
11641253

11651254
return stripos($version, 'mariadb') !== false;
11661255
}
1256+
1257+
/**
1258+
* {@inheritDoc}
1259+
*
1260+
* Overridden to support ALGORITHM and LOCK clauses from AlterInstructions.
1261+
*
1262+
* @param string $tableName The table name
1263+
* @param \Migrations\Db\AlterInstructions $instructions The alter instructions
1264+
* @throws \InvalidArgumentException
1265+
* @return void
1266+
*/
1267+
protected function executeAlterSteps(string $tableName, AlterInstructions $instructions): void
1268+
{
1269+
$algorithm = $instructions->getAlgorithm();
1270+
$lock = $instructions->getLock();
1271+
1272+
if ($algorithm === null && $lock === null) {
1273+
parent::executeAlterSteps($tableName, $instructions);
1274+
1275+
return;
1276+
}
1277+
1278+
$algorithmLockClause = '';
1279+
$upperAlgorithm = null;
1280+
$upperLock = null;
1281+
1282+
if ($algorithm !== null) {
1283+
$upperAlgorithm = strtoupper($algorithm);
1284+
$validAlgorithms = [
1285+
self::ALGORITHM_DEFAULT,
1286+
self::ALGORITHM_INSTANT,
1287+
self::ALGORITHM_INPLACE,
1288+
self::ALGORITHM_COPY,
1289+
];
1290+
if (!in_array($upperAlgorithm, $validAlgorithms, true)) {
1291+
throw new InvalidArgumentException(sprintf(
1292+
'Invalid algorithm "%s". Valid options: %s',
1293+
$algorithm,
1294+
implode(', ', $validAlgorithms),
1295+
));
1296+
}
1297+
$algorithmLockClause .= ', ALGORITHM=' . $upperAlgorithm;
1298+
}
1299+
1300+
if ($lock !== null) {
1301+
$upperLock = strtoupper($lock);
1302+
$validLocks = [
1303+
self::LOCK_DEFAULT,
1304+
self::LOCK_NONE,
1305+
self::LOCK_SHARED,
1306+
self::LOCK_EXCLUSIVE,
1307+
];
1308+
if (!in_array($upperLock, $validLocks, true)) {
1309+
throw new InvalidArgumentException(sprintf(
1310+
'Invalid lock "%s". Valid options: %s',
1311+
$lock,
1312+
implode(', ', $validLocks),
1313+
));
1314+
}
1315+
$algorithmLockClause .= ', LOCK=' . $upperLock;
1316+
}
1317+
1318+
if ($upperAlgorithm === self::ALGORITHM_INSTANT && $upperLock !== null && $upperLock !== self::LOCK_DEFAULT) {
1319+
throw new InvalidArgumentException(
1320+
'ALGORITHM=INSTANT cannot be combined with LOCK=NONE, LOCK=SHARED, or LOCK=EXCLUSIVE. ' .
1321+
'Either use ALGORITHM=INSTANT alone, or use ALGORITHM=INSTANT with LOCK=DEFAULT.',
1322+
);
1323+
}
1324+
1325+
$alterTemplate = sprintf('ALTER TABLE %s %%s', $this->quoteTableName($tableName));
1326+
1327+
if ($instructions->getAlterParts()) {
1328+
$alter = sprintf($alterTemplate, implode(', ', $instructions->getAlterParts()) . $algorithmLockClause);
1329+
$this->execute($alter);
1330+
}
1331+
1332+
$state = [];
1333+
foreach ($instructions->getPostSteps() as $instruction) {
1334+
if (is_callable($instruction)) {
1335+
$state = $instruction($state);
1336+
continue;
1337+
}
1338+
1339+
$this->execute($instruction);
1340+
}
1341+
}
11671342
}

src/Db/AlterInstructions.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
namespace Migrations\Db;
1010

11+
use InvalidArgumentException;
12+
1113
/**
1214
* Contains all the information for running an ALTER command for a table,
1315
* and any post-steps required after the fact.
@@ -24,6 +26,16 @@ class AlterInstructions
2426
*/
2527
protected array $postSteps = [];
2628

29+
/**
30+
* @var string|null MySQL-specific: ALGORITHM clause
31+
*/
32+
protected ?string $algorithm = null;
33+
34+
/**
35+
* @var string|null MySQL-specific: LOCK clause
36+
*/
37+
protected ?string $lock = null;
38+
2739
/**
2840
* Constructor
2941
*
@@ -87,12 +99,78 @@ public function getPostSteps(): array
8799
* Merges another AlterInstructions object to this one
88100
*
89101
* @param \Migrations\Db\AlterInstructions $other The other collection of instructions to merge in
102+
* @throws \InvalidArgumentException When algorithm or lock specifications conflict
90103
* @return void
91104
*/
92105
public function merge(AlterInstructions $other): void
93106
{
94107
$this->alterParts = array_merge($this->alterParts, $other->getAlterParts());
95108
$this->postSteps = array_merge($this->postSteps, $other->getPostSteps());
109+
110+
if ($other->getAlgorithm() !== null) {
111+
if ($this->algorithm !== null && $this->algorithm !== $other->getAlgorithm()) {
112+
throw new InvalidArgumentException(sprintf(
113+
'Conflicting algorithm specifications in batched operations: "%s" and "%s". ' .
114+
'All operations in a batch must use the same algorithm, or specify it on only one operation.',
115+
$this->algorithm,
116+
$other->getAlgorithm(),
117+
));
118+
}
119+
$this->algorithm = $other->getAlgorithm();
120+
}
121+
if ($other->getLock() !== null) {
122+
if ($this->lock !== null && $this->lock !== $other->getLock()) {
123+
throw new InvalidArgumentException(sprintf(
124+
'Conflicting lock specifications in batched operations: "%s" and "%s". ' .
125+
'All operations in a batch must use the same lock mode, or specify it on only one operation.',
126+
$this->lock,
127+
$other->getLock(),
128+
));
129+
}
130+
$this->lock = $other->getLock();
131+
}
132+
}
133+
134+
/**
135+
* Sets the ALGORITHM clause (MySQL-specific)
136+
*
137+
* @param string|null $algorithm The algorithm to use
138+
* @return void
139+
*/
140+
public function setAlgorithm(?string $algorithm): void
141+
{
142+
$this->algorithm = $algorithm;
143+
}
144+
145+
/**
146+
* Gets the ALGORITHM clause (MySQL-specific)
147+
*
148+
* @return string|null
149+
*/
150+
public function getAlgorithm(): ?string
151+
{
152+
return $this->algorithm;
153+
}
154+
155+
/**
156+
* Sets the LOCK clause (MySQL-specific)
157+
*
158+
* @param string|null $lock The lock mode to use
159+
* @return void
160+
*/
161+
public function setLock(?string $lock): void
162+
{
163+
$this->lock = $lock;
164+
}
165+
166+
/**
167+
* Gets the LOCK clause (MySQL-specific)
168+
*
169+
* @return string|null
170+
*/
171+
public function getLock(): ?string
172+
{
173+
return $this->lock;
96174
}
97175

98176
/**

src/Db/Table/Column.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ class Column extends DatabaseColumn
8787
*/
8888
protected ?array $values = null;
8989

90+
/**
91+
* @var string|null
92+
*/
93+
protected ?string $algorithm = null;
94+
95+
/**
96+
* @var string|null
97+
*/
98+
protected ?string $lock = null;
99+
90100
/**
91101
* Column constructor
92102
*
@@ -650,6 +660,52 @@ public function getEncoding(): ?string
650660
return $this->encoding;
651661
}
652662

663+
/**
664+
* Sets the ALTER TABLE algorithm (MySQL-specific).
665+
*
666+
* @param string $algorithm Algorithm
667+
* @return $this
668+
*/
669+
public function setAlgorithm(string $algorithm)
670+
{
671+
$this->algorithm = $algorithm;
672+
673+
return $this;
674+
}
675+
676+
/**
677+
* Gets the ALTER TABLE algorithm.
678+
*
679+
* @return string|null
680+
*/
681+
public function getAlgorithm(): ?string
682+
{
683+
return $this->algorithm;
684+
}
685+
686+
/**
687+
* Sets the ALTER TABLE lock mode (MySQL-specific).
688+
*
689+
* @param string $lock Lock mode
690+
* @return $this
691+
*/
692+
public function setLock(string $lock)
693+
{
694+
$this->lock = $lock;
695+
696+
return $this;
697+
}
698+
699+
/**
700+
* Gets the ALTER TABLE lock mode.
701+
*
702+
* @return string|null
703+
*/
704+
public function getLock(): ?string
705+
{
706+
return $this->lock;
707+
}
708+
653709
/**
654710
* Gets all allowed options. Each option must have a corresponding `setFoo` method.
655711
*
@@ -677,6 +733,8 @@ protected function getValidOptions(): array
677733
'seed',
678734
'increment',
679735
'generated',
736+
'algorithm',
737+
'lock',
680738
];
681739
}
682740

0 commit comments

Comments
 (0)