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}

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.

Add->__construct
22        private readonly array|Connection $connections,
23        private readonly array $options = [],
24    ) {}
Add->__invoke
37            return 1;
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;
52        $migrationsDir = $this->getFirstMigrationDir($migrations);
53
54        if ($migrationsDir === null) {
55            $io->line('No valid migration directory found. Aborting.');
56
57            return 1;
60        if (str_contains($migrationsDir, '/vendor')) {
60        if (str_contains($migrationsDir, '/vendor')) {
60        if (str_contains($migrationsDir, '/vendor')) {
60        if (str_contains($migrationsDir, '/vendor')) {
61            $io->line("The migrations directory is inside './vendor'.\n  -> %s\nAborting.", $migrationsDir);
62
63            return 1;
66        if (!is_writable($migrationsDir)) {
67            $io->line("Migrations directory is not writable\n  -> %s\nAborting. ", $migrationsDir);
68
69            return 1;
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;
83        if ($ext === 'php') {
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);
89        fclose($f);
90        $io->line("Migration created:\n%s", $migration);
91
92        return 0;
93    }
Add->fileName
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;
112        $fileName = strtolower(str_replace([' ', '_'], '-', $fileName));
112        $fileName = strtolower(str_replace([' ', '_'], '-', $fileName));
112        $fileName = strtolower(str_replace([' ', '_'], '-', $fileName));
112        $fileName = strtolower(str_replace([' ', '_'], '-', $fileName));
113        $ext = pathinfo($fileName, PATHINFO_EXTENSION);
114
115        if (!$ext) {
116            return $fileName . '.sql';
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;
125        return $fileName;
126    }
Add->getFirstMigrationDir
187    private function getFirstMigrationDir(array $migrations): ?string
188    {
189        $first = reset($migrations);
190
191        if ($first === false) {
196        if (is_string($first)) {
197            return $first;
201        return $first[0] ?? null;
202    }
Add->getPhpContent
128    private function getPhpContent(string $fileName, string $timestamp): string
129    {
130        $name = $this->getPhpMigrationName($fileName);
131        $namespace = 'Quma\\Migrations\\M' . str_replace('-', '_', $timestamp) . '_' . $name;
131        $namespace = 'Quma\\Migrations\\M' . str_replace('-', '_', $timestamp) . '_' . $name;
131        $namespace = 'Quma\\Migrations\\M' . str_replace('-', '_', $timestamp) . '_' . $name;
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    }
Add->getPhpMigrationName
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) {
162        if ($parts === false || count($parts) === 0) {
162        if ($parts === false || count($parts) === 0) {
163            return 'Migration';
167        return implode('', array_map(ucfirst(...), $parts));
167        return implode('', array_map(ucfirst(...), $parts));
167        return implode('', array_map(ucfirst(...), $parts));
167        return implode('', array_map(ucfirst(...), $parts));
168    }
Add->getTpqlContent
172        return "<?php if (\$driver === 'pgsql') : ?>
173
174<?php else : ?>
175
176<?php endif ?>
177";
178    }