Passed
Push — main ( 5326f7...3bbf58 )
by Thomas
12:47
created

Migrations::getAppliedMigrations()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 7
ccs 5
cts 5
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Conia\Quma\Commands;
6
7
use Conia\Cli\Opts;
8
use Conia\Quma\Connection;
9
use Conia\Quma\Database;
10
use PDOException;
11
use RuntimeException;
12
use Throwable;
13
14
class Migrations extends Command
15
{
16
    protected const STARTED = 'start';
17
    protected const ERROR = 'error';
18
    protected const WARNING = 'warning';
19
    protected const SUCCESS = 'success';
20
21
    protected string $name = 'migrations';
22
    protected string $group = 'Migrations';
23
    protected string $description = 'Apply missing database migrations';
24
25 23
    public function run(): string|int
26
    {
27 23
        $env = $this->env;
28 23
        $opts = new Opts();
29
30 23
        if (!$env->convenience || $env->checkIfMigrationsTableExists($env->db)) {
31 22
            return $this->migrate($env->db, $env->conn, $opts->has('--stacktrace'), $opts->has('--apply'));
32
        }
33 1
        $ddl = $env->getMigrationsTableDDL();
34
35 1
        if ($ddl) {
36 1
            echo "Migrations table does not exist. For '{$env->driver}' it should look like:\n\n";
37 1
            echo $ddl;
38 1
            echo "\n\nIf you want to create the table above, simply run\n\n";
39 1
            echo "    php run create-migrations-table\n\n";
40 1
            echo "If you need to change the table or column names set them via \n\n";
41 1
            echo "    \$\\Conia\\Quma\\Connection::setMigrationsTable(...)\n";
42 1
            echo "    \$\\Conia\\Quma\\Connection::setMigrationsColumnMigration(...)\n";
43 1
            echo "    \$\\Conia\\Quma\\Connection::setMigrationsColumnApplied(...)\n";
44
        } else {
45
            // An unsupported driver would have to be installed
46
            // to be able to test meaningfully
47
            // @codeCoverageIgnoreStart
48
            echo "Driver '{$env->driver}' is not supported.\n";
49
            // @codeCoverageIgnoreEnd
50
        }
51
52 1
        return 1;
53
    }
54
55 22
    protected function migrate(
56
        Database $db,
57
        Connection $conn,
58
        bool $showStacktrace,
59
        bool $apply
60
    ): int {
61 22
        $this->begin($db);
62 22
        $appliedMigrations = $this->getAppliedMigrations($db);
63 22
        $result = self::STARTED;
64 22
        $numApplied = 0;
65
66 22
        $migrations = $this->env->getMigrations();
67
68 22
        if ($migrations === false) {
0 ignored issues
show
introduced by
The condition $migrations === false is always true.
Loading history...
69 1
            return 1;
70
        }
71
72 21
        foreach ($migrations as $migration) {
73
            assert(!empty($migration) && is_string($migration));
74
75 21
            if (in_array(basename($migration), $appliedMigrations)) {
76 16
                continue;
77
            }
78
79 21
            if (!$this->supportedByDriver($migration)) {
80 21
                continue;
81
            }
82
83 21
            $script = file_get_contents($migration);
84
85 21
            if (empty(trim($script))) {
86 21
                $this->showEmptyMessage($migration);
87 21
                $result = self::WARNING;
88
89 21
                continue;
90
            }
91
92 21
            $result = match (pathinfo($migration, PATHINFO_EXTENSION)) {
93 21
                'sql' => $this->migrateSQL($db, $migration, $script, $showStacktrace),
94 21
                'tpql' => $this->migrateTPQL($db, $conn, $migration, $showStacktrace),
95 21
                'php' => $this->migratePHP($db, $migration, $showStacktrace),
96 21
            };
97
98 21
            if ($result === self::ERROR) {
99 12
                break;
100
            }
101
102 21
            if ($result === self::SUCCESS) {
103 6
                $numApplied++;
104
            }
105
        }
106
107 21
        return $this->finish($db, $result, $apply, $numApplied);
108
    }
109
110 22
    protected function begin(Database $db): void
111
    {
112 22
        if ($this->supportsTransactions()) {
113 16
            $db->begin();
114
        }
115
    }
116
117 21
    protected function finish(
118
        Database $db,
119
        string $result,
120
        bool $apply,
121
        int $numApplied,
122
    ): int {
123 21
        $plural = $numApplied > 1 ? 's' : '';
124
125 21
        if ($this->supportsTransactions()) {
126 15
            if ($result === self::ERROR) {
127 8
                $db->rollback();
128 8
                echo "\nDue to errors no migrations applied\n";
129
130 8
                return 1;
131
            }
132
133 7
            if ($numApplied === 0) {
134 2
                $db->rollback();
135 2
                echo "\nNo migrations applied\n";
136
137 2
                return 0;
138
            }
139
140 5
            if ($apply) {
141 3
                $db->commit();
142 3
                echo "\n{$numApplied} migration{$plural} successfully applied\n";
143
144 3
                return 0;
145
            }
146 2
            echo "\n\033[1;31mNotice\033[0m: Test run only\033[0m";
147 2
            echo "\nWould apply {$numApplied} migration{$plural}. ";
148 2
            echo "Use the switch --apply to make it happen\n";
149 2
            $db->rollback();
150
151 2
            return 0;
152
        }
153 6
        if ($result === self::ERROR) {
154 4
            echo "\n{$numApplied} migration{$plural} applied until the error occured\n";
155
156 4
            return 1;
157
        }
158
159 2
        if ($numApplied > 0) {
160 1
            echo "\n{$numApplied} migration{$plural} successfully applied\n";
161
162 1
            return 0;
163
        }
164
165 1
        echo "\nNo migrations applied\n";
166
167 1
        return 0;
168
    }
169
170 22
    protected function supportsTransactions(): bool
171
    {
172 22
        switch ($this->env->driver) {
173 22
            case 'sqlite':
174 9
                return true;
175 13
            case 'pgsql':
176 7
                return true;
177 6
            case 'mysql':
178 6
                return false;
179
        }
180
181
        // An unsupported driver would have to be installed
182
        // to be able to test meaningfully
183
        // @codeCoverageIgnoreStart
184
        throw new RuntimeException('Database driver not supported');
185
        // @codeCoverageIgnoreEnd
186
    }
187
188 22
    protected function getAppliedMigrations(Database $db): array
189
    {
190 22
        $table = $this->env->table;
191 22
        $column = $this->env->columnMigration;
192 22
        $migrations = $db->execute("SELECT {$column} FROM {$table};")->all();
193
194 22
        return array_map(fn (array $mig): string => (string)$mig['migration'], $migrations);
195
    }
196
197
    /**
198
     * Returns if the given migration is driver specific.
199
     */
200 21
    protected function supportedByDriver(string $migration): bool
201
    {
202
        // First checks if there are brackets in the filename.
203 21
        if (preg_match('/\[[a-z]{3,8}\]/', $migration)) {
204
            // We have found a driver specific migration.
205
            // Check if it matches the current driver.
206 21
            if (preg_match('/\[' . $this->env->driver . '\]/', $migration)) {
207 5
                return true;
208
            }
209
210 21
            return false;
211
        }
212
213
        // This is no driver specific migration
214 21
        return true;
215
    }
216
217 12
    protected function migrateSQL(
218
        Database $db,
219
        string $migration,
220
        string $script,
221
        bool $showStacktrace
222
    ): string {
223
        try {
224 12
            $db->execute($script)->run();
225 6
            $this->logMigration($db, $migration);
226 6
            $this->showMessage($migration);
227
228 6
            return self::SUCCESS;
229 6
        } catch (PDOException $e) {
230 6
            $this->showMessage($migration, $e, $showStacktrace);
231
232 6
            return self::ERROR;
233
        }
234
    }
235
236 21
    protected function migrateTPQL(
237
        Database $db,
238
        Connection $conn,
239
        string $migration,
240
        bool $showStacktrace
241
    ): string {
242
        try {
243 21
            $load = function (string $migrationPath, array $context = []): void {
244
                // Hide $migrationPath. Could be overwritten if $context['templatePath'] exists.
245 21
                $____migration_path____ = $migrationPath;
246
247 21
                extract($context);
248
249
                /** @psalm-suppress UnresolvableInclude */
250 21
                include $____migration_path____;
251 21
            };
252
253 21
            $error = null;
254 21
            $context = [
255 21
                'driver' => $db->getPdoDriver(),
256 21
                'db' => $db,
257 21
                'conn' => $conn,
258 21
            ];
259
260 21
            ob_start();
261
262
            try {
263 21
                $load($migration, $context);
264 3
            } catch (Throwable $e) {
265 3
                $error = $e;
266
            }
267
268 21
            $script = ob_get_contents();
269 21
            ob_end_clean();
270
271 21
            if ($error !== null) {
272 3
                throw $error;
273
            }
274
275 21
            if (empty(trim($script))) {
276 21
                $this->showEmptyMessage($migration);
277
278 21
                return self::WARNING;
279
            }
280
281 8
            return $this->migrateSQL($db, $migration, $script, $showStacktrace);
282 3
        } catch (Throwable $e) {
283 3
            $this->showMessage($migration, $e, $showStacktrace);
284
285 3
            return self::ERROR;
286
        }
287
    }
288
289
    /** @psalm-suppress UnresolvableInclude, MixedAssignment, MixedMethodCall */
290 8
    protected function migratePHP(
291
        Database $db,
292
        string $migration,
293
        bool $showStacktrace
294
    ): string {
295
        try {
296 8
            $migObj = require $migration;
297 5
            $migObj->run($this->env);
298 5
            $this->logMigration($db, $migration);
299 5
            $this->showMessage($migration);
300
301 5
            return self::SUCCESS;
302 3
        } catch (Throwable $e) {
303 3
            $this->showMessage($migration, $e, $showStacktrace);
304
305 3
            return self::ERROR;
306
        }
307
    }
308
309 6
    protected function logMigration(Database $db, string $migration): void
310
    {
311 6
        $name = basename($migration);
312 6
        $db->execute(
313 6
            'INSERT INTO migrations (migration) VALUES (:migration)',
314 6
            ['migration' => $name]
315 6
        )->run();
316
    }
317
318 21
    protected function showEmptyMessage(string $migration): void
319
    {
320 21
        echo "\033[33mWarning\033[0m: Migration '\033[1;33m" .
321 21
            basename($migration) .
322 21
            "'\033[0m is empty. Skipped\n";
323
    }
324
325 18
    protected function showMessage(
326
        string $migration,
327
        Throwable|null $e = null,
328
        bool $showStacktrace = false
329
    ): void {
330 18
        if ($e) {
331 12
            echo "\033[1;31mError\033[0m: while working on migration '\033[1;33m" .
332 12
                basename($migration) .
333 12
                "\033[0m'\n";
334 12
            echo $e->getMessage() . "\n";
335
336 12
            if ($showStacktrace) {
337 6
                echo $e->getTraceAsString() . "\n";
338
            }
339
340 12
            return;
341
        }
342
343 6
        echo "\033[1;32mSuccess\033[0m: Migration '\033[1;33m" .
344 6
            basename($migration) .
345 6
            "\033[0m' successfully applied\n";
346
    }
347
}
348