Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
100 / 100
100.00% covered (success)
100.00%
74 / 74
82.14% covered (warning)
82.14%
23 / 28
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
Migrations
100.00% covered (success)
100.00%
100 / 100
100.00% covered (success)
100.00%
74 / 74
82.14% covered (warning)
82.14%
23 / 28
100.00% covered (success)
100.00%
15 / 15
52.04
100.00% covered (success)
100.00%
1 / 1
 __construct
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
 __invoke
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
33 / 33
n/a
0 / 0
100.00% covered (success)
100.00%
1 / 1
17
 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
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 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%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
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\Command;
8use Celema\Console\Exception\InvalidUsage;
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')]
26final class Migrations
27{
28    /** @psalm-suppress PropertyNotSetInConstructor Assigned first thing in __invoke() */
29    private Environment $env;
30
31    /** @psalm-suppress PropertyNotSetInConstructor Assigned first thing in __invoke() */
32    private Io $io;
33
34    /**
35     * @param array<non-empty-string, Connection>|Connection $connections
36     * @param array<array-key, mixed> $options
37     */
38    public function __construct(
39        private readonly array|Connection $connections,
40        private readonly array $options = [],
41        private readonly ?Contract\MigrationFactory $migrationFactory = null,
42    ) {}
43
44    // The parameters are the command line's options.
45    // @mago-expect lint:excessive-parameter-list
46    public function __invoke(
47        Io $io,
48        #[Opt('Apply the pending migrations instead of only planning them')]
49        bool $apply = false,
50        #[Opt('Run pending migrations inside a transaction and roll back (sqlite/pgsql only)')]
51        bool $testRun = false,
52        #[Opt('Migration namespace to run', value: 'name')]
53        string $namespace = '',
54        #[Opt('Connection to use', value: 'name')]
55        string $conn = 'default',
56        #[Opt('Show stack traces for failing migrations')]
57        bool $stacktrace = false,
58        #[Opt('Skip the test-run confirmation prompt')]
59        bool $yes = false,
60    ): int {
61        if ($apply && $testRun) {
62            throw new InvalidUsage('Options --apply and --test-run cannot be used together');
63        }
64
65        $this->io = $io;
66        $this->env = new Environment($this->connections, $this->options, $conn, $stacktrace);
67        $env = $this->env;
68        $driverSupported = $this->driverPolicy()->isKnown();
69
70        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
71            $io->error('Error: Test runs are only supported for transactional drivers: sqlite and pgsql.');
72            $io->stderr()->line(
73                'MySQL migrations are plan-only without --apply because DDL statements can cause implicit commits.',
74            );
75
76            return 1;
77        }
78
79        $migrations = $this->migrationsForNamespace($namespace);
80
81        if ($migrations === false) {
82            return 1;
83        }
84
85        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
86        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
87
88        if (!$apply && !$testRun) {
89            return $this->plan()->show($migrationNamespace, $migrations, $tableExists);
90        }
91
92        if (
93            !$apply
94            && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
95        ) {
96            return 1;
97        }
98
99        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
100            $result = $this->createMigrationsTable();
101
102            if ($result !== 0) {
103                return $result;
104            }
105
106            $tableExists = true;
107        }
108
109        return $this->migrate(
110            $migrationNamespace,
111            $migrations,
112            $stacktrace,
113            $apply,
114            $tableExists,
115        );
116    }
117
118    /** @param list<string> $migrations */
119    private function migrate(
120        string $namespace,
121        array $migrations,
122        bool $showStacktrace,
123        bool $apply,
124        bool $tableExists,
125    ): int {
126        return $this->runner()->run(
127            $namespace,
128            $migrations,
129            new RunOptions(
130                $showStacktrace,
131                $apply,
132                $tableExists,
133                $this->createMigrationsTable(...),
134            ),
135        );
136    }
137
138    /**
139     * @param list<string> $migrations
140     */
141    private function confirmTestRunForPending(
142        string $namespace,
143        array $migrations,
144        bool $tableExists,
145        bool $yes,
146    ): bool {
147        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
148        $pendingMigrations = $this->planner()->pendingMigrations(
149            $namespace,
150            $migrations,
151            $appliedMigrations,
152        );
153
154        if (count($pendingMigrations) === 0) {
155            return true;
156        }
157
158        return new TestRunConfirmation()->confirm($this->io, $yes);
159    }
160
161    /**
162     * @return list<string>|false
163     */
164    private function migrationsForNamespace(string $namespace): array|false
165    {
166        $migrationNamespaces = $this->env->getMigrations();
167
168        if ($migrationNamespaces === false) {
169            $this->io->error('No migration directories defined in configuration');
170
171            return false;
172        }
173
174        if ($namespace) {
175            if (!array_key_exists($namespace, $migrationNamespaces)) {
176                $this->io->error("Migration namespace '%s' does not exist", $namespace);
177
178                return false;
179            }
180
181            $migrations = $migrationNamespaces[$namespace];
182
183            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
184        }
185
186        if (!array_key_exists('default', $migrationNamespaces)) {
187            $this->io->error("Migration namespace 'default' does not exist");
188            $this->io->line(
189                'If you have defined namespaced migrations, you must either provide a namespace using the '
190                    . "`--namespace` flag when running this command, or define a namespace named 'default' which "
191                    . 'will be used when no namespace is provided.',
192            );
193
194            return false;
195        }
196
197        $migrations = $migrationNamespaces['default'];
198
199        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
200    }
201
202    /** @param list<string> $migrations */
203    private function migrationIdsAreUnique(string $namespace, array $migrations): bool
204    {
205        $duplicates = $this->planner()->duplicateMigrationIds($namespace, $migrations);
206
207        foreach ($duplicates as $id => $paths) {
208            $this->io->error("Duplicate migration id '%s' in namespace '%s'", $id, $namespace);
209
210            foreach ($paths as $path) {
211                $this->io->stderr()->line('  - %s', $path);
212            }
213        }
214
215        return count($duplicates) === 0;
216    }
217
218    private function createMigrationsTable(): int
219    {
220        $result = new MetadataTable($this->env, $this->io)->create($this->env->db);
221
222        if ($result === 0) {
223            return 0;
224        }
225
226        $this->io->error('Migration table could not be created.');
227
228        return $result;
229    }
230
231    private 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(), $this->io);
259    }
260
261    private function executor(): Executor
262    {
263        return new Executor($this->env, $this->log(), $this->phpLoader(), $this->io);
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            $this->io,
275        );
276    }
277}

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
39        private readonly array|Connection $connections,
40        private readonly array $options = [],
41        private readonly ?Contract\MigrationFactory $migrationFactory = null,
42    ) {}
Migrations->__invoke
61        if ($apply && $testRun) {
61        if ($apply && $testRun) {
62            throw new InvalidUsage('Options --apply and --test-run cannot be used together');
65        $this->io = $io;
66        $this->env = new Environment($this->connections, $this->options, $conn, $stacktrace);
67        $env = $this->env;
68        $driverSupported = $this->driverPolicy()->isKnown();
69
70        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
70        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
70        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
70        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
70        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
71            $io->error('Error: Test runs are only supported for transactional drivers: sqlite and pgsql.');
72            $io->stderr()->line(
73                'MySQL migrations are plan-only without --apply because DDL statements can cause implicit commits.',
74            );
75
76            return 1;
79        $migrations = $this->migrationsForNamespace($namespace);
80
81        if ($migrations === false) {
82            return 1;
85        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
85        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
85        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
85        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
86        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
86        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
86        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
87
88        if (!$apply && !$testRun) {
88        if (!$apply && !$testRun) {
88        if (!$apply && !$testRun) {
89            return $this->plan()->show($migrationNamespace, $migrations, $tableExists);
93            !$apply
94            && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
94            && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
96            return 1;
99        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
99        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
99        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
99        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
99        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
100            $result = $this->createMigrationsTable();
101
102            if ($result !== 0) {
103                return $result;
106            $tableExists = true;
107        }
108
109        return $this->migrate(
109        return $this->migrate(
110            $migrationNamespace,
111            $migrations,
112            $stacktrace,
113            $apply,
114            $tableExists,
115        );
116    }
Migrations->confirmTestRunForPending
142        string $namespace,
143        array $migrations,
144        bool $tableExists,
145        bool $yes,
146    ): bool {
147        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
147        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
147        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
147        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
148        $pendingMigrations = $this->planner()->pendingMigrations(
149            $namespace,
150            $migrations,
151            $appliedMigrations,
152        );
153
154        if (count($pendingMigrations) === 0) {
155            return true;
158        return new TestRunConfirmation()->confirm($this->io, $yes);
159    }
Migrations->createMigrationsTable
220        $result = new MetadataTable($this->env, $this->io)->create($this->env->db);
221
222        if ($result === 0) {
223            return 0;
226        $this->io->error('Migration table could not be created.');
227
228        return $result;
229    }
Migrations->driverPolicy
238        return new DriverPolicy($this->env->driver);
239    }
Migrations->executor
263        return new Executor($this->env, $this->log(), $this->phpLoader(), $this->io);
264    }
Migrations->log
253        return new Log($this->env, $this->planner());
254    }
Migrations->migrate
120        string $namespace,
121        array $migrations,
122        bool $showStacktrace,
123        bool $apply,
124        bool $tableExists,
125    ): int {
126        return $this->runner()->run(
127            $namespace,
128            $migrations,
129            new RunOptions(
130                $showStacktrace,
131                $apply,
132                $tableExists,
133                $this->createMigrationsTable(...),
134            ),
135        );
136    }
Migrations->migrationIdsAreUnique
203    private function migrationIdsAreUnique(string $namespace, array $migrations): bool
204    {
205        $duplicates = $this->planner()->duplicateMigrationIds($namespace, $migrations);
206
207        foreach ($duplicates as $id => $paths) {
207        foreach ($duplicates as $id => $paths) {
207        foreach ($duplicates as $id => $paths) {
208            $this->io->error("Duplicate migration id '%s' in namespace '%s'", $id, $namespace);
209
210            foreach ($paths as $path) {
210            foreach ($paths as $path) {
210            foreach ($paths as $path) {
211                $this->io->stderr()->line('  - %s', $path);
207        foreach ($duplicates as $id => $paths) {
208            $this->io->error("Duplicate migration id '%s' in namespace '%s'", $id, $namespace);
209
210            foreach ($paths as $path) {
207        foreach ($duplicates as $id => $paths) {
208            $this->io->error("Duplicate migration id '%s' in namespace '%s'", $id, $namespace);
209
210            foreach ($paths as $path) {
211                $this->io->stderr()->line('  - %s', $path);
212            }
213        }
214
215        return count($duplicates) === 0;
216    }
Migrations->migrationsForNamespace
164    private function migrationsForNamespace(string $namespace): array|false
165    {
166        $migrationNamespaces = $this->env->getMigrations();
167
168        if ($migrationNamespaces === false) {
169            $this->io->error('No migration directories defined in configuration');
170
171            return false;
174        if ($namespace) {
175            if (!array_key_exists($namespace, $migrationNamespaces)) {
176                $this->io->error("Migration namespace '%s' does not exist", $namespace);
177
178                return false;
181            $migrations = $migrationNamespaces[$namespace];
182
183            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
183            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
183            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
183            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
186        if (!array_key_exists('default', $migrationNamespaces)) {
187            $this->io->error("Migration namespace 'default' does not exist");
188            $this->io->line(
189                'If you have defined namespaced migrations, you must either provide a namespace using the '
190                    . "`--namespace` flag when running this command, or define a namespace named 'default' which "
191                    . 'will be used when no namespace is provided.',
192            );
193
194            return false;
197        $migrations = $migrationNamespaces['default'];
198
199        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
199        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
199        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
199        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
200    }
Migrations->phpLoader
248        return new PhpLoader($this->env, $this->migrationFactory);
249    }
Migrations->plan
258        return new Plan($this->env, $this->planner(), $this->log(), $this->io);
259    }
Migrations->planner
243        return new Planner($this->driverPolicy());
244    }
Migrations->runner
268        return new Runner(
269            $this->env,
270            $this->driverPolicy(),
271            $this->planner(),
272            $this->log(),
273            $this->executor(),
274            $this->io,
275        );
276    }
Migrations->supportsTransactions
233        return $this->driverPolicy()->supportsTransactions();
234    }
createMigrationsTable
220        $result = new MetadataTable($this->env, $this->io)->create($this->env->db);
221
222        if ($result === 0) {
223            return 0;
226        $this->io->error('Migration table could not be created.');
227
228        return $result;
229    }