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