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