Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
104 / 104
98.68% covered (success)
98.68%
75 / 76
3.46% covered (danger)
3.46%
33 / 954
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Migrations
100.00% covered (success)
100.00%
104 / 104
98.68% covered (success)
98.68%
75 / 76
3.46% covered (danger)
3.46%
33 / 954
100.00% covered (success)
100.00%
15 / 15
1706.68
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 run
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
33 / 33
1.19% covered (danger)
1.19%
11 / 926
100.00% covered (success)
100.00%
1 / 1
295.82
 migrate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 confirmTestRunForPending
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 migrationsForNamespace
100.00% covered (success)
100.00%
19 / 19
93.33% covered (success)
93.33%
14 / 15
85.71% covered (warning)
85.71%
6 / 7
100.00% covered (success)
100.00%
1 / 1
7.14
 migrationIdsAreUnique
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
7 / 7
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
4.94
 createMigrationsTable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 supportsTransactions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 driverPolicy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 planner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 phpLoader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 plan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 runner
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Quma\Commands;
6
7use Celemas\Cli\Command;
8use Celemas\Cli\Opts;
9use Celemas\Quma\Connection;
10use Celemas\Quma\Contract;
11use Celemas\Quma\Environment;
12use Celemas\Quma\Migrations\DriverPolicy;
13use Celemas\Quma\Migrations\Executor;
14use Celemas\Quma\Migrations\Log;
15use Celemas\Quma\Migrations\MetadataTable;
16use Celemas\Quma\Migrations\PhpLoader;
17use Celemas\Quma\Migrations\Plan;
18use Celemas\Quma\Migrations\Planner;
19use Celemas\Quma\Migrations\Runner;
20use Celemas\Quma\Migrations\RunOptions;
21use Celemas\Quma\Migrations\TestRunConfirmation;
22use Override;
23
24final class Migrations extends Command
25{
26    protected readonly Environment $env;
27    protected readonly ?Contract\MigrationFactory $migrationFactory;
28    protected string $name = 'migrations';
29    protected string $group = 'Database';
30    protected string $prefix = 'db';
31    protected string $description = 'Apply missing database migrations';
32
33    /** @param array<non-empty-string, Connection>|Connection $conn */
34    public function __construct(
35        array|Connection $conn,
36        array $options = [],
37        ?Contract\MigrationFactory $migrationFactory = null,
38    ) {
39        if (is_array($conn)) {
40            $this->env = new Environment($conn, $options);
41        } else {
42            $this->env = new Environment(['default' => $conn], $options);
43        }
44
45        $this->migrationFactory = $migrationFactory;
46    }
47
48    #[Override]
49    public function run(): string|int
50    {
51        $env = $this->env;
52        $opts = new Opts();
53        $namespace = $opts->get('--namespace', '');
54        $showStacktrace = $opts->has('--stacktrace');
55        $apply = $opts->has('--apply');
56        $testRun = $opts->has('--test-run');
57        $yes = $opts->has('--yes');
58        $driverSupported = $this->driverPolicy()->isKnown();
59
60        if ($apply && $testRun) {
61            echo "\033[1;31mError\033[0m: Options --apply and --test-run cannot be used together.\n";
62
63            return 1;
64        }
65
66        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
67            echo
68                "\033[1;31mError\033[0m: Test runs are only supported for transactional drivers: sqlite and pgsql.\n"
69            ;
70            echo
71                "MySQL migrations are plan-only without --apply because DDL statements can cause implicit commits.\n"
72            ;
73
74            return 1;
75        }
76
77        $migrations = $this->migrationsForNamespace($namespace);
78
79        if ($migrations === false) {
80            return 1;
81        }
82
83        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
84        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
85
86        if (!$apply && !$testRun) {
87            return $this->plan()->show($migrationNamespace, $migrations, $tableExists);
88        }
89
90        if (
91            !$apply && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
92        ) {
93            return 1;
94        }
95
96        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
97            $result = $this->createMigrationsTable();
98
99            if ($result !== 0) {
100                // Requires simulating failing metadata table creation.
101                return $result; // @codeCoverageIgnore
102            }
103
104            $tableExists = true;
105        }
106
107        return $this->migrate(
108            $migrationNamespace,
109            $migrations,
110            $showStacktrace,
111            $apply,
112            $tableExists,
113        );
114    }
115
116    /** @param list<string> $migrations */
117    protected function migrate(
118        string $namespace,
119        array $migrations,
120        bool $showStacktrace,
121        bool $apply,
122        bool $tableExists,
123    ): int {
124        return $this->runner()->run(
125            $namespace,
126            $migrations,
127            new RunOptions(
128                $showStacktrace,
129                $apply,
130                $tableExists,
131                $this->createMigrationsTable(...),
132            ),
133        );
134    }
135
136    /**
137     * @param list<string> $migrations
138     */
139    protected function confirmTestRunForPending(
140        string $namespace,
141        array $migrations,
142        bool $tableExists,
143        bool $yes,
144    ): bool {
145        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
146        $pendingMigrations = $this->planner()->pendingMigrations(
147            $namespace,
148            $migrations,
149            $appliedMigrations,
150        );
151
152        if (count($pendingMigrations) === 0) {
153            return true;
154        }
155
156        return new TestRunConfirmation()->confirm($yes);
157    }
158
159    /**
160     * @return list<string>|false
161     */
162    protected function migrationsForNamespace(string $namespace): array|false
163    {
164        $migrationNamespaces = $this->env->getMigrations();
165
166        if ($migrationNamespaces === false) {
167            return false;
168        }
169
170        if ($namespace) {
171            if (!array_key_exists($namespace, $migrationNamespaces)) {
172                $this->error("Migration namespace '{$namespace}' does not exist");
173
174                return false;
175            }
176
177            $migrations = $migrationNamespaces[$namespace];
178
179            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
180        }
181
182        if (!array_key_exists('default', $migrationNamespaces)) {
183            $this->error("Migration namespace 'default' does not exist");
184            $this->info(
185                'If you have defined namespaced migrations, you must either provide a namespace using the '
186                . "`--namespace` flag when running this command, or define a namespace named 'default' which "
187                . 'will be used when no namespace is provided.',
188            );
189
190            return false;
191        }
192
193        $migrations = $migrationNamespaces['default'];
194
195        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
196    }
197
198    /** @param list<string> $migrations */
199    protected function migrationIdsAreUnique(string $namespace, array $migrations): bool
200    {
201        $duplicates = $this->planner()->duplicateMigrationIds($namespace, $migrations);
202
203        foreach ($duplicates as $id => $paths) {
204            $this->error("Duplicate migration id '{$id}' in namespace '{$namespace}'");
205
206            foreach ($paths as $path) {
207                echo "  - {$path}\n";
208            }
209        }
210
211        return count($duplicates) === 0;
212    }
213
214    protected function createMigrationsTable(): int
215    {
216        $result = new MetadataTable($this->env)->create($this->env->db);
217
218        if ($result === 0) {
219            return 0;
220        }
221
222        // Would require simulating failing metadata table creation.
223        // @codeCoverageIgnoreStart
224        $this->error('Migration table could not be created.');
225
226        return $result;
227
228        // @codeCoverageIgnoreEnd
229    }
230
231    protected function supportsTransactions(): bool
232    {
233        return $this->driverPolicy()->supportsTransactions();
234    }
235
236    private function driverPolicy(): DriverPolicy
237    {
238        return new DriverPolicy($this->env->driver);
239    }
240
241    private function planner(): Planner
242    {
243        return new Planner($this->driverPolicy());
244    }
245
246    private function phpLoader(): PhpLoader
247    {
248        return new PhpLoader($this->env, $this->migrationFactory);
249    }
250
251    private function log(): Log
252    {
253        return new Log($this->env, $this->planner());
254    }
255
256    private function plan(): Plan
257    {
258        return new Plan($this->env, $this->planner(), $this->log());
259    }
260
261    private function executor(): Executor
262    {
263        return new Executor($this->env, $this->log(), $this->phpLoader());
264    }
265
266    private function runner(): Runner
267    {
268        return new Runner(
269            $this->env,
270            $this->driverPolicy(),
271            $this->planner(),
272            $this->log(),
273            $this->executor(),
274        );
275    }
276}