Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.28% covered (success)
98.28%
171 / 174
90.48% covered (success)
90.48%
19 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Migrator
98.28% covered (success)
98.28%
171 / 174
90.48% covered (success)
90.48%
19 / 21
90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 create
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 run
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 runAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rollback
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
12
 rollbackAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPath
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 getPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 hasTable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 createTable
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 getNextBatch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getCurrentBatch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getByBatch
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 loadCurrent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 storeCurrent
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 deleteCurrent
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 clearCurrent
80.00% covered (success)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Db\Sql;
16
17use Pop\Db\Adapter\AbstractAdapter;
18use Pop\Db\Sql\Parser;
19
20/**
21 * Sql migrator class
22 *
23 * @category   Pop
24 * @package    Pop\Db
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    7.0.0
29 */
30class Migrator extends Migration\AbstractMigrator
31{
32
33    /**
34     * Migration path
35     * @var ?string
36     */
37    protected ?string $path = null;
38
39    /**
40     * Current migration position
41     * @var ?int
42     */
43    protected ?int $current = null;
44
45    /**
46     * Migrations
47     * @var array
48     */
49    protected array $migrations = [];
50
51    /**
52     * Constructor
53     *
54     * Instantiate the migrator object
55     *
56     * @param  AbstractAdapter $db
57     * @param  string          $path
58     * @throws Exception
59     */
60    public function __construct(AbstractAdapter $db, string $path)
61    {
62        parent::__construct($db);
63        $this->setPath($path);
64
65        // If migration is stored in a DB table, check for table and create if does not exist
66        if (($this->isTable()) && (!$this->hasTable())) {
67            $this->createTable();
68        }
69    }
70
71    /**
72     * Create new migration file
73     *
74     * @param  string  $class
75     * @param  ?string $path
76     * @throws Exception
77     * @return string
78     */
79    public static function create(string $class, ?string $path = null): string
80    {
81        $file          = date('YmdHis') . '_' . Parser\Table::parse($class) . '.php';
82        $classContents = str_replace(
83            'MigrationTemplate', $class, file_get_contents(__DIR__ . '/Migration/Template/MigrationTemplate.php')
84        );
85
86        if ($path !== null) {
87            if (!is_dir($path)) {
88                throw new Exception('Error: That path does not exist');
89            }
90            $file = $path . DIRECTORY_SEPARATOR . $file;
91        }
92
93        file_put_contents($file, $classContents);
94
95        return $file;
96    }
97
98    /**
99     * Run the migrator (up/forward direction)
100     *
101     * @param  mixed $steps
102     * @return Migrator
103     */
104    public function run(mixed $steps = 1): Migrator
105    {
106        ksort($this->migrations, SORT_NUMERIC);
107
108        $stepsToRun = [];
109        $batch      = $this->getNextBatch();
110
111        foreach ($this->migrations as $timestamp => $migration) {
112            if (strtotime((string)$timestamp) > strtotime((string)($this->current ?? 0))) {
113                $stepsToRun[] = $timestamp;
114            }
115        }
116
117        $numOfSteps = count($stepsToRun);
118
119        if ($numOfSteps > 0) {
120            $stop = (($steps == 'all') || ($steps > $numOfSteps)) ? $numOfSteps : (int)$steps;
121            for ($i = 0; $i < $stop; $i++) {
122                $class = $this->migrations[$stepsToRun[$i]]['class'];
123                if (!class_exists($class)) {
124                    include $this->path . DIRECTORY_SEPARATOR . $this->migrations[$stepsToRun[$i]]['filename'];
125                }
126                $migration = new $class($this->db);
127                $migration->up();
128
129                $current = (int)$stepsToRun[$i];
130                $this->storeCurrent($current, $this->migrations[$stepsToRun[$i]]['filename'], $batch);
131            }
132        }
133
134        return $this;
135    }
136
137    /**
138     * Run all the migrator (up/forward direction)
139     *
140     * @return Migrator
141     */
142    public function runAll(): Migrator
143    {
144        return $this->run('all');
145    }
146
147    /**
148     * Roll back the migrator (down/backward direction)
149     *
150     * @param  mixed $steps
151     * @return Migrator
152     */
153    public function rollback(mixed $steps = 1): Migrator
154    {
155        krsort($this->migrations, SORT_NUMERIC);
156
157        $stepsToRun = [];
158        $class      = null;
159
160        if (is_string($steps) && str_starts_with($steps, 'batch-')) {
161            $stepsToRun = $this->getByBatch($steps);
162        } else {
163            foreach ($this->migrations as $timestamp => $migration) {
164                if (strtotime((string)$timestamp) <= strtotime((string)($this->current ?? 0))) {
165                    $stepsToRun[] = $timestamp;
166                }
167            }
168        }
169
170        $numOfSteps = count($stepsToRun);
171
172        if ($numOfSteps > 0) {
173            $stop = (($steps == 'all') || ($steps > $numOfSteps)) ? $numOfSteps : (int)$steps;
174            for ($i = 0; $i < $stop; $i++) {
175                $class = $this->migrations[$stepsToRun[$i]]['class'];
176                if (!class_exists($class)) {
177                    include $this->path . DIRECTORY_SEPARATOR . $this->migrations[$stepsToRun[$i]]['filename'];
178                }
179                $migration = new $class($this->db);
180                $migration->down();
181
182                $this->deleteCurrent($stepsToRun[$i], ($stepsToRun[$i + 1] ?? null));
183            }
184        }
185
186        if (!isset($i) || !isset($stepsToRun[$i])) {
187            $this->clearCurrent();
188        }
189
190        return $this;
191    }
192
193    /**
194     * Roll back all the migrator (down/backward direction)
195     *
196     * @return Migrator
197     */
198    public function rollbackAll(): Migrator
199    {
200        return $this->rollback('all');
201    }
202
203    /**
204     * Set the migration path and get migration files
205     *
206     * @param  string $path
207     * @throws Exception
208     * @return Migrator
209     */
210    public function setPath(string $path): Migrator
211    {
212        if (!file_exists($path)) {
213            throw new Exception('Error: That migration path does not exist');
214        }
215
216        $this->path = $path;
217
218        $handle = opendir($this->path);
219
220        while (($filename = readdir($handle)) !== false) {
221            if (($filename != '.') && ($filename != '..') &&
222                !is_dir($this->path . DIRECTORY_SEPARATOR . $filename) && (str_ends_with($filename, '.php'))) {
223                $fileContents = trim(file_get_contents($this->path . DIRECTORY_SEPARATOR . $filename));
224                if ((str_contains($fileContents, 'extends AbstractMigration'))) {
225                    $namespace = null;
226                    if (str_contains($fileContents, 'namespace ')) {
227                        $namespace = substr($fileContents, (strpos($fileContents, 'namespace ') + 10));
228                        $namespace = trim(substr($namespace, 0, strpos($namespace, ';'))) . '\\';
229                    }
230                    $class = substr($fileContents, (strpos($fileContents, 'class ') + 6));
231                    $class = $namespace . substr($class, 0, strpos($class, ' extends'));
232                    $this->migrations[substr($filename, 0, 14)] = [
233                        'class'    => $class,
234                        'filename' => $filename
235                    ];
236                }
237            }
238        }
239
240        closedir($handle);
241
242        $this->loadCurrent();
243
244        return $this;
245    }
246
247    /**
248     * Get the migration path
249     *
250     * @return ?string
251     */
252    public function getPath(): ?string
253    {
254        return $this->path;
255    }
256
257    /**
258     * Get the current migration position
259     *
260     * @return ?int
261     */
262    public function getCurrent(): ?int
263    {
264        return $this->current;
265    }
266
267    /**
268     * Determine if the migration source is stored in a file
269     *
270     * @return bool
271     */
272    public function isFile(): bool
273    {
274        return (file_exists($this->path . DIRECTORY_SEPARATOR . '.current'));
275    }
276
277    /**
278     * Determine if the migration source is stored in a DB
279     *
280     * @return bool
281     */
282    public function isTable(): bool
283    {
284        if (file_exists($this->path . DIRECTORY_SEPARATOR . '.table')) {
285            $table = trim(file_get_contents($this->path . DIRECTORY_SEPARATOR . '.table'));
286            return (class_exists($table) && is_subclass_of($table, 'Pop\Db\Record'));
287        } else {
288            return false;
289        }
290    }
291
292    /**
293     * Determine if the migration source has a table in the DB
294     *
295     * @return bool
296     */
297    public function hasTable(): bool
298    {
299        if ($this->isTable()) {
300            $migrationTable = $this->getTable();
301            return (in_array($migrationTable::table(), $this->db->getTables()));
302        } else {
303            return false;
304        }
305    }
306
307    /**
308     * Get table class string
309     *
310     * @return string
311     */
312    public function getTable(): string
313    {
314        return (file_exists($this->path . DIRECTORY_SEPARATOR . '.table')) ?
315            trim(file_get_contents($this->path . DIRECTORY_SEPARATOR . '.table')) : '';
316    }
317
318    /**
319     * Create table
320     *
321     * @return Migrator
322     */
323    public function createTable(): Migrator
324    {
325        if (($this->isTable()) && (!$this->hasTable())) {
326            $migrationTable = $this->getTable();
327
328            $schema = $this->db->createSchema();
329            $schema->create($migrationTable::table())
330                ->int('id', 16)->notNullable()->increment()
331                ->varchar('migration_id', 255)
332                ->varchar('class_file', 255)
333                ->int('batch', 16)
334                ->datetime('timestamp')->notNullable()
335                ->primary('id')
336                ->index('migration_id', 'migration_id')
337                ->index('class_file', 'class_file')
338                ->index('batch', 'batch')
339                ->index('timestamp', 'timestamp');
340
341            $schema->execute();
342        }
343
344        return $this;
345    }
346
347    /**
348     * Get next batch
349     *
350     * @return int
351     */
352    public function getNextBatch(): int
353    {
354        $batch = 1;
355
356        if (($this->isTable()) && ($this->hasTable())) {
357            $class = $this->getTable();
358            if (!empty($class)) {
359                $current = $class::findOne(null, ['order' => 'batch DESC']);
360                if (!empty($current->batch)) {
361                    $batch = (int)$current->batch + 1;
362                }
363            }
364        }
365
366        return $batch;
367    }
368
369    /**
370     * Get current batch
371     *
372     * @return int
373     */
374    public function getCurrentBatch(): int
375    {
376        $batch = 0;
377
378        if (($this->isTable()) && ($this->hasTable())) {
379            $class = $this->getTable();
380            if (!empty($class)) {
381                $current = $class::findOne(null, ['order' => 'batch DESC']);
382                if (!empty($current->batch)) {
383                    $batch = (int)$current->batch;
384                }
385            }
386        }
387
388        return $batch;
389    }
390
391    /**
392     * Get migrations by batch
393     *
394     * @param  string|int $batch
395     * @return array
396     */
397    public function getByBatch(string|int $batch): array
398    {
399        if (is_string($batch) && str_starts_with($batch, 'batch-')) {
400            $batch = substr($batch, 6);
401        }
402
403        $batchMigrations = [];
404
405        if (($this->isTable()) && ($this->hasTable()) && ($batch == $this->getCurrentBatch())) {
406            $class = $this->getTable();
407            if (!empty($class)) {
408                $batchMigrations = array_values(
409                    $class::findBy(['batch' => $batch], ['order' => 'migration_id DESC'])->toArray(['column' => 'migration_id'])
410                );
411            }
412        }
413
414        return $batchMigrations;
415    }
416
417    /**
418      * Load the current migration timestamp
419      *
420      * @return void
421    */
422    protected function loadCurrent(): void
423    {
424        if (($this->isTable()) && ($this->hasTable())) {
425            $class = $this->getTable();
426            if (!empty($class)) {
427                $current = $class::findOne(null, ['order' => 'id DESC']);
428                if (isset($current->id)) {
429                    $this->current = (int)$current->migration_id;
430                }
431            }
432        } else if ($this->isFile()) {
433            $current = file_get_contents($this->path . DIRECTORY_SEPARATOR . '.current');
434            if ($current !== false) {
435                $this->current = (int)$current;
436            }
437        }
438    }
439
440    /**
441     * Store the current migration timestamp
442     *
443     * @param int    $current
444     * @param string $classFile
445     * @param ?int   $batch
446     * @return void
447     */
448    protected function storeCurrent(int $current, string $classFile, ?int $batch = null): void
449    {
450        if (($this->isTable()) && ($this->hasTable())) {
451            $class = $this->getTable();
452            if (!empty($class)) {
453                $migration = new $class([
454                    'migration_id' => $current,
455                    'class_file'   => $classFile,
456                    'batch'        => $batch,
457                    'timestamp'    => date('Y-m-d H:i:s')
458                ]);
459                $migration->save();
460            }
461        } else {
462            file_put_contents($this->path . DIRECTORY_SEPARATOR . '.current', $current);
463        }
464
465        $this->current = $current;
466    }
467
468    /**
469     * Delete migration
470     *
471     * @param  int|string  $current
472     * @param  int|string|null $previous
473     * @return void
474     */
475    protected function deleteCurrent(int|string $current, int|string|null $previous = null): void
476    {
477        if (($this->isTable()) && ($this->hasTable())) {
478            $class     = $this->getTable();
479            $migration = $class::findOne(['migration_id' => $current]);
480            if (isset($migration->id)) {
481                $migration->delete();
482            }
483        } else if ($this->isFile()) {
484            if ($previous !== null) {
485                file_put_contents($this->path . DIRECTORY_SEPARATOR . '.current', $previous);
486            } else {
487                unlink($this->path . DIRECTORY_SEPARATOR . '.current');
488            }
489        }
490
491        $this->loadCurrent();
492    }
493
494    /**
495     * Clear migrations
496     *
497     * @return void
498     */
499    protected function clearCurrent(): void
500    {
501        if (($this->isTable()) && ($this->hasTable())) {
502            $class = $this->getTable();
503            $count = $class::total();
504            if ($count > 0) {
505                $migrations = new $class();
506                $migrations->delete();
507            }
508        } else if ($this->isFile()) {
509            if (file_exists($this->path . DIRECTORY_SEPARATOR . '.current')) {
510                unlink($this->path . DIRECTORY_SEPARATOR . '.current');
511            }
512        }
513
514        $this->current = null;
515    }
516
517}