Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
107 / 107
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%
107 / 107
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%
42 / 42
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    private readonly Environment $env;
38    private readonly ?Contract\MigrationFactory $migrationFactory;
39
40    /** @psalm-suppress PropertyNotSetInConstructor Assigned first thing in __invoke() */
41    private 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
98                && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
99        ) {
100            return 1;
101        }
102
103        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
104            $result = $this->createMigrationsTable();
105
106            if ($result !== 0) {
107                // Requires simulating failing metadata table creation.
108                return $result; // @codeCoverageIgnore
109            }
110
111            $tableExists = true;
112        }
113
114        return $this->migrate(
115            $migrationNamespace,
116            $migrations,
117            $showStacktrace,
118            $apply,
119            $tableExists,
120        );
121    }
122
123    /** @param list<string> $migrations */
124    private function migrate(
125        string $namespace,
126        array $migrations,
127        bool $showStacktrace,
128        bool $apply,
129        bool $tableExists,
130    ): int {
131        return $this->runner()->run(
132            $namespace,
133            $migrations,
134            new RunOptions(
135                $showStacktrace,
136                $apply,
137                $tableExists,
138                $this->createMigrationsTable(...),
139            ),
140        );
141    }
142
143    /**
144     * @param list<string> $migrations
145     */
146    private function confirmTestRunForPending(
147        string $namespace,
148        array $migrations,
149        bool $tableExists,
150        bool $yes,
151    ): bool {
152        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
153        $pendingMigrations = $this->planner()->pendingMigrations(
154            $namespace,
155            $migrations,
156            $appliedMigrations,
157        );
158
159        if (count($pendingMigrations) === 0) {
160            return true;
161        }
162
163        return new TestRunConfirmation()->confirm($this->io, $yes);
164    }
165
166    /**
167     * @return list<string>|false
168     */
169    private function migrationsForNamespace(string $namespace): array|false
170    {
171        $migrationNamespaces = $this->env->getMigrations();
172
173        if ($migrationNamespaces === false) {
174            $this->io->error('No migration directories defined in configuration');
175
176            return false;
177        }
178
179        if ($namespace) {
180            if (!array_key_exists($namespace, $migrationNamespaces)) {
181                $this->io->error("Migration namespace '{$namespace}' does not exist");
182
183                return false;
184            }
185
186            $migrations = $migrationNamespaces[$namespace];
187
188            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
189        }
190
191        if (!array_key_exists('default', $migrationNamespaces)) {
192            $this->io->error("Migration namespace 'default' does not exist");
193            $this->io->info(
194                'If you have defined namespaced migrations, you must either provide a namespace using the '
195                    . "`--namespace` flag when running this command, or define a namespace named 'default' which "
196                    . 'will be used when no namespace is provided.',
197            );
198
199            return false;
200        }
201
202        $migrations = $migrationNamespaces['default'];
203
204        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
205    }
206
207    /** @param list<string> $migrations */
208    private function migrationIdsAreUnique(string $namespace, array $migrations): bool
209    {
210        $duplicates = $this->planner()->duplicateMigrationIds($namespace, $migrations);
211
212        foreach ($duplicates as $id => $paths) {
213            $this->io->error("Duplicate migration id '{$id}' in namespace '{$namespace}'");
214
215            foreach ($paths as $path) {
216                $this->io->echolnErr("  - {$path}");
217            }
218        }
219
220        return count($duplicates) === 0;
221    }
222
223    private function createMigrationsTable(): int
224    {
225        $result = new MetadataTable($this->env, $this->io)->create($this->env->db);
226
227        if ($result === 0) {
228            return 0;
229        }
230
231        // Would require simulating failing metadata table creation.
232        // @codeCoverageIgnoreStart
233        $this->io->error('Migration table could not be created.');
234
235        return $result;
236
237        // @codeCoverageIgnoreEnd
238    }
239
240    private function supportsTransactions(): bool
241    {
242        return $this->driverPolicy()->supportsTransactions();
243    }
244
245    private function driverPolicy(): DriverPolicy
246    {
247        return new DriverPolicy($this->env->driver);
248    }
249
250    private function planner(): Planner
251    {
252        return new Planner($this->driverPolicy());
253    }
254
255    private function phpLoader(): PhpLoader
256    {
257        return new PhpLoader($this->env, $this->migrationFactory);
258    }
259
260    private function log(): Log
261    {
262        return new Log($this->env, $this->planner());
263    }
264
265    private function plan(): Plan
266    {
267        return new Plan($this->env, $this->planner(), $this->log(), $this->io);
268    }
269
270    private function executor(): Executor
271    {
272        return new Executor($this->env, $this->log(), $this->phpLoader(), $this->io);
273    }
274
275    private function runner(): Runner
276    {
277        return new Runner(
278            $this->env,
279            $this->driverPolicy(),
280            $this->planner(),
281            $this->log(),
282            $this->executor(),
283            $this->io,
284        );
285    }
286}