Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
75 / 75
91.84% covered (success)
91.84%
45 / 49
52.50% covered (warning)
52.50%
21 / 40
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Add
100.00% covered (success)
100.00%
75 / 75
91.84% covered (success)
91.84%
45 / 49
52.50% covered (warning)
52.50%
21 / 40
100.00% covered (success)
100.00%
7 / 7
79.69
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
95.00% covered (success)
95.00%
19 / 20
60.00% covered (warning)
60.00%
9 / 15
100.00% covered (success)
100.00%
1 / 1
14.18
 fileName
100.00% covered (success)
100.00%
14 / 14
90.91% covered (success)
90.91%
10 / 11
38.46% covered (danger)
38.46%
5 / 13
100.00% covered (success)
100.00%
1 / 1
10.83
 getPhpContent
100.00% covered (success)
100.00%
6 / 6
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 getPhpMigrationName
100.00% covered (success)
100.00%
13 / 13
87.50% covered (warning)
87.50%
7 / 8
33.33% covered (danger)
33.33%
2 / 6
100.00% covered (success)
100.00%
1 / 1
5.67
 getTpqlContent
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
 getFirstMigrationDir
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma\Commands;
6
7use Celema\Console\Arg;
8use Celema\Console\Args;
9use Celema\Console\Command;
10use Celema\Console\Io;
11use Celema\Console\Opt;
12use Celema\Quma\Connection;
13use Celema\Quma\Environment;
14
15#[Command('db:add-migration', 'Initialize a new migration', group: 'Database')]
16#[Arg(
17    'name',
18    'Name of the migration script; prompted for interactively when omitted',
19    optional: true,
20)]
21#[Opt('--conn', 'Connection to use', value: 'name')]
22final class Add
23{
24    private readonly Environment $env;
25
26    /** @param array<non-empty-string, Connection>|Connection $conn */
27    public function __construct(array|Connection $conn, array $options = [])
28    {
29        $this->env = new Environment($conn, $options);
30    }
31
32    public function __invoke(Args $args, Io $io): int
33    {
34        $env = $this->env;
35        $fileName = $this->fileName($args, $io);
36
37        if ($fileName === null) {
38            return 1;
39        }
40
41        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
42        $migrations = $env->conn->config->migrations;
43
44        if (count($migrations) === 0) {
45            $io->echoln('No migration directories configured. Aborting.');
46
47            return 1;
48        }
49
50        // Get the first migrations directory from the config
51        // Handles both flat list and namespaced formats
52        $migrationsDir = $this->getFirstMigrationDir($migrations);
53
54        if ($migrationsDir === null) {
55            $io->echoln('No valid migration directory found. Aborting.');
56
57            return 1;
58        }
59
60        if (str_contains($migrationsDir, '/vendor')) {
61            $io->echoln(
62                "The migrations directory is inside './vendor'.\n  -> {$migrationsDir}\nAborting.",
63            );
64
65            return 1;
66        }
67
68        if (!is_writable($migrationsDir)) {
69            $io->echoln("Migrations directory is not writable\n  -> {$migrationsDir}\nAborting. ");
70
71            return 1;
72        }
73
74        $timestamp = date('ymd-His', time());
75
76        $migration = $migrationsDir . DIRECTORY_SEPARATOR . $timestamp . '-' . $fileName;
77        $f = fopen($migration, 'w');
78
79        if ($f === false) {
80            $io->echoln("Could not create migration file: {$migration}\nAborting.");
81
82            return 1;
83        }
84
85        if ($ext === 'php') {
86            fwrite($f, $this->getPhpContent($fileName, $timestamp));
87        } elseif ($ext === 'tpql') {
88            fwrite($f, $this->getTpqlContent());
89        }
90
91        fclose($f);
92        $io->echoln("Migration created:\n{$migration}");
93
94        return 0;
95    }
96
97    /**
98     * Resolves the migration file name from the argument or a prompt.
99     *
100     * Returns null when no name was provided or the extension is invalid.
101     */
102    private function fileName(Args $args, Io $io): ?string
103    {
104        $fileName = (string) $args->positional(0, '');
105
106        if ($fileName === '') {
107            $fileName = $io->ask('Name of the migration script:');
108
109            if ($fileName === '') {
110                $io->echoln('No input provided. Aborting.');
111
112                return null;
113            }
114        }
115
116        $fileName = strtolower(str_replace([' ', '_'], '-', $fileName));
117        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
118
119        if (!$ext) {
120            return $fileName . '.sql';
121        }
122
123        if (!in_array($ext, ['sql', 'php', 'tpql'], strict: true)) {
124            $io->echoln("Wrong file extension '{$ext}'. Use 'sql', 'php' or 'tpql' instead.\nAborting.");
125
126            return null;
127        }
128
129        return $fileName;
130    }
131
132    protected function getPhpContent(string $fileName, string $timestamp): string
133    {
134        $name = $this->getPhpMigrationName($fileName);
135        $namespace = 'Quma\\Migrations\\M' . str_replace('-', '_', $timestamp) . '_' . $name;
136
137        return "<?php
138
139declare(strict_types=1);
140
141namespace {$namespace};
142
143use Celema\\Quma\\Contract;
144use Celema\\Quma\\Environment;
145
146class Migration implements Contract\\Migration
147{
148    public function run(Environment \$env): void
149    {
150        throw new \\LogicException('Implement migration {$name} before running it.');
151    }
152}
153
154return Migration::class;";
155    }
156
157    protected function getPhpMigrationName(string $fileName): string
158    {
159        $parts = preg_split(
160            '/[^a-zA-Z0-9]+/',
161            pathinfo($fileName, PATHINFO_FILENAME),
162            -1,
163            PREG_SPLIT_NO_EMPTY,
164        );
165
166        if ($parts === false || count($parts) === 0) {
167            return 'Migration';
168        }
169
170        $words = array_map(
171            static fn(string $part): string => ucfirst(strtolower($part)),
172            $parts,
173        );
174
175        return implode('', $words);
176    }
177
178    protected function getTpqlContent(): string
179    {
180        return "<?php if (\$driver === 'pgsql') : ?>
181
182<?php else : ?>
183
184<?php endif ?>
185";
186    }
187
188    /**
189     * Gets the first migration directory from the config.
190     *
191     * Handles both flat list and namespaced formats.
192     *
193     * @param array<int|string, string|list<string>> $migrations
194     */
195    protected function getFirstMigrationDir(array $migrations): ?string
196    {
197        $first = reset($migrations);
198
199        if ($first === false) {
200            return null; // @codeCoverageIgnore
201        }
202
203        // If it's a string, return it directly
204        if (is_string($first)) {
205            return $first;
206        }
207
208        // It's a list, return the first element
209        return $first[0] ?? null;
210    }
211}