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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Migrations->__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    }
Migrations->__invoke
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) {
64        if ($apply && $testRun) {
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;
72        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
72        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
72        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
72        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
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;
83        $migrations = $this->migrationsForNamespace($namespace);
84
85        if ($migrations === false) {
86            return 1;
89        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
89        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
89        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
89        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
90        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
90        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
90        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
91
92        if (!$apply && !$testRun) {
92        if (!$apply && !$testRun) {
92        if (!$apply && !$testRun) {
93            return $this->plan()->show($migrationNamespace, $migrations, $tableExists);
97            !$apply
98                && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
98                && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
100            return 1;
103        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
103        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
103        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
103        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
103        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
104            $result = $this->createMigrationsTable();
105
106            if ($result !== 0) {
111            $tableExists = true;
112        }
113
114        return $this->migrate(
114        return $this->migrate(
115            $migrationNamespace,
116            $migrations,
117            $showStacktrace,
118            $apply,
119            $tableExists,
120        );
121    }
Migrations->confirmTestRunForPending
147        string $namespace,
148        array $migrations,
149        bool $tableExists,
150        bool $yes,
151    ): bool {
152        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
152        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
152        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
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;
163        return new TestRunConfirmation()->confirm($this->io, $yes);
164    }
Migrations->createMigrationsTable
225        $result = new MetadataTable($this->env, $this->io)->create($this->env->db);
226
227        if ($result === 0) {
228            return 0;
Migrations->driverPolicy
247        return new DriverPolicy($this->env->driver);
248    }
Migrations->executor
272        return new Executor($this->env, $this->log(), $this->phpLoader(), $this->io);
273    }
Migrations->log
262        return new Log($this->env, $this->planner());
263    }
Migrations->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    }
Migrations->migrationIdsAreUnique
208    private function migrationIdsAreUnique(string $namespace, array $migrations): bool
209    {
210        $duplicates = $this->planner()->duplicateMigrationIds($namespace, $migrations);
211
212        foreach ($duplicates as $id => $paths) {
212        foreach ($duplicates as $id => $paths) {
212        foreach ($duplicates as $id => $paths) {
213            $this->io->error("Duplicate migration id '{$id}' in namespace '{$namespace}'");
214
215            foreach ($paths as $path) {
215            foreach ($paths as $path) {
215            foreach ($paths as $path) {
216                $this->io->echolnErr("  - {$path}");
212        foreach ($duplicates as $id => $paths) {
213            $this->io->error("Duplicate migration id '{$id}' in namespace '{$namespace}'");
214
215            foreach ($paths as $path) {
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    }
Migrations->migrationsForNamespace
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;
179        if ($namespace) {
180            if (!array_key_exists($namespace, $migrationNamespaces)) {
181                $this->io->error("Migration namespace '{$namespace}' does not exist");
182
183                return false;
186            $migrations = $migrationNamespaces[$namespace];
187
188            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
188            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
188            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
188            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
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;
202        $migrations = $migrationNamespaces['default'];
203
204        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
204        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
204        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
204        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
205    }
Migrations->phpLoader
257        return new PhpLoader($this->env, $this->migrationFactory);
258    }
Migrations->plan
267        return new Plan($this->env, $this->planner(), $this->log(), $this->io);
268    }
Migrations->planner
252        return new Planner($this->driverPolicy());
253    }
Migrations->runner
277        return new Runner(
278            $this->env,
279            $this->driverPolicy(),
280            $this->planner(),
281            $this->log(),
282            $this->executor(),
283            $this->io,
284        );
285    }
Migrations->supportsTransactions
242        return $this->driverPolicy()->supportsTransactions();
243    }
createMigrationsTable
225        $result = new MetadataTable($this->env, $this->io)->create($this->env->db);
226
227        if ($result === 0) {
228            return 0;