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