Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
121 / 121
96.91% covered (success)
96.91%
94 / 97
68.57% covered (warning)
68.57%
48 / 70
92.31% covered (success)
92.31%
24 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Config
100.00% covered (success)
100.00%
121 / 121
96.91% covered (success)
96.91%
94 / 97
68.57% covered (warning)
68.57%
48 / 70
100.00% covered (success)
100.00%
26 / 26
186.21
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
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
 setCredentials
100.00% covered (success)
100.00%
1 / 1
n/a
0 / 0
n/a
0 / 0
100.00% covered (success)
100.00%
1 / 1
1
 setPdoOptions
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
 setPdoOption
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
 setFetchMode
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
 setPingAfterIdle
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
 setMaxConnectionAge
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
 seconds
100.00% covered (success)
100.00%
3 / 3
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
 setPlaceholders
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
 addSqlDirs
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
 setMigrations
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
 addMigrationDir
100.00% covered (success)
100.00%
6 / 6
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
 setMigrationNamespace
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
13 / 13
36.36% covered (danger)
36.36%
4 / 11
100.00% covered (success)
100.00%
1 / 1
15.28
 setMigrationsTable
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
 setMigrationsColumnMigration
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
 setMigrationsColumnApplied
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
 preparePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
5 / 5
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 readDriver
100.00% covered (success)
100.00%
4 / 4
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
 readFlatDirs
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
17 / 17
50.00% covered (danger)
50.00%
5 / 10
100.00% covered (success)
100.00%
1 / 1
16.00
 readAssocDirs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
6 / 6
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 readDirsEntry
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
7
 readMigrationDirs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
7 / 7
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
4.13
 isDriverConfig
100.00% covered (success)
100.00%
1 / 1
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
 readNamespacedDirs
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
5
 getMigrationsTableName
100.00% covered (success)
100.00%
6 / 6
84.62% covered (warning)
84.62%
11 / 13
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
6.00
 getColumnName
