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}