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