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