Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
117 / 117
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
Migrator
100.00% covered (success)
100.00%
117 / 117
100.00% covered (success)
100.00%
18 / 18
43
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setDatabase
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDatabase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addDirectory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getDirectories
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 getLastMigrationName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getFiles
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 getMigrationsAsc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMigrationsDesc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMigrations
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 migrateDown
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 migrateUp
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 insertRow
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 deleteRow
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 migrateTo
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Database Extra Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Database\Extra;
11
12use Framework\Database\Database;
13use Framework\Database\Definition\Table\TableDefinition;
14use Framework\Helpers\Isolation;
15use Generator;
16use InvalidArgumentException;
17
18/**
19 * Class Migrator.
20 *
21 * @package database-extra
22 */
23class Migrator
24{
25    protected Database $database;
26    protected string $table;
27    /**
28     * @var array<int,string>
29     */
30    protected array $directories;
31
32    /**
33     * @param Database $database
34     * @param array<string> $directories
35     * @param string $table
36     */
37    public function __construct(
38        Database $database,
39        array $directories,
40        string $table = 'Migrations'
41    ) {
42        foreach ($directories as $directory) {
43            $this->addDirectory($directory);
44        }
45        $this->setDatabase($database)
46            ->setTable($table)
47            ->prepare();
48    }
49
50    public function setDatabase(Database $database) : static
51    {
52        $this->database = $database;
53        return $this;
54    }
55
56    public function getDatabase() : Database
57    {
58        return $this->database;
59    }
60
61    public function addDirectory(string $directory) : static
62    {
63        $realpath = \realpath($directory);
64        if ($realpath === false || ! \is_dir($realpath)) {
65            throw new InvalidArgumentException('Directory path is invalid: ' . $directory);
66        }
67        $this->directories[] = $realpath . \DIRECTORY_SEPARATOR;
68        return $this;
69    }
70
71    /**
72     * @return array<int,string>
73     */
74    public function getDirectories() : array
75    {
76        return $this->directories;
77    }
78
79    public function setTable(string $table) : static
80    {
81        $this->table = $table;
82        return $this;
83    }
84
85    public function getTable() : string
86    {
87        return $this->table;
88    }
89
90    protected function prepare() : void
91    {
92        $result = $this->getDatabase()->query(
93            'SHOW TABLES LIKE ' . $this->getDatabase()->quote($this->getTable())
94        )->fetch();
95        if ($result) {
96            return;
97        }
98        $this->getDatabase()->createTable()
99            ->table($this->getTable())
100            ->definition(static function (TableDefinition $definition) : void {
101                $definition->column('id')->int()->autoIncrement()->primaryKey();
102                $definition->column('migration')->varchar(255);
103                $definition->column('timestamp')->timestamp()->notNull();
104                $definition->index()->key('migration');
105            })->run();
106    }
107
108    /**
109     * Get current migrated version from Database.
110     *
111     * @return string|null
112     */
113    public function getLastMigrationName() : ?string
114    {
115        return $this->database->select()
116            ->from($this->getTable())
117            ->orderByDesc('id')
118            ->limit(1)
119            ->run()
120            ->fetch()->migration ?? null;
121    }
122
123    /**
124     * @return array<int,string>
125     */
126    protected function getFiles() : array
127    {
128        $files = [];
129        foreach ($this->getDirectories() as $directory) {
130            foreach ((array) \glob($directory . '*.php') as $filename) {
131                if ($filename && \is_file($filename)) {
132                    $files[] = [
133                        'basename' => \basename($filename, '.php'),
134                        'filename' => $filename,
135                    ];
136                }
137            }
138        }
139        \usort($files, static function ($file1, $file2) {
140            return \strnatcmp($file1['basename'], $file2['basename']);
141        });
142        $result = [];
143        foreach ($files as $file) {
144            $result[] = $file['filename'];
145        }
146        return $result;
147    }
148
149    /**
150     * @return Generator<string,Migration>
151     */
152    protected function getMigrationsAsc() : Generator
153    {
154        yield from $this->getMigrations($this->getFiles());
155    }
156
157    /**
158     * @return Generator<string,Migration>
159     */
160    protected function getMigrationsDesc() : Generator
161    {
162        yield from $this->getMigrations(\array_reverse($this->getFiles()));
163    }
164
165    /**
166     * @param array<string> $files
167     *
168     * @return Generator<string,Migration>
169     */
170    protected function getMigrations(array $files) : Generator
171    {
172        foreach ($files as $file) {
173            $migration = Isolation::require($file);
174            if ($migration instanceof Migration) {
175                $migration->setDatabase($this->getDatabase());
176                $file = \basename($file, '.php');
177                yield $file => $migration;
178            }
179        }
180    }
181
182    /**
183     * @param int|null $quantity
184     *
185     * @return Generator<string>
186     */
187    public function migrateDown(int $quantity = null) : Generator
188    {
189        $count = 0;
190        $last = $this->getLastMigrationName() ?? '';
191        foreach ($this->getMigrationsDesc() as $name => $migration) {
192            $cmp = \strnatcmp($last, $name);
193            if ($cmp < 0) {
194                continue;
195            }
196            $migration->down();
197            $this->deleteRow($name);
198            yield $name;
199            $count++;
200            if ($count === $quantity) {
201                break;
202            }
203        }
204    }
205
206    /**
207     * @param int|null $quantity
208     *
209     * @return Generator<string>
210     */
211    public function migrateUp(int $quantity = null) : Generator
212    {
213        $count = 0;
214        $last = $this->getLastMigrationName() ?? '';
215        foreach ($this->getMigrationsAsc() as $name => $migration) {
216            $cmp = \strnatcmp($last, $name);
217            if ($cmp >= 0) {
218                continue;
219            }
220            $migration->up();
221            $this->insertRow($name);
222            yield $name;
223            $count++;
224            if ($count === $quantity) {
225                break;
226            }
227        }
228    }
229
230    protected function insertRow(string $name) : int | string
231    {
232        return $this->getDatabase()->insert()
233            ->into($this->getTable())
234            ->set([
235                'migration' => $name,
236                'timestamp' => \gmdate('Y-m-d H:i:s'),
237            ])->run();
238    }
239
240    protected function deleteRow(string $name) : int | string
241    {
242        return $this->getDatabase()->delete()
243            ->from($this->getTable())
244            ->whereEqual('migration', $name)
245            ->orderByDesc('id')
246            ->limit(1)
247            ->run();
248    }
249
250    /**
251     * @param string $name
252     *
253     * @return Generator<string>
254     */
255    public function migrateTo(string $name) : Generator
256    {
257        $last = $this->getLastMigrationName() ?? '';
258        $cmp = \strnatcmp($last, $name);
259        if ($cmp === 0) {
260            return;
261        }
262        if ($cmp < 0) {
263            foreach ($this->getMigrationsAsc() as $n => $migration) {
264                if (\strnatcmp($n, $name) > 0) {
265                    continue;
266                }
267                if (\strnatcmp($last, $n) >= 0) {
268                    continue;
269                }
270                $migration->up();
271                $this->insertRow($n);
272                yield $n;
273            }
274            return;
275        }
276        foreach ($this->getMigrationsDesc() as $n => $migration) {
277            if (\strnatcmp($name, $n) > 0) {
278                continue;
279            }
280            if (\strnatcmp($last, $n) < 0) {
281                continue;
282            }
283            $migration->down();
284            $this->deleteRow($n);
285            yield $n;
286        }
287    }
288}