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