Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
196 / 196
84.58% covered (warning)
84.58%
203 / 240
8.84% covered (danger)
8.84%
55 / 622
54.84% covered (warning)
54.84%
17 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Debug
100.00% covered (success)
100.00%
196 / 196
84.58% covered (warning)
84.58%
203 / 240
8.84% covered (danger)
8.84%
55 / 622
100.00% covered (success)
100.00%
31 / 31
8943.39
100.00% covered (success)
100.00%
1 / 1
 query
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
22 / 22
1.23% covered (danger)
1.23%
4 / 324
100.00% covered (success)
100.00%
1 / 1
127.57
 interpolate
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
2
 enabled
100.00% covered (success)
100.00%
2 / 2
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
 prints
100.00% covered (success)
100.00%
2 / 2
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
 writesTranslated
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
 writesInterpolated
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
 printQuery
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 value
100.00% covered (success)
100.00%
10 / 10
86.67% covered (warning)
86.67%
13 / 15
71.43% covered (warning)
71.43%
5 / 7
100.00% covered (success)
100.00%
1 / 1
8.14
 prepareQuery
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
8 / 8
10.00% covered (danger)
10.00%
1 / 10
100.00% covered (success)
100.00%
1 / 1
9.56
 restoreQuery
100.00% covered (success)
100.00%
3 / 3
85.71% covered (warning)
85.71%
6 / 7
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
 interpolateNamed
100.00% covered (success)
100.00%
9 / 9
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
4.12
 interpolatePositional
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
1
 flag
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
4.94
 env
100.00% covered (success)
100.00%
7 / 7
81.82% covered (warning)
81.82%
9 / 11
20.00% covered (danger)
20.00%
2 / 10
100.00% covered (success)
100.00%
1 / 1
12.19
 dir
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
8 / 8
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
7.33
 writeEnv
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 write
100.00% covered (success)
100.00%
4 / 4
80.00% covered (warning)
80.00%
8 / 10
25.00% covered (danger)
25.00%
2 / 8
100.00% covered (success)
100.00%
1 / 1
15.55
 relativeToRoots
100.00% covered (success)
100.00%
10 / 10
73.91% covered (warning)
73.91%
17 / 23
1.56% covered (danger)
1.56%
1 / 64
100.00% covered (success)
100.00%
1 / 1
69.05
 sessionPath
100.00% covered (success)
100.00%
5 / 5
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
 sourceName
100.00% covered (success)
100.00%
8 / 8
75.00% covered (warning)
75.00%
9 / 12
22.22% covered (danger)
22.22%
2 / 9
100.00% covered (success)
100.00%
1 / 1
11.53
 session
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
 sessionInfo
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
7 / 7
60.00% covered (warning)
60.00%
3 / 5
100.00% covered (success)
100.00%
1 / 1
5.02
 requestTime
100.00% covered (success)
100.00%
9 / 9
92.31% covered (success)
92.31%
12 / 13
11.11% covered (danger)
11.11%
3 / 27
100.00% covered (success)
100.00%
1 / 1
31.28
 argv
100.00% covered (success)
100.00%
1 / 1
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
 sessionLabel
100.00% covered (success)
100.00%
2 / 2
42.86% covered (danger)
42.86%
3 / 7
33.33% covered (danger)
33.33%
1 / 3
100.00% covered (success)
100.00%
1 / 1
3.19
 uriLabel
100.00% covered (success)
100.00%
9 / 9
65.22% covered (warning)
65.22%
15 / 23
1.92% covered (danger)
1.92%
2 / 104
100.00% covered (success)
100.00%
1 / 1
39.96
 hash
100.00% covered (success)
100.00%
1 / 1
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
 server
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
10 / 10
40.00% covered (danger)
40.00%
4 / 10
100.00% covered (success)
100.00%
1 / 1
10.40
 counter
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
 safeRelativePath
100.00% covered (success)
100.00%
8 / 8
85.71% covered (warning)
85.71%
12 / 14
n/a
0 / 0
100.00% covered (success)
100.00%
1 / 1
7
 safeSegment
