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