packages/sql-catalog/fuzz/Target/RecoverTarget.php
1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Target;
6
7use Error;
8use Faker\Factory;
9use Faker\Generator;
10use SqlCatalog\Facade\Analyzer;
11use SqlFaker\Generation\Choice\BytePlanCompiler;
12use SqlFaker\Generation\Choice\PlanBuilder;
13use SqlFaker\Generation\Plan\GenerationPlan;
14use SqlFaker\MySql\MySqlProvider;
15
16/**
17 * Checks that a statement written into PHP comes back out of the catalog.
18 *
19 * The input picks both the statement, through sql-faker's grammar, and the way
20 * it is written into the source: as one literal, as a concatenation, through a
21 * constant, through a variable, through a call. Every one of those has to
22 * produce the same catalogued statement, character for character, because the
23 * whole point of the analysis is that how a query is assembled does not change
24 * what it is.
25 */
26final class RecoverTarget
27{
28 private readonly Generator $faker;
29
30 private readonly MySqlProvider $sql;
31
32 private readonly PlanBuilder $planner;
33
34 /**
35 * @var GenerationPlan<bool>
36 */
37 private readonly GenerationPlan $plan;
38
39 private readonly Analyzer $analyzer;
40
41 /**
42 * Builds the target over one grammar version and one expansion budget.
43 */
44 public function __construct(private readonly string $grammarVersion, int $maxExpansions = 64)
45 {
46 $this->faker = Factory::create();
47 $this->sql = new MySqlProvider($this->faker, $grammarVersion);
48 $this->planner = $this->sql->planner();
49 $this->plan = GenerationPlan::fromRule('select_stmt')->requiringNonEmpty()->withExpansionBudget($maxExpansions);
50 $this->analyzer = new Analyzer();
51 }
52
53 /**
54 * Writes a generated statement into PHP and checks that it comes back.
55 *
56 * @throws Error When the catalog does not hold exactly the statement that was written
57 */
58 public function __invoke(string $input): void
59 {
60 $plan = (new BytePlanCompiler())->compile($input, $this->planner, $this->plan);
61 $statement = $this->sql->generate($plan);
62 if ($statement === '') {
63 return;
64 }
65
66 $shape = strlen($input) === 0 ? 0 : ord($input[0]) % 6;
67 $catalog = $this->analyzer->analyzeSource(['fuzz.php' => $this->program($shape, $statement)]);
68
69 if ($catalog->count() !== 1) {
70 throw new Error(sprintf(
71 'Expected one statement, got %d; shape=%d; input=%s%sSQL: %s',
72 $catalog->count(),
73 $shape,
74 bin2hex($input),
75 PHP_EOL,
76 $statement,
77 ));
78 }
79
80 $recovered = $catalog->entries()[0]->pattern->text();
81 if ($recovered !== $statement) {
82 throw new Error(sprintf(
83 'Recovered a different statement; shape=%d; input=%s%sWritten: %s%sRecovered: %s',
84 $shape,
85 bin2hex($input),
86 PHP_EOL,
87 $statement,
88 PHP_EOL,
89 $recovered ?? '(unresolved)',
90 ));
91 }
92 }
93
94 /**
95 * A PHP program that issues the statement, written the way the shape says.
96 */
97 public function program(int $shape, string $statement): string
98 {
99 $literal = var_export($statement, true);
100 $head = var_export(substr($statement, 0, (int) (strlen($statement) / 2)), true);
101 $tail = var_export(substr($statement, (int) (strlen($statement) / 2)), true);
102
103 return '<?php ' . match ($shape) {
104 0 => 'function f(\\PDO $d): void { $d->query(' . $literal . '); }',
105 1 => 'function f(\\PDO $d): void { $sql = ' . $literal . '; $d->query($sql); }',
106 2 => 'function f(\\PDO $d): void { $d->query(' . $head . ' . ' . $tail . '); }',
107 3 => 'const Q = ' . $literal . '; function f(\\PDO $d): void { $d->query(Q); }',
108 4 => 'class C { public const Q = ' . $literal . '; } function f(\\PDO $d): void { $d->query(C::Q); }',
109 default => 'function q(): string { return ' . $literal . '; } function f(\\PDO $d): void { $d->query(q()); }',
110 };
111 }
112}
113