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