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}

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
35        array|Connection $conn,
36        array $options = [],
37        ?Contract\MigrationFactory $migrationFactory = null,
38    ) {
39        if (is_array($conn)) {
39        if (is_array($conn)) {
40            $this->env = new Environment($conn, $options);
42            $this->env = new Environment(['default' => $conn], $options);
43        }
44
45        $this->migrationFactory = $migrationFactory;
45        $this->migrationFactory = $migrationFactory;
46    }
Migrations->confirmTestRunForPending
140        string $namespace,
141        array $migrations,
142        bool $tableExists,
143        bool $yes,
144    ): bool {
145        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
145        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
145        $appliedMigrations = $tableExists ? $this->log()->applied($this->env->db) : [];
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;
156        return new TestRunConfirmation()->confirm($yes);
157    }
Migrations->createMigrationsTable
216        $result = new MetadataTable($this->env)->create($this->env->db);
217
218        if ($result === 0) {
219            return 0;
Migrations->driverPolicy
238        return new DriverPolicy($this->env->driver);
239    }
Migrations->executor
263        return new Executor($this->env, $this->log(), $this->phpLoader());
264    }
Migrations->log
253        return new Log($this->env, $this->planner());
254    }
Migrations->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    }
Migrations->migrationIdsAreUnique
199    protected function migrationIdsAreUnique(string $namespace, array $migrations): bool
200    {
201        $duplicates = $this->planner()->duplicateMigrationIds($namespace, $migrations);
202
203        foreach ($duplicates as $id => $paths) {
203        foreach ($duplicates as $id => $paths) {
203        foreach ($duplicates as $id => $paths) {
204            $this->error("Duplicate migration id '{$id}' in namespace '{$namespace}'");
205
206            foreach ($paths as $path) {
206            foreach ($paths as $path) {
206            foreach ($paths as $path) {
207                echo "  - {$path}\n";
203        foreach ($duplicates as $id => $paths) {
204            $this->error("Duplicate migration id '{$id}' in namespace '{$namespace}'");
205
206            foreach ($paths as $path) {
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    }
Migrations->migrationsForNamespace
162    protected function migrationsForNamespace(string $namespace): array|false
163    {
164        $migrationNamespaces = $this->env->getMigrations();
165
166        if ($migrationNamespaces === false) {
167            return false;
170        if ($namespace) {
171            if (!array_key_exists($namespace, $migrationNamespaces)) {
172                $this->error("Migration namespace '{$namespace}' does not exist");
173
174                return false;
177            $migrations = $migrationNamespaces[$namespace];
178
179            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
179            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
179            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
179            return $this->migrationIdsAreUnique($namespace, $migrations) ? $migrations : false;
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;
193        $migrations = $migrationNamespaces['default'];
194
195        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
195        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
195        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
195        return $this->migrationIdsAreUnique('default', $migrations) ? $migrations : false;
196    }
Migrations->phpLoader
248        return new PhpLoader($this->env, $this->migrationFactory);
249    }
Migrations->plan
258        return new Plan($this->env, $this->planner(), $this->log());
259    }
Migrations->planner
243        return new Planner($this->driverPolicy());
244    }
Migrations->run
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) {
60        if ($apply && $testRun) {
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;
66        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
66        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
66        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
66        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
66        if ($testRun && (!$driverSupported || !$this->supportsTransactions())) {
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;
77        $migrations = $this->migrationsForNamespace($namespace);
78
79        if ($migrations === false) {
80            return 1;
83        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
83        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
83        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
83        $migrationNamespace = $namespace !== '' ? $namespace : 'default';
84        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
84        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
84        $tableExists = $driverSupported && $env->checkIfMigrationsTableExists($env->db);
85
86        if (!$apply && !$testRun) {
86        if (!$apply && !$testRun) {
86        if (!$apply && !$testRun) {
87            return $this->plan()->show($migrationNamespace, $migrations, $tableExists);
91            !$apply && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
91            !$apply && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
91            !$apply && !$this->confirmTestRunForPending($migrationNamespace, $migrations, $tableExists, $yes)
93            return 1;
96        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
96        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
96        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
96        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
96        if ($driverSupported && !$tableExists && !$this->supportsTransactions()) {
97            $result = $this->createMigrationsTable();
98
99            if ($result !== 0) {
104            $tableExists = true;
105        }
106
107        return $this->migrate(
107        return $this->migrate(
108            $migrationNamespace,
109            $migrations,
110            $showStacktrace,
111            $apply,
112            $tableExists,
113        );
114    }
Migrations->runner
268        return new Runner(
269            $this->env,
270            $this->driverPolicy(),
271            $this->planner(),
272            $this->log(),
273            $this->executor(),
274        );
275    }
Migrations->supportsTransactions
233        return $this->driverPolicy()->supportsTransactions();
234    }
createMigrationsTable
216        $result = new MetadataTable($this->env)->create($this->env->db);
217
218        if ($result === 0) {
219            return 0;