100.00% covered (success)
100.00%
3 / 3
83.33% covered (warning)
83.33%
5 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Quma;
6
7use PDO;
8use RuntimeException;
9use ValueError;
10
11/**
12 * @api
13 *
14 * @psalm-type SqlDirs = list<non-empty-string>
15 * @psalm-type SqlAssoc = array<non-empty-string, non-empty-string|list<non-empty-string>>
16 * @psalm-type SqlMixed = list<non-empty-string|SqlAssoc>
17 * @psalm-type SqlConfig = non-empty-string|SqlAssoc|SqlMixed
18 * @psalm-type MigrationDirsFlat = list<non-empty-string>
19 * @psalm-type MigrationDirsNamespaced = array<non-empty-string, non-empty-string|list<non-empty-string>>
20 * @psalm-type MigrationDirs = MigrationDirsFlat|MigrationDirsNamespaced
21 * @psalm-type PlaceholderMap = array<non-empty-string, string>
22 * @psalm-type PlaceholderConfig = array<non-empty-string, PlaceholderMap>
23 */
24final class Config
25{
26    /** @var non-empty-string */
27    public readonly string $driver;
28
29    /** @psalm-var SqlDirs */
30    public private(set) array $sql;
31
32    /** @psalm-var MigrationDirs */
33    public private(set) array $migrations = [];
34
35    public private(set) PdoConfig $pdo;
36    public private(set) ?Placeholders $placeholders = null;
37
38    public private(set) string $migrationsTable = 'migrations';
39    public private(set) string $migrationsColumnMigration = 'migration';
40    public private(set) string $migrationsColumnApplied = 'applied';
41
42    /** Seconds a connection may be idle before it is pinged on reuse; 0 never pings. */
43    public private(set) int $pingAfterIdle = 60;
44
45    /** Seconds after which a connection is replaced on reuse; 0 keeps it. */
46    public private(set) int $maxConnectionAge = 0;
47
48    /** @psalm-param SqlConfig $sql */
49    public function __construct(
50        public readonly string $dsn,
51        string|array $sql,
52    ) {
53        $this->driver = $this->readDriver($this->dsn);
54        $this->sql = $this->readFlatDirs($sql);
55        $this->pdo = new PdoConfig();
56    }
57
58    public function setCredentials(
59        string $username,
60        #[\SensitiveParameter]
61        ?string $password = null,
62    ): void {
63        $this->pdo = $this->pdo->credentials($username, $password);
64    }
65
66    /** @param array<array-key, mixed> $options */
67    public function setPdoOptions(array $options): void
68    {
69        $this->pdo = $this->pdo->options($options);
70    }
71
72    public function setPdoOption(int $attribute, mixed $value): void
73    {
74        $this->pdo = $this->pdo->option($attribute, $value);
75    }
76
77    public function setFetchMode(int $fetchMode): void
78    {
79        $this->pdo = $this->pdo->fetch($fetchMode);
80    }
81
82    public function setPingAfterIdle(int $seconds): void
83    {
84        $this->pingAfterIdle = self::seconds($seconds, 'pingAfterIdle');
85    }
86
87    public function setMaxConnectionAge(int $seconds): void
88    {
89        $this->maxConnectionAge = self::seconds($seconds, 'maxConnectionAge');
90    }
91
92    private static function seconds(int $seconds, string $setting): int
93    {
94        if ($seconds < 0) {
95            throw new ValueError("{$setting} must be a number of seconds, 0 to disable it: {$seconds}");
96        }
97
98        return $seconds;
99    }
100
101    /** @psalm-param PlaceholderConfig $placeholders */
102    public function setPlaceholders(Delimiters $delimiters, array $placeholders): void
103    {
104        $this->placeholders = new Placeholders($this->driver, $placeholders, $delimiters);
105    }
106
107    /** @psalm-param SqlConfig $sql */
108    public function addSqlDirs(array|string $sql): void
109    {
110        $dirs = $this->readFlatDirs($sql);
111        $this->sql = array_merge($dirs, $this->sql);
112    }
113
114    /** @psalm-param SqlConfig $migrations */
115    public function setMigrations(array|string $migrations): void
116    {
117        $this->migrations = $this->readMigrationDirs($migrations);
118    }
119
120    /** @param non-empty-string $migrations */
121    public function addMigrationDir(string $migrations): void
122    {
123        if (!array_is_list($this->migrations)) {
124            throw new ValueError(
125                'Cannot add a flat migration directory when migrations are namespaced. Use migrationNamespace().',
126            );
127        }
128
129        $dirs = $this->readFlatDirs($migrations);
130        $this->migrations = array_merge($dirs, $this->migrations);
131    }
132
133    /** @psalm-param SqlConfig $dirs */
134    public function setMigrationNamespace(string $namespace, string|array $dirs): void
135    {
136        if ($namespace === '') {
137            throw new ValueError('Migration namespace must not be empty.');
138        }
139
140        if (array_is_list($this->migrations) && count($this->migrations) > 0) {
141            throw new ValueError(
142                'Cannot add a namespaced migration directory when migrations are configured as a flat list.',
143            );
144        }
145
146        /** @psalm-var MigrationDirsNamespaced $migrations */
147        $migrations = array_is_list($this->migrations) ? [] : $this->migrations;
148        $migrations[$namespace] = is_string($dirs)
149            ? $this->preparePath($dirs)
150            : $this->readDirsEntry($dirs);
151        $this->migrations = $migrations;
152    }
153
154    public function setMigrationsTable(string $table): void
155    {
156        $this->migrationsTable = $this->getMigrationsTableName($table);
157    }
158
159    public function setMigrationsColumnMigration(string $column): void
160    {
161        $this->migrationsColumnMigration = $this->getColumnName($column);
162    }
163
164    public function setMigrationsColumnApplied(string $column): void
165    {
166        $this->migrationsColumnApplied = $this->getColumnName($column);
167    }
168
169    /** @return non-empty-string */
170    private function preparePath(string $path): string
171    {
172        $result = realpath($path);
173
174        if ($result !== false && $result !== '') {
175            return $result;
176        }
177
178        throw new ValueError("Path does not exist: {$path}");
179    }
180
181    /** @return non-empty-string */
182    private function readDriver(string $dsn): string
183    {
184        $driver = explode(':', $dsn)[0];
185
186        if (in_array($driver, PDO::getAvailableDrivers(), strict: true)) {
187            assert($driver !== '', 'PDO driver name must not be empty.');
188
189            return $driver;
190        }
191
192        throw new RuntimeException('PDO driver not supported: ' . $driver);
193    }
194
195    /**
196     * Reads directories from configuration into a flat list.
197     *
198     * @psalm-param SqlConfig $config
199     *
200     * @return list<non-empty-string>
201     */
202    private function readFlatDirs(string|array $config): array
203    {
204        if (is_string($config)) {
205            return [$this->preparePath($config)];
206        }
207
208        if (Util::isAssoc($config)) {
209            return $this->readAssocDirs($config);
210        }
211
212        $dirs = [];
213
214        foreach ($config as $entry) {
215            if (is_string($entry)) {
216                array_unshift($dirs, $this->preparePath($entry));
217
218                continue;
219            }
220
221            if (array_is_list($entry)) {
222                foreach ($entry as $path) {
223                    if (!is_string($path)) {
224                        continue;
225                    }
226
227                    array_unshift($dirs, $this->preparePath($path));
228                }
229
230                continue;
231            }
232
233            $dirs = array_merge($this->readAssocDirs($entry), $dirs);
234        }
235
236        return $dirs;
237    }
238
239    /**
240     * Reads directories from an associative array config.
241     *
242     * @param array<array-key, mixed> $entry
243     *
244     * @return list<non-empty-string>
245     */
246    private function readAssocDirs(array $entry): array
247    {
248        $dirs = array_key_exists($this->driver, $entry) ? $this->readDirsEntry($entry[$this->driver]) : [];
249
250        if (array_key_exists('all', $entry)) {
251            $dirs = array_merge($dirs, $this->readDirsEntry($entry['all']));
252        }
253
254        return $dirs;
255    }
256
257    /** @return list<non-empty-string> */
258    private function readDirsEntry(mixed $entry): array
259    {
260        if (is_string($entry)) {
261            return [$this->preparePath($entry)];
262        }
263
264        if (!is_array($entry)) {
265            return [];
266        }
267
268        $dirs = [];
269
270        array_walk(
271            $entry,
272            function (mixed $value) use (&$dirs): void {
273                if (is_string($value)) {
274                    $dirs[] = $this->preparePath($value);
275
276                    return;
277                }
278
279                if (!is_array($value)) {
280                    return;
281                }
282
283                if (Util::isAssoc($value)) {
284                    $dirs = array_merge($dirs, $this->readAssocDirs($value));
285
286                    return;
287                }
288
289                array_walk(
290                    $value,
291                    function (mixed $path) use (&$dirs): void {
292                        if (is_string($path)) {
293                            $dirs[] = $this->preparePath($path);
294                        }
295                    },
296                );
297            },
298        );
299
300        return $dirs;
301    }
302
303    /**
304     * Reads migration directories from configuration.
305     *
306     * Migrations can be configured as:
307     * - A flat list of directories
308     * - A namespaced structure with string keys mapping to directories
309     *
310     * @psalm-param SqlConfig $config
311     *
312     * @psalm-return MigrationDirs
313     */
314    private function readMigrationDirs(string|array $config): array
315    {
316        if (is_string($config)) {
317            return [$this->preparePath($config)];
318        }
319
320        if (Util::isAssoc($config) && !$this->isDriverConfig($config)) {
321            return $this->readNamespacedDirs($config);
322        }
323
324        return $this->readFlatDirs($config);
325    }
326
327    /** @param array<array-key, mixed> $config */
328    private function isDriverConfig(array $config): bool
329    {
330        return array_key_exists($this->driver, $config) || array_key_exists('all', $config);
331    }
332
333    /**
334     * Reads namespaced migration directories.
335     *
336     * @param array<array-key, mixed> $config
337     *
338     * @psalm-return MigrationDirsNamespaced
339     */
340    private function readNamespacedDirs(array $config): array
341    {
342        $result = [];
343
344        array_walk(
345            $config,
346            function (mixed $dirs, int|string $namespace) use (&$result): void {
347                if (!is_string($namespace) || $namespace === '') {
348                    return;
349                }
350
351                if (is_string($dirs)) {
352                    $result[$namespace] = $this->preparePath($dirs);
353
354                    return;
355                }
356
357                if (!is_array($dirs)) {
358                    return;
359                }
360
361                $result[$namespace] = $this->readDirsEntry($dirs);
362            },
363        );
364
365        return $result;
366    }
367
368    private function getMigrationsTableName(string $table): string
369    {
370        if ($this->driver === 'pgsql') {
371            if (preg_match('/^([a-zA-Z0-9_]+\.)?[a-zA-Z0-9_]+$/', $table)) {
372                return $table;
373            }
374        } elseif (preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
375            return $table;
376        }
377
378        throw new ValueError('Invalid migrations table name: ' . $table);
379    }
380
381    private function getColumnName(string $column): string
382    {
383        if (preg_match('/^[a-zA-Z0-9_]+$/', $column)) {
384            return $column;
385        }
386
387        throw new ValueError('Invalid migrations table column name: ' . $column);
388    }
389}