packages/sql-formatter/fuzz/Target/SqliteEquivalence.php

1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Target;
6
7use Error;
8use PDO;
9use PDOException;
10use SqlFormatter\Core\FormattingException;
11use SqlFormatter\Core\Style;
12use SqlFormatter\Facade\Formatter;
13use SqlParser\Lexer\SourceException;
14
15/**
16 * Runs a generated statement and its formatted text on SQLite and reports a different answer.
17 *
18 * Every statement runs on its own empty in-memory database, and file names it may carry,
19 * such as the target of `ATTACH` or `VACUUM INTO`, resolve inside a scratch directory. A
20 * mismatch is confirmed by running the original once more, so a statement that reads the
21 * clock is not reported.
22 */
23final class SqliteEquivalence
24{
25    /**
26     * @param string $scratch Directory the process changes into while a statement runs
27     * @param Formatter $formatter The formatter under test
28     * @param Style $style The layout preset in use, for findings
29     */
30    public function __construct(
31        private readonly string $scratch,
32        private readonly Formatter $formatter,
33        private readonly Style $style,
34    ) {
35    }
36
37    /**
38     * Verifies that SQLite answers the formatted statement as it answers the original.
39     *
40     * @param string $sql Statement produced by the grammar
41     * @param string $input Fuzzer input that produced the statement, so a finding can be replayed
42     *
43     * @throws Error When the statement is empty, formatting fails verification, or the answers differ
44     */
45    public function verify(string $sql, string $input): void
46    {
47        if ($sql === '') {
48            throw new Error("Statement generation returned an empty string\nInput (hex): " . bin2hex($input));
49        }
50        $context = "Grammar: sqlite-3.47.2\nStyle: {$this->style->value}\nInput (hex): " . bin2hex($input) . "\nSQL: {$sql}";
51        try {
52            $formatted = $this->formatter->format($sql);
53        } catch (SourceException) {
54            return;
55        } catch (FormattingException $failure) {
56            throw new Error("Formatting failed verification\n{$context}\nError: {$failure->getMessage()}", 0, $failure);
57        }
58        $before = $this->execute($sql);
59        $after = $this->execute($formatted);
60        if ($before === $after || $this->execute($sql) !== $before) {
61            return;
62        }
63        throw new Error(
64            "Formatted SQL behaves differently on SQLite\n{$context}\nFormatted: {$formatted}\n" .
65            'Before: ' . json_encode($before, JSON_INVALID_UTF8_SUBSTITUTE) . "\n" .
66            'After: ' . json_encode($after, JSON_INVALID_UTF8_SUBSTITUTE),
67        );
68    }
69
70    /**
71     * Executes one statement on a new in-memory database and answers its rows, its affected row count, or its error.
72     *
73     * @return array{string, mixed}|array{string, int, string} ["ok", rows or count] or ["error", code, message]
74     */
75    public function execute(string $sql): array
76    {
77        $directory = getcwd();
78        if ($directory === false || !chdir($this->scratch)) {
79            fwrite(STDERR, "Cannot enter the scratch directory {$this->scratch}.\n");
80            exit(2);
81        }
82        try {
83            $pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
84            $statement = $pdo->query($sql);
85            if ($statement === false) {
86                fwrite(STDERR, "PDO::query returned false without throwing.\n");
87                exit(2);
88            }
89            return ['ok', $statement->columnCount() > 0 ? $statement->fetchAll(PDO::FETCH_NUM) : $statement->rowCount()];
90        } catch (PDOException $rejection) {
91            $code = $rejection->errorInfo[1] ?? 0;
92            $message = $rejection->errorInfo[2] ?? null;
93            return ['error', is_int($code) ? $code : 0, trim(is_string($message) ? $message : $rejection->getMessage())];
94        } finally {
95            chdir($directory);
96        }
97    }
98}
99