100.00% covered (success)
100.00%
3 / 3
70.00% covered (warning)
70.00%
7 / 10
12.50% covered (danger)
12.50%
1 / 8
100.00% covered (success)
100.00%
1 / 1
4.68
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Quma;
6
7use DateTimeImmutable;
8use RuntimeException;
9
10/**
11 * @internal
12 *
13 * Coverage ignores mark defensive filesystem/global-state race branches that are
14 * not deterministic or meaningful to exercise in tests.
15 */
16final class Debug
17{
18    public const string ENV_DEBUG = 'QUMA_DEBUG';
19    public const string ENV_PRINT = 'QUMA_DEBUG_PRINT';
20    public const string ENV_TRANSLATED = 'QUMA_DEBUG_TRANSLATED';
21    public const string ENV_INTERPOLATED = 'QUMA_DEBUG_INTERPOLATED';
22
23    private static ?string $sessionKey = null;
24    private static ?string $session = null;
25    private static ?string $fallbackTime = null;
26    private static int $counter = 0;
27
28    public static function query(Database $db, string $query, Args $args, ?string $sourcePath): void
29    {
30        $print = self::prints();
31        $writeTranslated = self::writesTranslated();
32        $writeInterpolated = self::writesInterpolated();
33
34        if (!$print && !$writeTranslated && !$writeInterpolated) {
35            return;
36        }
37
38        $path = $writeTranslated || $writeInterpolated
39            ? self::sessionPath($sourcePath, $db->getSqlDirs())
40            : null;
41
42        if ($writeTranslated) {
43            self::writeEnv(self::ENV_TRANSLATED, $path, $query);
44        }
45
46        if (!$print && !$writeInterpolated) {
47            return;
48        }
49
50        $interpolated = self::interpolate($query, $args);
51
52        if ($print) {
53            self::printQuery($interpolated);
54        }
55
56        if ($writeInterpolated) {
57            self::writeEnv(self::ENV_INTERPOLATED, $path, $interpolated);
58        }
59    }
60
61    public static function interpolate(string $query, Args $args): string
62    {
63        $prep = self::prepareQuery($query);
64
65        if ($args->type() === ArgType::Named) {
66            $interpolated = self::interpolateNamed($prep->query, $args->getNamed());
67        } else {
68            $interpolated = self::interpolatePositional($prep->query, $args->get());
69        }
70
71        return self::restoreQuery($interpolated, $prep);
72    }
73
74    public static function enabled(): bool
75    {
76        $value = self::env(self::ENV_DEBUG);
77
78        return $value !== null && self::flag($value);
79    }
80
81    public static function prints(): bool
82    {
83        $value = self::env(self::ENV_PRINT);
84
85        return $value !== null && self::flag($value);
86    }
87
88    public static function writesTranslated(): bool
89    {
90        return self::env(self::ENV_TRANSLATED) !== null;
91    }
92
93    public static function writesInterpolated(): bool
94    {
95        return self::env(self::ENV_INTERPOLATED) !== null;
96    }
97
98    private static function printQuery(string $query): void
99    {
100        $msg =
101            "\n\n-----------------------------------------------\n\n"
102            . $query
103            . "\n------------------------------------------------\n";
104
105        if (($_SERVER['SERVER_SOFTWARE'] ?? null) !== null) {
106            // @codeCoverageIgnoreStart
107            error_log($msg);
108
109            // @codeCoverageIgnoreEnd
110        } else {
111            echo $msg;
112        }
113    }
114
115    private static function value(mixed $value): string
116    {
117        if (is_string($value)) {
118            return "'" . $value . "'";
119        }
120
121        if (is_array($value)) {
122            $encoded = json_encode($value);
123
124            return "'" . ($encoded !== false ? $encoded : '[]') . "'";
125        }
126
127        if (is_null($value)) {
128            return 'NULL';
129        }
130
131        if (is_bool($value)) {
132            return $value ? 'true' : 'false';
133        }
134
135        return (string) $value;
136    }
137
138    private static function prepareQuery(string $query): PreparedQuery
139    {
140        $patterns = [
141            Query::PATTERN_BLOCK,
142            Query::PATTERN_STRING,
143            Query::PATTERN_COMMENT_MULTI,
144            Query::PATTERN_COMMENT_SINGLE,
145        ];
146
147        $swaps = [];
148        $i = 0;
149
150        do {
151            $found = false;
152
153            foreach ($patterns as $pattern) {
154                $matches = [];
155
156                if (preg_match($pattern, $query, $matches) === 1) {
157                    $match = $matches[0];
158                    $replacement = "___CHUCK_REPLACE_{$i}___";
159                    assert($match !== '', 'Query placeholder match must not be empty.');
160                    $swaps[$replacement] = $match;
161
162                    $query = preg_replace($pattern, $replacement, $query, limit: 1) ?? $query;
163                    $found = true;
164                    $i++;
165
166                    break;
167                }
168            }
169        } while ($found);
170
171        return new PreparedQuery($query, $swaps);
172    }
173
174    private static function restoreQuery(string $query, PreparedQuery $prep): string
175    {
176        foreach ($prep->swaps as $swap => $replacement) {
177            $query = str_replace($swap, $replacement, $query);
178        }
179
180        return $query;
181    }
182
183    /** @param array<array-key, mixed> $args */
184    private static function interpolateNamed(string $query, array $args): string
185    {
186        $map = [];
187
188        array_walk(
189            $args,
190            static function (mixed $value, int|string $key) use (&$map): void {
191                if (is_string($key) && $key !== '') {
192                    $map[':' . $key] = self::value($value);
193                }
194            },
195        );
196
197        return strtr($query, $map);
198    }
199
200    /** @param array<array-key, mixed> $args */
201    private static function interpolatePositional(string $query, array $args): string
202    {
203        $result = $query;
204
205        array_walk(
206            $args,
207            static function (mixed $value) use (&$result): void {
208                $replaced = preg_replace('/\\?/', self::value($value), $result, 1);
209                $result = $replaced ?? $result;
210            },
211        );
212
213        return $result;
214    }
215
216    private static function flag(string $value): bool
217    {
218        return match (strtolower($value)) {
219            '1', 'true', 'yes', 'on' => true,
220            default => false,
221        };
222    }
223
224    private static function env(string $name): ?string
225    {
226        $value = getenv($name);
227
228        if ($value === false) {
229            $value = $_SERVER[$name] ?? $_ENV[$name] ?? null;
230        }
231
232        if (!is_string($value)) {
233            return null;
234        }
235
236        $value = trim($value);
237
238        return $value === '' ? null : $value;
239    }
240
241    /** @return non-empty-string|null */
242    private static function dir(string $name): ?string
243    {
244        $dir = self::env($name);
245
246        if ($dir === null) {
247            return null; // @codeCoverageIgnore
248        }
249
250        if (!is_dir($dir)) {
251            throw new RuntimeException("Quma debug directory does not exist for {$name}{$dir}");
252        }
253
254        if (!is_writable($dir)) {
255            throw new RuntimeException("Quma debug directory is not writable for {$name}{$dir}"); // @codeCoverageIgnore
256        }
257
258        $path = realpath($dir);
259
260        if ($path === false || $path === '') {
261            throw new RuntimeException("Quma debug directory does not exist for {$name}{$dir}"); // @codeCoverageIgnore
262        }
263
264        return $path;
265    }
266
267    private static function writeEnv(string $name, ?string $relative, string $source): void
268    {
269        if ($relative === null) {
270            return; // @codeCoverageIgnore
271        }
272
273        $dir = self::dir($name);
274
275        if ($dir === null) {
276            return; // @codeCoverageIgnore
277        }
278
279        self::write($dir, $relative, $source);
280    }
281
282    private static function write(
283        string $dir,
284        string $relative,
285        string $source,
286    ): void {
287        $path = $dir . DIRECTORY_SEPARATOR . self::safeRelativePath($relative);
288        $targetDir = dirname($path);
289
290        if (!is_dir($targetDir) && !mkdir($targetDir, 0o775, true) && !is_dir($targetDir)) {
291            throw new RuntimeException('Could not create Quma debug directory: ' . $targetDir); // @codeCoverageIgnore
292        }
293
294        if (file_put_contents($path, $source, LOCK_EX) === false) {
295            throw new RuntimeException('Could not write Quma debug file: ' . $path); // @codeCoverageIgnore
296        }
297    }
298
299    /** @param array<array-key, mixed> $roots */
300    private static function relativeToRoots(string $sourcePath, array $roots): string
301    {
302        $realSource = realpath($sourcePath);
303        $source = $realSource !== false ? $realSource : $sourcePath;
304
305        foreach ($roots as $root) {
306            if (!is_string($root) || $root === '') {
307                continue; // @codeCoverageIgnore
308            }
309
310            $realRoot = realpath($root);
311            $root = $realRoot !== false ? $realRoot : $root;
312            $prefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
313
314            if (str_starts_with($source, $prefix)) {
315                $relative = substr($source, strlen($prefix));
316
317                return $relative === '' ? basename($sourcePath) : $relative;
318            }
319        }
320
321        return basename($sourcePath); // @codeCoverageIgnore
322    }
323
324    /** @param array<array-key, mixed> $roots */
325    private static function sessionPath(?string $sourcePath, array $roots): string
326    {
327        return self::session()
328        . DIRECTORY_SEPARATOR
329        . self::counter()
330        . '--'
331        . self::sourceName($sourcePath, $roots);
332    }
333
334    /** @param array<array-key, mixed> $roots */
335    private static function sourceName(?string $sourcePath, array $roots): string
336    {
337        if ($sourcePath === null) {
338            return 'execute.sql';
339        }
340
341        $relative = self::relativeToRoots($sourcePath, $roots);
342        $dir = dirname($relative);
343        $name = pathinfo($relative, PATHINFO_FILENAME);
344        $name = $name !== '' ? $name : 'query';
345        $path = ($dir === '.' ? '' : $dir . DIRECTORY_SEPARATOR) . $name . '.sql';
346
347        return preg_replace('/[\\/]+/', '--', $path) ?? 'query.sql';
348    }
349
350    private static function session(): string
351    {
352        [$key, $session] = self::sessionInfo();
353
354        if (self::$sessionKey !== $key) {
355            self::$sessionKey = $key;
356            self::$session = $session;
357            self::$counter = 0;
358        }
359
360        return self::$session ?? $session;
361    }
362
363    /** @return array{0: string, 1: string} */
364    private static function sessionInfo(): array
365    {
366        $explicit = self::env('QUMA_DEBUG_SESSION');
367
368        if ($explicit !== null) {
369            return ['env:' . $explicit, self::sessionLabel($explicit)];
370        }
371
372        $method = self::server('REQUEST_METHOD');
373        $requestUri = self::server('REQUEST_URI');
374
375        if ($method !== null || $requestUri !== null) {
376            $method = strtoupper($method ?? 'HTTP');
377            $uri = $requestUri ?? self::server('SCRIPT_NAME') ?? self::server('PHP_SELF') ?? '/';
378            $time = self::requestTime();
379            $label = self::uriLabel($uri);
380            $hash = self::hash([$time, $method, $uri, (string) getmypid()]);
381
382            return ["http:{$time}:{$method}:{$uri}", "{$time}--{$method}--{$label}--{$hash}"];
383        }
384
385        $time = self::requestTime();
386        $hash = self::hash([$time, (string) getmypid(), self::argv()]);
387
388        return ["cli:{$time}", "{$time}--cli--{$hash}"];
389    }
390
391    private static function requestTime(): string
392    {
393        $time = self::server('REQUEST_TIME_FLOAT');
394
395        if ($time !== null && is_numeric($time)) {
396            $date = DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', (float) $time));
397
398            if ($date instanceof DateTimeImmutable) {
399                return $date->format('Ymd-His-u');
400            }
401        }
402
403        $time = self::server('REQUEST_TIME');
404
405        if ($time !== null && ctype_digit($time)) {
406            return new DateTimeImmutable('@' . $time)->format('Ymd-His') . '-000000';
407        }
408
409        return self::$fallbackTime ??= new DateTimeImmutable()->format('Ymd-His-u');
410    }
411
412    private static function argv(): string
413    {
414        return implode(' ', $_SERVER['argv'] ?? []);
415    }
416
417    private static function sessionLabel(string $value): string
418    {
419        $label = self::safeSegment($value);
420
421        return strlen($label) <= 96 ? $label : substr($label, 0, 96);
422    }
423
424    private static function uriLabel(string $uri): string
425    {
426        $path = parse_url($uri, PHP_URL_PATH);
427        $path = is_string($path) && $path !== '' ? $path : '/';
428        $label = trim($path, '/');
429
430        if ($label === '') {
431            return 'root';
432        }
433
434        $label = preg_replace('/[^A-Za-z0-9._:-]+/', '-', $label) ?? 'request';
435        $label = trim($label, '-.');
436        $label = $label !== '' ? $label : 'request';
437
438        return strlen($label) <= 64 ? $label : substr($label, 0, 64);
439    }
440
441    /** @param list<string> $parts */
442    private static function hash(array $parts): string
443    {
444        return substr(hash('xxh128', implode("\0", $parts)), 0, 8);
445    }
446
447    private static function server(string $name): ?string
448    {
449        $value = $_SERVER[$name] ?? null;
450
451        if (is_float($value) || is_int($value)) {
452            return (string) $value;
453        }
454
455        return is_string($value) && $value !== '' ? $value : null;
456    }
457
458    private static function counter(): string
459    {
460        self::$counter++;
461
462        return str_pad((string) self::$counter, 4, '0', STR_PAD_LEFT);
463    }
464
465    private static function safeRelativePath(string $path): string
466    {
467        $parts = preg_split('/[\\/]+/', $path, -1, PREG_SPLIT_NO_EMPTY);
468
469        if (!is_array($parts) || count($parts) === 0) {
470            return 'query.sql'; // @codeCoverageIgnore
471        }
472
473        $result = [];
474
475        foreach ($parts as $part) {
476            if ($part === '.' || $part === '..') {
477                continue; // @codeCoverageIgnore
478            }
479
480            $result[] = self::safeSegment($part);
481        }
482
483        $path = implode(DIRECTORY_SEPARATOR, $result);
484
485        return $path !== '' ? $path : 'query.sql';
486    }
487
488    private static function safeSegment(string $segment): string
489    {
490        $result = preg_replace('/[^A-Za-z0-9._:-]+/', '_', $segment);
491        $result = trim($result ?? '', '.');
492
493        return $result === '' ? '_' : $result;
494    }
495}