packages/sql-fixture/fuzz/Target/CreateTableTarget.php

1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Target;
6
7use Error;
8use Faker\Factory;
9use Faker\Generator;
10use SqlFaker\Generation\Choice\BytePlanCompiler;
11use SqlFaker\Generation\Choice\PlanBuilder;
12use SqlFaker\Generation\Plan\GenerationPlan;
13use SqlFaker\MySqlProvider;
14use SqlFixture\FixtureGenerator;
15use SqlFixture\Platform\MySql\MySqlSchemaParser;
16use SqlFixture\Schema\SchemaParseException;
17
18/**
19 * Mutates SQL structure and lexical choices, then checks the accepted-schema row contract.
20 */
21final class CreateTableTarget
22{
23    private readonly Generator $faker;
24    private readonly MySqlProvider $sqlProvider;
25    private readonly PlanBuilder $planner;
26
27    /**
28     * @var GenerationPlan<bool>
29     */
30    private readonly GenerationPlan $constraints;
31
32    /**
33     * Bounds grammar expansion while keeping raw input choices available to the planner.
34     */
35    public function __construct(private readonly string $grammarVersion, int $maxExpansions = 128)
36    {
37        $this->faker = Factory::create();
38        $this->sqlProvider = new MySqlProvider($this->faker, $grammarVersion);
39        $this->planner = $this->sqlProvider->planner();
40        $this->constraints = GenerationPlan::fromRule('create_table_stmt')->requiringNonEmpty()->withExpansionBudget($maxExpansions);
41    }
42
43    /**
44     * Accepted schemas must generate exactly their writable columns, including explicit overrides.
45     *
46     * @throws Error When generated fixture columns differ from the parsed schema
47     */
48    public function __invoke(string $input): void
49    {
50        $plan = (new BytePlanCompiler())->compile($input, $this->planner, $this->constraints);
51        $sql = $this->sqlProvider->generate($plan);
52        try {
53            $schema = (new MySqlSchemaParser())->parse($sql);
54        } catch (SchemaParseException) {
55            return;
56        }
57        $this->faker->seed(crc32(str_pad($input, 4, "\0")));
58        $generator = new FixtureGenerator($this->faker);
59        $row = $generator->generate($schema);
60        $writable = array_filter($schema->columns, static fn ($column): bool => !$column->autoIncrement && !$column->generated);
61        if (array_keys($row) !== array_keys($writable)) {
62            throw new Error("Writable column mismatch; grammar={$this->grammarVersion}; input=" . bin2hex($input) . "\nSQL: " . $sql);
63        }
64        $overridden = $generator->generate($schema, $row);
65        if ($overridden !== $row) {
66            throw new Error('Override preservation mismatch; input=' . bin2hex($input) . "\nSQL: " . $sql);
67        }
68    }
69}
70