Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
104 / 104
87.93% covered (warning)
87.93%
51 / 58
11.54% covered (danger)
11.54%
15 / 130
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Environment
100.00% covered (success)
100.00%
104 / 104
87.93% covered (warning)
87.93%
51 / 58
11.54% covered (danger)
11.54%
15 / 130
100.00% covered (success)
100.00%
6 / 6
933.16
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
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
 getMigrations
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
7 / 7
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
10.37
 normalizeMigrationDirs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 collectMigrations
100.00% covered (success)
100.00%
17 / 17
76.92% covered (warning)
76.92%
10 / 13
10.00% covered (danger)
10.00%
1 / 10
100.00% covered (success)
100.00%
1 / 1
54.66
 checkIfMigrationsTableExists
100.00% covered (success)
100.00%
17 / 17
88.89% covered (warning)
88.89%
16 / 18
7.69% covered (danger)
7.69%
6 / 78
100.00% covered (success)
100.00%
1 / 1
58.34
 getMigrationsTableDDL
100.00% covered (success)
100.00%
27 / 27
87.50% covered (warning)
87.50%
14 / 16
8.33% covered (danger)
8.33%
3 / 36
100.00% covered (success)
100.00%
1 / 1
44.74
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Quma;
6
7use Celemas\Cli\Opts;
8use PDO;
9use RuntimeException;
10
11/**
12 * @api
13 */
14class Environment
15{
16    public readonly Connection $conn;
17    public readonly string $driver;
18    public readonly bool $showStacktrace;
19    public readonly string $table;
20    public readonly string $columnMigration;
21    public readonly string $columnApplied;
22    public readonly Database $db;
23
24    /** @param array<non-empty-string, Connection> $connections */
25    public function __construct(
26        array $connections,
27        public readonly array $options,
28    ) {
29        $opts = new Opts();
30
31        $key = $opts->get('--conn', 'default');
32
33        if (!array_key_exists($key, $connections)) {
34            throw new RuntimeException("Connection '{$key}' does not exist");
35        }
36
37        $this->conn = $connections[$key];
38        $this->showStacktrace = $opts->has('--stacktrace');
39        $this->db = new Database($this->conn);
40        $this->driver = $this->conn->config->driver;
41        $this->table = $this->conn->config->migrationsTable;
42        $this->columnMigration = $this->conn->config->migrationsColumnMigration;
43        $this->columnApplied = $this->conn->config->migrationsColumnApplied;
44    }
45
46    /**
47     * @return array<string, list<string>>|false
48     */
49    public function getMigrations(): array|false
50    {
51        $migrations = [];
52        $migrationDirs = $this->conn->config->migrations;
53
54        if (count($migrationDirs) === 0) {
55            echo "\033[1;31mNotice\033[0m: No migration directories defined in configuration\033[0m\n";
56
57            return false;
58        }
59
60        // Check if migrations is a flat list or namespaced
61        if (array_is_list($migrationDirs)) {
62            // Flat list: wrap in 'default' namespace
63            $dirs = $this->normalizeMigrationDirs($migrationDirs);
64
65            if (count($dirs) > 0) {
66                $migrations['default'] = $this->collectMigrations($dirs);
67            }
68        } else {
69            // Namespaced: process each namespace
70            array_walk(
71                $migrationDirs,
72                function (mixed $dirs, int|string $namespace) use (&$migrations): void {
73                    if (!is_string($namespace)) {
74                        return; // @codeCoverageIgnore
75                    }
76
77                    if (is_string($dirs)) {
78                        $resolvedDirs = $this->normalizeMigrationDirs([$dirs]);
79                    } elseif (is_array($dirs)) {
80                        $resolvedDirs = $this->normalizeMigrationDirs($dirs);
81                    } else {
82                        return; // @codeCoverageIgnore
83                    }
84
85                    if (count($resolvedDirs) === 0) {
86                        return;
87                    }
88
89                    $migrations[$namespace] = $this->collectMigrations($resolvedDirs);
90                },
91            );
92        }
93
94        return $migrations;
95    }
96
97    /**
98     * @param array<array-key, mixed> $dirs
99     *
100     * @return list<non-empty-string>
101     */
102    protected function normalizeMigrationDirs(array $dirs): array
103    {
104        $normalized = [];
105
106        array_walk(
107            $dirs,
108            static function (mixed $path) use (&$normalized): void {
109                if (is_string($path) && $path !== '') {
110                    $normalized[] = $path;
111                }
112            },
113        );
114
115        return $normalized;
116    }
117
118    /**
119     * @param list<non-empty-string> $migrationDirs
120     *
121     * @return list<string>
122     */
123    protected function collectMigrations(array $migrationDirs): array
124    {
125        $migrations = [];
126
127        foreach ($migrationDirs as $path) {
128            $phpFiles = glob("{$path}/*.php");
129            $sqlFiles = glob("{$path}/*.sql");
130            $tpqlFiles = glob("{$path}/*.tpql");
131
132            $migrations = array_merge(
133                $migrations,
134                is_array($phpFiles) ? array_filter($phpFiles, 'is_file') : [],
135                is_array($sqlFiles) ? array_filter($sqlFiles, 'is_file') : [],
136                is_array($tpqlFiles) ? array_filter($tpqlFiles, 'is_file') : [],
137            );
138        }
139
140        // Sort by file name instead of full path
141        uasort($migrations, static function ($a, $b) {
142            $a = is_string($a) ? $a : '';
143            $b = is_string($b) ? $b : '';
144
145            return basename($a) < basename($b) ? -1 : 1;
146        });
147
148        return array_values($migrations);
149    }
150
151    public function checkIfMigrationsTableExists(Database $db): bool
152    {
153        $driver = $db->getPdoDriver();
154        $table = $this->table;
155
156        if ($driver === 'pgsql' && str_contains($table, '.')) {
157            [$schema, $table] = explode('.', $table);
158        } else {
159            $schema = 'public';
160        }
161
162        $query = match ($driver) {
163            'sqlite' => "
164                SELECT count(*) AS available
165                FROM sqlite_master
166                WHERE type='table'
167                AND name='{$table}';",
168            'mysql' => "
169                SELECT count(*) AS available
170                FROM information_schema.tables
171                WHERE table_schema = DATABASE()
172                AND table_name='{$table}';",
173            'pgsql' => "
174                SELECT count(*) AS available
175                FROM pg_tables
176                WHERE schemaname = '{$schema}'
177                AND tablename = '{$table}';",
178        };
179
180        if ($query && ($db->execute($query)->one(fetchMode: PDO::FETCH_ASSOC)['available'] ?? 0) === 1) {
181            return true;
182        }
183
184        return false;
185    }
186
187    public function getMigrationsTableDDL(): string|false
188    {
189        if ($this->driver === 'pgsql' && str_contains($this->table, '.')) {
190            [$schema, $table] = explode('.', $this->table);
191        } else {
192            $schema = 'public';
193            $table = $this->table;
194        }
195        $columnMigration = $this->columnMigration;
196        $columnApplied = $this->columnApplied;
197
198        switch ($this->driver) {
199            case 'sqlite':
200                return "CREATE TABLE {$table} (
201    {$columnMigration} text NOT NULL,
202    {$columnApplied} text DEFAULT CURRENT_TIMESTAMP,
203    PRIMARY KEY ({$columnMigration}),
204    CHECK(typeof(\"{$columnMigration}\") = \"text\" AND length(\"{$columnMigration}\") <= 256),
205    CHECK(typeof(\"{$columnApplied}\") = \"text\" AND length(\"{$columnApplied}\") = 19)
206);";
207
208            case 'pgsql':
209                return "CREATE TABLE {$schema}.{$table} (
210    {$columnMigration} text NOT NULL CHECK (char_length({$columnMigration}) <= 256),
211    {$columnApplied} timestamp with time zone DEFAULT now() NOT NULL,
212    CONSTRAINT pk_{$table} PRIMARY KEY ({$columnMigration})
213);";
214
215            case 'mysql':
216                return "CREATE TABLE {$table} (
217    {$columnMigration} varchar(256) NOT NULL,
218    {$columnApplied} timestamp DEFAULT CURRENT_TIMESTAMP,
219    PRIMARY KEY ({$columnMigration})
220);";
221
222            default:
223                // Cannot be reliably tested.
224                // Would require an unsupported driver to be installed.
225                // @codeCoverageIgnoreStart
226                return false;
227
228            // @codeCoverageIgnoreEnd
229        }
230    }
231}