1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* Starlit Db. |
4
|
|
|
* |
5
|
|
|
* @copyright Copyright (c) 2016 Starweb / Ehandelslogik i Lund AB |
6
|
|
|
* @license BSD 3-Clause |
7
|
|
|
*/ |
8
|
|
|
|
9
|
|
|
namespace Starlit\Db\Migration; |
10
|
|
|
|
11
|
|
|
use Starlit\Db\Db; |
12
|
|
|
|
13
|
|
|
/** |
14
|
|
|
* Class for handling migration between different database versions. |
15
|
|
|
* |
16
|
|
|
* @author Andreas Nilsson <http://github.com/jandreasn> |
17
|
|
|
*/ |
18
|
|
|
class Migrator |
19
|
|
|
{ |
20
|
|
|
/** |
21
|
|
|
* @const string |
22
|
|
|
*/ |
23
|
|
|
const DIRECTION_UP = 'up'; |
24
|
|
|
|
25
|
|
|
/** |
26
|
|
|
* @const string |
27
|
|
|
*/ |
28
|
|
|
const DIRECTION_DOWN = 'down'; |
29
|
|
|
|
30
|
|
|
/** |
31
|
|
|
* @var string |
32
|
|
|
*/ |
33
|
|
|
protected $migrationsTableName = 'migrations'; |
34
|
|
|
|
35
|
|
|
/** |
36
|
|
|
* @var string |
37
|
|
|
*/ |
38
|
|
|
protected $migrationsDirectory; |
39
|
|
|
|
40
|
|
|
/** |
41
|
|
|
* @var Db |
42
|
|
|
*/ |
43
|
|
|
protected $db; |
44
|
|
|
|
45
|
|
|
/** |
46
|
|
|
* @var callable |
47
|
|
|
*/ |
48
|
|
|
protected $infoCallback; |
49
|
|
|
|
50
|
|
|
/** |
51
|
|
|
* @var AbstractMigration[] |
52
|
|
|
*/ |
53
|
|
|
protected $migrations; |
54
|
|
|
|
55
|
|
|
/** |
56
|
|
|
* @var int[] |
57
|
|
|
*/ |
58
|
|
|
protected $migratedNumbers; |
59
|
|
|
|
60
|
|
|
/** |
61
|
|
|
* @var bool |
62
|
|
|
*/ |
63
|
|
|
private $hasMigrationsTable; |
64
|
|
|
|
65
|
|
|
/** |
66
|
|
|
* @param string $migrationsDirectory |
67
|
|
|
* @param Db $db |
68
|
|
|
* @param callable $infoCallback |
69
|
|
|
*/ |
70
|
10 |
|
public function __construct( |
71
|
|
|
$migrationsDirectory, |
72
|
|
|
Db $db, |
73
|
|
|
callable $infoCallback = null |
74
|
|
|
) { |
75
|
10 |
|
$this->migrationsDirectory = $migrationsDirectory; |
76
|
10 |
|
$this->db = $db; |
77
|
10 |
|
$this->infoCallback = $infoCallback; |
78
|
10 |
|
} |
79
|
|
|
|
80
|
|
|
/** |
81
|
|
|
* @return \SplFileInfo[] |
82
|
|
|
*/ |
83
|
8 |
|
protected function findMigrationFiles() |
84
|
|
|
{ |
85
|
8 |
|
$migrationFiles = []; |
86
|
8 |
|
$directoryIterator = new \FilesystemIterator($this->migrationsDirectory); |
87
|
8 |
|
foreach ($directoryIterator as $fileInfo) { |
88
|
8 |
|
if ($fileInfo->isFile() && $fileInfo->getExtension() === 'php') { |
89
|
8 |
|
$migrationFiles[] = $fileInfo; |
90
|
8 |
|
} |
91
|
8 |
|
} |
92
|
|
|
|
93
|
8 |
|
return $migrationFiles; |
94
|
|
|
} |
95
|
|
|
|
96
|
|
|
/** |
97
|
|
|
* @return AbstractMigration[] |
98
|
|
|
*/ |
99
|
8 |
|
protected function loadMigrations() |
100
|
|
|
{ |
101
|
8 |
|
$migrations = []; |
102
|
8 |
|
foreach ($this->findMigrationFiles() as $file) { |
103
|
8 |
|
require_once $file->getPathname(); |
104
|
|
|
|
105
|
8 |
|
$className = '\\' . $file->getBasename('.' . $file->getExtension()); |
106
|
8 |
|
$migration = new $className($this->db); |
107
|
|
|
|
108
|
8 |
|
$migrations[$migration->getNumber()] = $migration; |
109
|
8 |
|
} |
110
|
|
|
|
111
|
8 |
|
ksort($migrations); |
112
|
|
|
|
113
|
8 |
|
return $migrations; |
114
|
|
|
} |
115
|
|
|
|
116
|
|
|
/** |
117
|
|
|
* @return AbstractMigration[] |
118
|
|
|
*/ |
119
|
8 |
|
public function getMigrations() |
120
|
|
|
{ |
121
|
8 |
|
if (!isset($this->migrations)) { |
122
|
8 |
|
$this->migrations = $this->loadMigrations(); |
123
|
8 |
|
} |
124
|
|
|
|
125
|
8 |
|
return $this->migrations; |
126
|
|
|
} |
127
|
|
|
|
128
|
|
|
/** |
129
|
|
|
* @return int |
130
|
|
|
*/ |
131
|
8 |
|
public function getLatestNumber() |
132
|
|
|
{ |
133
|
8 |
|
$numbers = array_keys($this->getMigrations()); |
134
|
8 |
|
$latest = end($numbers); |
135
|
|
|
|
136
|
8 |
|
return ($latest !== false) ? $latest : 0; |
137
|
|
|
|
138
|
|
|
} |
139
|
|
|
|
140
|
|
|
/** |
141
|
|
|
* @return bool |
142
|
|
|
*/ |
143
|
8 |
|
private function hasMigrationsTable() |
144
|
|
|
{ |
145
|
8 |
|
if (!isset($this->hasMigrationsTable)) { |
146
|
8 |
|
$this->hasMigrationsTable = |
147
|
8 |
|
(bool) $this->db->fetchOne('SHOW TABLES LIKE ?', [$this->migrationsTableName]); |
148
|
8 |
|
} |
149
|
|
|
|
150
|
8 |
|
return $this->hasMigrationsTable; |
151
|
|
|
} |
152
|
|
|
|
153
|
|
|
/** |
154
|
|
|
*/ |
155
|
8 |
|
protected function createMigrationsTable() |
156
|
|
|
{ |
157
|
8 |
|
if (!$this->hasMigrationsTable()) { |
158
|
8 |
|
$this->db->exec(' |
159
|
8 |
|
CREATE TABLE `' . $this->migrationsTableName . '` ( |
160
|
|
|
`migration_number` BIGINT NOT NULL, |
161
|
|
|
`completed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, |
162
|
|
|
PRIMARY KEY (`migration_number`) |
163
|
|
|
) |
164
|
8 |
|
'); |
165
|
8 |
|
$this->hasMigrationsTable = true; |
166
|
8 |
|
} |
167
|
8 |
|
} |
168
|
|
|
|
169
|
|
|
/** |
170
|
|
|
* @return array |
171
|
|
|
*/ |
172
|
8 |
|
protected function getMigratedNumbers() |
173
|
|
|
{ |
174
|
8 |
|
if (!isset($this->migratedNumbers)) { |
175
|
8 |
|
$this->createMigrationsTable(); |
176
|
|
|
|
177
|
8 |
|
$this->migratedNumbers = []; |
178
|
|
|
|
179
|
8 |
|
$sql = 'SELECT * FROM `' . $this->migrationsTableName . '` ORDER BY migration_number'; |
180
|
8 |
|
foreach ($this->db->fetchAll($sql) as $row) { |
181
|
8 |
|
$this->migratedNumbers[] = $row['migration_number']; |
182
|
8 |
|
} |
183
|
8 |
|
} |
184
|
|
|
|
185
|
8 |
|
return $this->migratedNumbers; |
186
|
|
|
} |
187
|
|
|
|
188
|
|
|
/** |
189
|
|
|
* @return int |
190
|
|
|
*/ |
191
|
8 |
|
public function getCurrentNumber() |
192
|
|
|
{ |
193
|
8 |
|
$migratedNumbers = $this->getMigratedNumbers(); |
194
|
8 |
|
$current = end($migratedNumbers); |
195
|
|
|
|
196
|
8 |
|
return ($current !== false) ? $current : 0; |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
/** |
200
|
|
|
* @param int $toNumber |
201
|
|
|
* @return string |
202
|
|
|
*/ |
203
|
4 |
|
protected function getDirection($toNumber) |
204
|
|
|
{ |
205
|
4 |
|
return ($this->getCurrentNumber() > $toNumber) ? self::DIRECTION_DOWN : self::DIRECTION_UP; |
206
|
|
|
} |
207
|
|
|
|
208
|
|
|
/** |
209
|
|
|
* @param int $to |
210
|
|
|
* @return AbstractMigration[] |
211
|
|
|
*/ |
212
|
4 |
|
public function getMigrationsTo($to = null) |
213
|
|
|
{ |
214
|
4 |
|
$toNumber = $this->getToNumber($to); |
215
|
4 |
|
$allMigrations = $this->getMigrations(); |
216
|
4 |
|
$direction = $this->getDirection($toNumber); |
217
|
4 |
|
if ($direction === self::DIRECTION_DOWN) { |
218
|
1 |
|
$allMigrations = array_reverse($allMigrations, true); |
219
|
1 |
|
} |
220
|
|
|
|
221
|
4 |
|
$migrations = []; |
222
|
4 |
|
foreach ($allMigrations as $migrationNumber => $migration) { |
223
|
4 |
|
if ($this->shouldMigrationBeMigrated($migration, $toNumber, $direction)) { |
224
|
3 |
|
$migrations[$migrationNumber] = $migration; |
225
|
3 |
|
} |
226
|
4 |
|
} |
227
|
|
|
|
228
|
4 |
|
return $migrations; |
229
|
|
|
} |
230
|
|
|
|
231
|
|
|
/** |
232
|
|
|
* @param AbstractMigration $migration |
233
|
|
|
* @param int $toNumber |
234
|
|
|
* @param string $direction |
235
|
|
|
* @return bool |
236
|
|
|
*/ |
237
|
4 |
|
private function shouldMigrationBeMigrated(AbstractMigration $migration, $toNumber, $direction) |
238
|
|
|
{ |
239
|
|
|
if (($direction === self::DIRECTION_UP |
240
|
4 |
|
&& $migration->getNumber() <= $toNumber |
241
|
4 |
|
&& !in_array($migration->getNumber(), $this->getMigratedNumbers())) |
242
|
|
|
|| ($direction == self::DIRECTION_DOWN |
243
|
4 |
|
&& $migration->getNumber() > $toNumber |
244
|
4 |
|
&& in_array($migration->getNumber(), $this->getMigratedNumbers())) |
245
|
4 |
|
) { |
246
|
3 |
|
return true; |
247
|
|
|
} |
248
|
|
|
|
249
|
4 |
|
return false; |
250
|
|
|
} |
251
|
|
|
|
252
|
|
|
/** |
253
|
|
|
* @param AbstractMigration $migration |
254
|
|
|
*/ |
255
|
2 |
|
protected function addMigratedMigration(AbstractMigration $migration) |
256
|
|
|
{ |
257
|
2 |
|
$this->createMigrationsTable(); |
258
|
|
|
|
259
|
2 |
|
$this->db->exec( |
260
|
2 |
|
'INSERT INTO `' . $this->migrationsTableName . '` SET migration_number = ?', |
261
|
2 |
|
[$migration->getNumber()] |
262
|
2 |
|
); |
263
|
|
|
|
264
|
2 |
|
$this->migratedNumbers[] = $migration->getNumber(); |
265
|
2 |
|
} |
266
|
|
|
|
267
|
|
|
/** |
268
|
|
|
* @param AbstractMigration $migration |
269
|
|
|
*/ |
270
|
1 |
|
protected function deleteMigratedMigration(AbstractMigration $migration) |
271
|
|
|
{ |
272
|
1 |
|
$this->createMigrationsTable(); |
273
|
|
|
|
274
|
1 |
|
$this->db->exec( |
275
|
1 |
|
'DELETE FROM `' . $this->migrationsTableName . '` WHERE migration_number = ?', |
276
|
1 |
|
[$migration->getNumber()] |
277
|
1 |
|
); |
278
|
|
|
|
279
|
1 |
|
if (($key = array_search($migration->getNumber(), $this->migratedNumbers)) !== false) { |
280
|
1 |
|
unset($this->migratedNumbers[$key]); |
281
|
1 |
|
} |
282
|
1 |
|
} |
283
|
|
|
|
284
|
|
|
/** |
285
|
|
|
* Empty database. |
286
|
|
|
*/ |
287
|
1 |
|
public function emptyDb() |
288
|
|
|
{ |
289
|
1 |
|
if (($rows = $this->db->fetchAll('SHOW TABLES', [], true))) { |
290
|
1 |
|
$this->db->exec('SET foreign_key_checks = 0'); |
291
|
1 |
|
foreach ($rows as $row) { |
292
|
1 |
|
$this->db->exec('DROP TABLE `' . $row[0] . '`'); |
293
|
1 |
|
} |
294
|
1 |
|
$this->db->exec('SET foreign_key_checks = 1'); |
295
|
1 |
|
} |
296
|
1 |
|
} |
297
|
|
|
|
298
|
|
|
/** |
299
|
|
|
* @param int|null $to |
300
|
|
|
* @return int |
301
|
|
|
* @throws \InvalidArgumentException |
302
|
|
|
*/ |
303
|
6 |
|
protected function getToNumber($to = null) |
304
|
|
|
{ |
305
|
6 |
|
if ($to === null) { |
306
|
3 |
|
return $this->getLatestNumber(); |
307
|
6 |
|
} elseif (!preg_match('/^\d+$/', $to)) { |
308
|
1 |
|
throw new \InvalidArgumentException('Migration number must be a number'); |
309
|
|
|
} |
310
|
|
|
|
311
|
5 |
|
$toNumber = (int) $to; |
312
|
|
|
|
313
|
5 |
|
if (!in_array($toNumber, array_keys($this->getMigrations()))) { |
314
|
1 |
|
throw new \InvalidArgumentException('Invalid migration number'); |
315
|
|
|
} |
316
|
|
|
|
317
|
4 |
|
return $toNumber; |
318
|
|
|
} |
319
|
|
|
|
320
|
|
|
/** |
321
|
|
|
* @param int|null $to A migration number to migrate to (if not provided, latest migration number will be used) |
322
|
|
|
* @return bool Returns true if any action was performed |
323
|
|
|
* @throws \InvalidArgumentException |
324
|
|
|
* @throws \RuntimeException |
325
|
|
|
*/ |
326
|
7 |
|
public function migrate($to = null) |
327
|
|
|
{ |
328
|
7 |
|
if ($this->getCurrentNumber() > $this->getLatestNumber()) { |
329
|
1 |
|
throw new \RuntimeException(sprintf( |
330
|
1 |
|
'The current migration number (%d) is higher than latest available (%d). Something is wrong!', |
331
|
1 |
|
$this->getCurrentNumber(), |
332
|
1 |
|
$this->getLatestNumber() |
333
|
1 |
|
)); |
334
|
|
|
} |
335
|
|
|
|
336
|
6 |
|
$toNumber = $this->getToNumber($to); |
337
|
4 |
|
$migrations = $this->getMigrationsTo($toNumber); |
338
|
|
|
|
339
|
|
|
// If there's no migration to be done, we are up to date. |
340
|
4 |
|
if (!$migrations) { |
|
|
|
|
341
|
1 |
|
$this->addInfo(sprintf( |
342
|
1 |
|
'No migrations available, things are up to date (migration %d)!', |
343
|
1 |
|
$this->getCurrentNumber() |
344
|
1 |
|
)); |
345
|
|
|
|
346
|
1 |
|
return false; |
347
|
|
|
} |
348
|
|
|
|
349
|
3 |
|
$this->addInfo(sprintf( |
350
|
3 |
|
'Running %d migrations from migration %d to %d...', |
351
|
3 |
|
count($migrations), |
352
|
3 |
|
$this->getCurrentNumber(), |
353
|
|
|
$toNumber |
354
|
3 |
|
)); |
355
|
|
|
|
356
|
3 |
|
$this->runMigrations($migrations, $this->getDirection($toNumber)); |
357
|
|
|
|
358
|
3 |
|
$this->addInfo(sprintf('Done! %s migrations migrated!', count($migrations))); |
359
|
|
|
|
360
|
3 |
|
return true; |
361
|
|
|
} |
362
|
|
|
|
363
|
|
|
/** |
364
|
|
|
* @param AbstractMigration[] $migrations |
365
|
|
|
* @param string $direction |
366
|
|
|
*/ |
367
|
3 |
|
protected function runMigrations(array $migrations, $direction) |
368
|
|
|
{ |
369
|
3 |
|
foreach ($migrations as $migration) { |
370
|
3 |
|
if ($direction === self::DIRECTION_UP) { |
371
|
2 |
|
$this->addInfo(sprintf(' - Migrating up %d...', $migration->getNumber())); |
372
|
2 |
|
$migration->up(); |
373
|
2 |
|
$this->addMigratedMigration($migration); |
374
|
|
|
|
375
|
2 |
|
} else { |
376
|
1 |
|
$this->addInfo(sprintf(' - Migrating down %d...', $migration->getNumber())); |
377
|
1 |
|
$migration->down(); |
378
|
1 |
|
$this->deleteMigratedMigration($migration); |
379
|
|
|
|
380
|
|
|
} |
381
|
3 |
|
} |
382
|
3 |
|
} |
383
|
|
|
|
384
|
|
|
/** |
385
|
|
|
* @param string $info |
386
|
|
|
*/ |
387
|
4 |
|
protected function addInfo($info) |
388
|
|
|
{ |
389
|
4 |
|
if ($this->infoCallback) { |
390
|
4 |
|
$callback = $this->infoCallback; |
391
|
4 |
|
$callback($info); |
392
|
4 |
|
} |
393
|
4 |
|
} |
394
|
|
|
} |
395
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.