packages/sql-faker/src/Generation/SqlGenerator.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlFaker\Generation;
6
7use Closure;
8use Faker\Generator;
9use SqlFaker\Generation\Choice\PlanBuilder;
10use SqlFaker\Generation\Coverage\GeneratorRevision;
11use SqlFaker\Generation\Coverage\GrammarCoverage;
12use SqlFaker\Generation\Coverage\GrammarCoverageInventory;
13use SqlFaker\Generation\Coverage\SequenceObservation;
14use SqlFaker\Generation\Derivation\TokenGenerator;
15use SqlFaker\Generation\Exception\GenerationException;
16use SqlFaker\Generation\Exception\LexicalException;
17use SqlFaker\Generation\Lexeme\LexicalGrammar;
18use SqlFaker\Generation\Lexeme\ResolvedOutput;
19use SqlFaker\Generation\Output\SqlSerializer;
20use SqlFaker\Generation\Plan\GenerationPlan;
21use SqlFaker\Generation\Token\TerminalSequence;
22use SqlFaker\Generation\Token\TokenRewriter;
23use SqlFaker\Grammar\Model\Grammar;
24
25/**
26 * Derives terminals, rewrites structural constraints, and realizes lexemes once.
27 * Dialect definitions supply the syntax and boundary rules; no completed SQL is retried or repaired.
28 *
29 * @visibility root
30 */
31final class SqlGenerator
32{
33    private ?TokenGenerator $tokens = null;
34
35    /**
36     * The latest terminal sequence retains both the original grammar choices and their rewrites.
37     */
38    public ?TerminalSequence $lastSequence = null;
39
40    /**
41     * Selected lexical candidates and resolved boundary rules from the latest generation.
42     */
43    public ?ResolvedOutput $lastOutput = null;
44
45    /**
46     * Binds grammar, lexical definitions and structural rules before generation starts.
47     * @param (Closure(string|null): string)|null $startSymbol Resolves explicitly requested release aliases
48     */
49    public function __construct(
50        private readonly Grammar $grammar,
51        private readonly Generator $faker,
52        private readonly LexicalGrammar $lexicalGrammar,
53        private readonly ?TokenRewriter $rewriter = null,
54        private readonly ?Closure $startSymbol = null,
55        private readonly ?GrammarCoverage $coverage = null,
56        ?Grammar $original = null,
57    ) {
58        $coverage?->register(new GrammarCoverageInventory($grammar, $grammar->startSymbol, $lexicalGrammar->version(), $original), GeneratorRevision::current());
59    }
60
61    /**
62     * Reuses the same grammar, rewrite rules and lexical definitions to compile plans.
63     */
64    public function planner(): PlanBuilder
65    {
66        return new PlanBuilder($this->grammar, $this->lexicalGrammar, $this->rewriter, $this->startSymbol);
67    }
68
69    /**
70     * Generates once through the declared stages; candidate absence remains an error.
71     * @template TRequiresNonEmpty of bool
72     * @param GenerationPlan<TRequiresNonEmpty> $plan
73     * @return (TRequiresNonEmpty is true ? non-empty-string : string)
74     * @throws GenerationException When the grammar or plan cannot produce the requested output
75     * @throws LexicalException When no applicable lexical realization exists
76     */
77    public function generate(GenerationPlan $plan): string
78    {
79        $this->lastSequence = null;
80        $this->lastOutput = null;
81        $requested = $plan->startRule();
82        $root = $requested ?? $this->grammar->startSymbol;
83        $this->coverage?->beginGeneration($root, ['budget' => $plan->expansionBudget(), 'lexicalTarget' => $plan->lexicalTarget()]);
84        $this->coverage?->beginAttempt(0);
85        try {
86            $root = $requested !== null && $this->startSymbol !== null ? ($this->startSymbol)($requested) : $root;
87            $sql = $plan->lexicalTarget() !== null ? $this->lexicalGrammar->generate($plan) : $this->realize($root, $plan);
88            if ($sql === '' && $plan->requiresNonEmpty()) {
89                throw GenerationException::planRequiresNonEmptyOutput($this->lexicalGrammar->version());
90            }
91            $this->coverage?->commitAttempt(
92                hash('sha256', $sql),
93                $this->lastSequence === null ? [] : (new SequenceObservation())->preserved($this->lastSequence)
94            );
95            return $sql;
96        } finally {
97            $this->coverage?->endGeneration();
98        }
99    }
100
101    /**
102     * Derives and realizes once, retaining source selection independently of transformed output.
103     * @param GenerationPlan<bool> $plan
104     * @throws GenerationException When derivation cannot complete
105     * @throws LexicalException When a terminal has no compatible realization
106     */
107    public function realize(string $root, GenerationPlan $plan): string
108    {
109        $tokens = ($this->tokens ??= new TokenGenerator($this->grammar, $this->faker, $this->lexicalGrammar->isNonOutput(...)))->generate($root, $plan);
110        $this->lastSequence = $this->rewriter?->rewrite($tokens) ?? $tokens;
111        $this->coverage?->recordSequence($this->lastSequence);
112        $this->lastOutput = $this->lexicalGrammar->resolveSequence($this->lastSequence, $plan, fn (int $count): int => $this->faker->numberBetween(0, $count - 1));
113        $this->coverage?->recordOutput($this->lastOutput);
114        return (new SqlSerializer())->serialize($this->lastOutput->pieces());
115    }
116}
117