packages/sql-faker/tests/Unit/Generation/SqlGeneratorTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\SqlFaker\Generation;
6
7use Closure;
8use Faker\Factory;
9use PHPUnit\Framework\Attributes\CoversClass;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use SqlFaker\Generation\Candidate\ChoiceLexemeGenerator;
13use SqlFaker\Generation\Candidate\FixedLexemeGenerator;
14use SqlFaker\Generation\Candidate\ValueLexemeGenerator;
15use SqlFaker\Generation\Choice\ByteChoices;
16use SqlFaker\Generation\Choice\BytePlanCompiler;
17use SqlFaker\Generation\Choice\PlanBuilder;
18use SqlFaker\Generation\Coverage\CoverageSets;
19use SqlFaker\Generation\Coverage\GenerationTrace;
20use SqlFaker\Generation\Coverage\GeneratorRevision;
21use SqlFaker\Generation\Coverage\GrammarCoverage;
22use SqlFaker\Generation\Coverage\GrammarCoverageInventory;
23use SqlFaker\Generation\Coverage\LexicalObservation;
24use SqlFaker\Generation\Coverage\SequenceObservation;
25use SqlFaker\Generation\Derivation\Completion\CompletionWitness;
26use SqlFaker\Generation\Derivation\Completion\PatternProductions;
27use SqlFaker\Generation\Derivation\CompletionCosts;
28use SqlFaker\Generation\Derivation\CompletionFrontier;
29use SqlFaker\Generation\Derivation\CompletionMemo;
30use SqlFaker\Generation\Derivation\CompletionReduction;
31use SqlFaker\Generation\Derivation\CompletionState;
32use SqlFaker\Generation\Derivation\ConstrainedCompletion;
33use SqlFaker\Generation\Derivation\ConstraintDependencies;
34use SqlFaker\Generation\Derivation\Derivation;
35use SqlFaker\Generation\Derivation\DerivationTrace;
36use SqlFaker\Generation\Derivation\TerminationAnalyzer;
37use SqlFaker\Generation\Derivation\TerminationCost;
38use SqlFaker\Generation\Derivation\TokenGenerator;
39use SqlFaker\Generation\Exception\GenerationException;
40use SqlFaker\Generation\Exception\LexicalException;
41use SqlFaker\Generation\Lexeme\Lexeme;
42use SqlFaker\Generation\Lexeme\LexemeBoundary;
43use SqlFaker\Generation\Lexeme\LexemeCandidates;
44use SqlFaker\Generation\Lexeme\LexemeInput;
45use SqlFaker\Generation\Lexeme\LexemeSequence;
46use SqlFaker\Generation\Lexeme\LexicalGrammar;
47use SqlFaker\Generation\Lexeme\OutputPart;
48use SqlFaker\Generation\Lexeme\ResolvedOutput;
49use SqlFaker\Generation\Lexeme\SpacingConstraint;
50use SqlFaker\Generation\Output\BoundaryCompletion;
51use SqlFaker\Generation\Output\CandidateResolver;
52use SqlFaker\Generation\Output\CombinedSpacingRule;
53use SqlFaker\Generation\Output\ReverseLexemeGenerator;
54use SqlFaker\Generation\Output\SqlSerializer;
55use SqlFaker\Generation\Plan\GenerationPlan;
56use SqlFaker\Generation\Plan\ProductionPattern;
57use SqlFaker\Generation\SqlGenerator;
58use SqlFaker\Generation\Token\ProductionOccurrence;
59use SqlFaker\Generation\Token\RewriteRule;
60use SqlFaker\Generation\Token\TerminalOccurrence;
61use SqlFaker\Generation\Token\TerminalSequence;
62use SqlFaker\Generation\Token\TokenRewriter;
63use SqlFaker\Generation\Value\CharacterDomain;
64use SqlFaker\Generation\Value\ValueChoices;
65use SqlFaker\Grammar\Model\Grammar;
66use SqlFaker\Grammar\Model\NonTerminal;
67use SqlFaker\Grammar\Model\Production;
68use SqlFaker\Grammar\Model\ProductionRule;
69use SqlFaker\Grammar\Model\Terminal;
70
71#[CoversClass(SqlGenerator::class)]
72#[UsesClass(Derivation::class)]
73#[UsesClass(GenerationException::class)]
74#[UsesClass(GenerationPlan::class)]
75#[UsesClass(Grammar::class)]
76#[UsesClass(NonTerminal::class)]
77#[UsesClass(Production::class)]
78#[UsesClass(ProductionRule::class)]
79#[UsesClass(Terminal::class)]
80#[UsesClass(TerminationAnalyzer::class)]
81#[UsesClass(TerminationCost::class)]
82#[UsesClass(LexicalException::class)]
83#[UsesClass(CompletionCosts::class)]
84#[UsesClass(DerivationTrace::class)]
85#[UsesClass(ProductionOccurrence::class)]
86#[UsesClass(TerminalOccurrence::class)]
87#[UsesClass(TerminalSequence::class)]
88#[UsesClass(TokenGenerator::class)]
89#[UsesClass(TokenRewriter::class)]
90#[UsesClass(ProductionPattern::class)]
91#[UsesClass(ChoiceLexemeGenerator::class)]
92#[UsesClass(Lexeme::class)]
93#[UsesClass(LexemeCandidates::class)]
94#[UsesClass(LexemeInput::class)]
95#[UsesClass(LexemeSequence::class)]
96#[UsesClass(ValueLexemeGenerator::class)]
97#[UsesClass(CandidateResolver::class)]
98#[UsesClass(OutputPart::class)]
99#[UsesClass(ResolvedOutput::class)]
100#[UsesClass(ReverseLexemeGenerator::class)]
101#[UsesClass(SqlSerializer::class)]
102#[UsesClass(CombinedSpacingRule::class)]
103#[UsesClass(SpacingConstraint::class)]
104#[UsesClass(ByteChoices::class)]
105#[UsesClass(PlanBuilder::class)]
106#[UsesClass(LexemeBoundary::class)]
107#[UsesClass(GrammarCoverage::class)]
108#[UsesClass(GrammarCoverageInventory::class)]
109#[UsesClass(GeneratorRevision::class)]
110#[UsesClass(GenerationTrace::class)]
111#[UsesClass(CoverageSets::class)]
112#[UsesClass(SequenceObservation::class)]
113#[UsesClass(CompletionState::class)]
114#[UsesClass(CompletionFrontier::class)]
115#[UsesClass(ConstrainedCompletion::class)]
116#[UsesClass(LexicalObservation::class)]
117#[UsesClass(ValueChoices::class)]
118#[UsesClass(BoundaryCompletion::class)]
119#[UsesClass(CompletionMemo::class)]
120#[UsesClass(CompletionReduction::class)]
121#[UsesClass(ConstraintDependencies::class)]
122#[UsesClass(BytePlanCompiler::class)]
123#[UsesClass(PatternProductions::class)]
124#[UsesClass(CompletionWitness::class)]
125#[UsesClass(CharacterDomain::class)]
126#[UsesClass(FixedLexemeGenerator::class)]
127final class SqlGeneratorTest extends TestCase
128{
129    public function testGenerateRecordsACompleteCoverageObservation(): void
130    {
131        $coverage = new GrammarCoverage();
132        $lexical = $this->createMock(LexicalGrammar::class);
133        $lexical->method('version')->willReturn('test-v1');
134        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
135        $lexemePipeline = new ReverseLexemeGenerator(
136            new ChoiceLexemeGenerator(
137                new ValueLexemeGenerator('SELECT', $literalDomain, ['SELECT'], 'fixture', 'fixture-literal'),
138                new ValueLexemeGenerator('DELETE', $literalDomain, ['DELETE'], 'fixture', 'fixture-literal'),
139                new ValueLexemeGenerator('FROM', $literalDomain, ['FROM'], 'fixture', 'fixture-literal'),
140                new ValueLexemeGenerator('missing', $literalDomain, ['missing'], 'fixture', 'fixture-literal'),
141                new ValueLexemeGenerator('1', $literalDomain, ['1'], 'fixture', 'fixture-literal'),
142                new ValueLexemeGenerator('+', $literalDomain, ['+'], 'fixture', 'fixture-literal'),
143                new ValueLexemeGenerator('AS', $literalDomain, ['AS'], 'fixture', 'fixture-literal'),
144                new ValueLexemeGenerator('name', $literalDomain, ['name'], 'fixture', 'fixture-literal'),
145                new ValueLexemeGenerator('CHANGED', $literalDomain, ['CHANGED'], 'fixture', 'fixture-literal'),
146            ),
147            new CandidateResolver(new CombinedSpacingRule()),
148            'fixture',
149        );
150        $lexical->method('resolveSequence')->willReturnCallback(
151            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
152        );
153        $generator = new SqlGenerator(
154            (new Grammar(
155                'stmt',
156                [
157                    'stmt' => new ProductionRule(
158                        'stmt',
159                        [
160                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
161                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
162                        ],
163                    ),
164                    'expr' => new ProductionRule(
165                        'expr',
166                        [
167                            new Production([new Terminal('1')]),
168                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
169                        ],
170                    ),
171                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
172                ],
173            ))->identified(),
174            Factory::create(),
175            $lexical,
176            coverage: $coverage,
177        );
178        $sql = $generator->generate(GenerationPlan::all()->withMaxDepth(1)->withStepBudget()->withExpansionBudget(17));
179        self::assertSame('DELETE FROM missing', $sql);
180        $trace = $coverage->lastGeneration();
181        self::assertNotNull($trace);
182        self::assertSame('stmt', $trace['root']);
183        self::assertSame(['budget' => 17, 'lexicalTarget' => null], $trace['planSummary']);
184        self::assertSame('success', $trace['status']);
185        self::assertCount(1, $trace['attempts']);
186        self::assertSame(0, $trace['attempts'][0]['id']);
187        self::assertSame('committed', $trace['attempts'][0]['status']);
188        self::assertSame(hash('sha256', $sql), $trace['attempts'][0]['sqlHash']);
189        self::assertCount(1, $trace['reachedIds']);
190        self::assertSame($trace['reachedIds'], $trace['emittedIds']);
191        self::assertCount(3, $trace['lexicalEvents']);
192        self::assertCount(3, $trace['spacingEvents']);
193        self::assertSame(1, $coverage->snapshot()['checkpoint']['generationsObservedInRun']);
194        self::assertFalse($coverage->snapshot()['checkpoint']['generationInProgress']);
195    }
196
197    public function testGenerateKeepsRewrittenSourcesReachedWithoutCreditingTheirOutput(): void
198    {
199        $coverage = new GrammarCoverage();
200        $lexical = $this->createMock(LexicalGrammar::class);
201        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
202        $lexemePipeline = new ReverseLexemeGenerator(
203            new ChoiceLexemeGenerator(
204                new ValueLexemeGenerator('SELECT', $literalDomain, ['SELECT'], 'fixture', 'fixture-literal'),
205                new ValueLexemeGenerator('DELETE', $literalDomain, ['DELETE'], 'fixture', 'fixture-literal'),
206                new ValueLexemeGenerator('FROM', $literalDomain, ['FROM'], 'fixture', 'fixture-literal'),
207                new ValueLexemeGenerator('missing', $literalDomain, ['missing'], 'fixture', 'fixture-literal'),
208                new ValueLexemeGenerator('1', $literalDomain, ['1'], 'fixture', 'fixture-literal'),
209                new ValueLexemeGenerator('+', $literalDomain, ['+'], 'fixture', 'fixture-literal'),
210                new ValueLexemeGenerator('AS', $literalDomain, ['AS'], 'fixture', 'fixture-literal'),
211                new ValueLexemeGenerator('name', $literalDomain, ['name'], 'fixture', 'fixture-literal'),
212                new ValueLexemeGenerator('CHANGED', $literalDomain, ['CHANGED'], 'fixture', 'fixture-literal'),
213            ),
214            new CandidateResolver(new CombinedSpacingRule()),
215            'fixture',
216        );
217        $lexical->method('resolveSequence')->willReturnCallback(
218            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
219        );
220        $rule = $this->createMock(RewriteRule::class);
221        $rule->method('rewrite')->willReturnCallback(
222            static fn (TerminalSequence $sequence): TerminalSequence => $sequence->replace(0, 1, [$sequence->terminals[0]->replaced('CHANGED', 'fixture.change')], 'fixture.change'),
223        );
224        $generator = new SqlGenerator(
225            (new Grammar(
226                'stmt',
227                [
228                    'stmt' => new ProductionRule(
229                        'stmt',
230                        [
231                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
232                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
233                        ],
234                    ),
235                    'expr' => new ProductionRule(
236                        'expr',
237                        [
238                            new Production([new Terminal('1')]),
239                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
240                        ],
241                    ),
242                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
243                ],
244            ))->identified(),
245            Factory::create(),
246            $lexical,
247            new TokenRewriter($rule),
248            coverage: $coverage,
249        );
250        self::assertSame('CHANGED FROM missing', $generator->generate(GenerationPlan::all()->withMaxDepth(1)->withStepBudget()));
251        self::assertNotNull($coverage->lastGeneration());
252        self::assertSame(['fixture.change'], $coverage->lastGeneration()['rewrites']);
253        self::assertSame(1, $coverage->snapshot()['current']['reached']);
254        self::assertSame(0, $coverage->snapshot()['current']['emitted']);
255    }
256
257    public function testGenerateClosesCoverageAfterARejectedEmptyLexicalResult(): void
258    {
259        $coverage = new GrammarCoverage();
260        $lexical = $this->createMock(LexicalGrammar::class);
261        $lexical->method('generate')->willReturn('');
262        $generator = new SqlGenerator(
263            (new Grammar(
264                'stmt',
265                [
266                    'stmt' => new ProductionRule(
267                        'stmt',
268                        [
269                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
270                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
271                        ],
272                    ),
273                    'expr' => new ProductionRule(
274                        'expr',
275                        [
276                            new Production([new Terminal('1')]),
277                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
278                        ],
279                    ),
280                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
281                ],
282            ))->identified(),
283            Factory::create(),
284            $lexical,
285            coverage: $coverage,
286        );
287        $this->expectException(GenerationException::class);
288        try {
289            $generator->generate(GenerationPlan::lexical('identifier', [])->requiringNonEmpty());
290        } finally {
291            self::assertNotNull($coverage->lastGeneration());
292            self::assertSame('failed', $coverage->lastGeneration()['status']);
293            self::assertSame('discarded', $coverage->lastGeneration()['attempts'][0]['status']);
294            self::assertSame([], $coverage->lastGeneration()['emittedIds']);
295            self::assertFalse($coverage->snapshot()['checkpoint']['generationInProgress']);
296        }
297    }
298
299    public function testGenerateReplacesGrammarCoverageWithTheLatestLexicalTrace(): void
300    {
301        $coverage = new GrammarCoverage();
302        $lexical = $this->createMock(LexicalGrammar::class);
303        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
304        $lexemePipeline = new ReverseLexemeGenerator(
305            new ChoiceLexemeGenerator(
306                new ValueLexemeGenerator('SELECT', $literalDomain, ['SELECT'], 'fixture', 'fixture-literal'),
307                new ValueLexemeGenerator('DELETE', $literalDomain, ['DELETE'], 'fixture', 'fixture-literal'),
308                new ValueLexemeGenerator('FROM', $literalDomain, ['FROM'], 'fixture', 'fixture-literal'),
309                new ValueLexemeGenerator('missing', $literalDomain, ['missing'], 'fixture', 'fixture-literal'),
310                new ValueLexemeGenerator('1', $literalDomain, ['1'], 'fixture', 'fixture-literal'),
311                new ValueLexemeGenerator('+', $literalDomain, ['+'], 'fixture', 'fixture-literal'),
312                new ValueLexemeGenerator('AS', $literalDomain, ['AS'], 'fixture', 'fixture-literal'),
313                new ValueLexemeGenerator('name', $literalDomain, ['name'], 'fixture', 'fixture-literal'),
314                new ValueLexemeGenerator('CHANGED', $literalDomain, ['CHANGED'], 'fixture', 'fixture-literal'),
315            ),
316            new CandidateResolver(new CombinedSpacingRule()),
317            'fixture',
318        );
319        $lexical->method('resolveSequence')->willReturnCallback(
320            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
321        );
322        $lexical->method('generate')->willReturn('name');
323        $generator = new SqlGenerator(
324            (new Grammar(
325                'stmt',
326                [
327                    'stmt' => new ProductionRule(
328                        'stmt',
329                        [
330                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
331                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
332                        ],
333                    ),
334                    'expr' => new ProductionRule(
335                        'expr',
336                        [
337                            new Production([new Terminal('1')]),
338                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
339                        ],
340                    ),
341                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
342                ],
343            ))->identified(),
344            Factory::create(),
345            $lexical,
346            coverage: $coverage,
347        );
348        $generator->generate(GenerationPlan::all()->withMaxDepth(1)->withStepBudget());
349        self::assertSame('name', $generator->generate(GenerationPlan::lexical('identifier', [])));
350        self::assertNotNull($coverage->lastGeneration());
351        self::assertSame(2, $coverage->lastGeneration()['generationId']);
352        self::assertSame(['budget' => null, 'lexicalTarget' => 'identifier'], $coverage->lastGeneration()['planSummary']);
353        self::assertSame(['identifier'], $coverage->lastGeneration()['lexicalEvents']);
354        self::assertSame([], $coverage->lastGeneration()['reachedIds']);
355        self::assertSame([], $coverage->lastGeneration()['emittedIds']);
356        self::assertSame(hash('sha256', 'name'), $coverage->lastGeneration()['attempts'][0]['sqlHash']);
357        self::assertSame('success', $coverage->lastGeneration()['status']);
358        self::assertFalse($coverage->snapshot()['checkpoint']['generationInProgress']);
359    }
360
361    public function testRealizeOffersTheWholeZeroBasedCandidateRange(): void
362    {
363        $faker = Factory::create();
364        $faker->seed(19);
365        $grammar = new Grammar('stmt', ['stmt' => new ProductionRule('stmt', [new Production([new Terminal('T')])])]);
366        $lexical = $this->createMock(LexicalGrammar::class);
367        $lexical->method('resolveSequence')->willReturnCallback(
368            /**
369             * @param GenerationPlan<bool> $plan
370             * @param Closure(int): int $choose
371             */
372            static function (TerminalSequence $sequence, GenerationPlan $plan, Closure $choose) {
373                $choices = array_map($choose, array_fill(0, 32, 2));
374                self::assertContains(0, $choices);
375                self::assertContains(1, $choices);
376                self::assertContainsOnly('int', $choices);
377                self::assertSame(0, min($choices));
378                self::assertSame(1, max($choices));
379                self::assertSame(0, $choose(1));
380                return (new ReverseLexemeGenerator(
381                    new FixedLexemeGenerator('T', 'fixture', 'fixture-literal'),
382                    new CandidateResolver(new CombinedSpacingRule()),
383                    'fixture',
384                ))->generate(TerminalSequence::fromNames(['T']), null, static fn (int $count): int => 0);
385            },
386        );
387        self::assertSame('T', (new SqlGenerator($grammar, $faker, $lexical))->realize('stmt', GenerationPlan::all()));
388    }
389
390    public function testGenerateReusesCompletionAnalysisAcrossDifferentPlans(): void
391    {
392        $grammar = new Grammar(
393            'first',
394            [
395                'first' => new ProductionRule('first', [new Production([new Terminal('T')])]),
396                'second' => new ProductionRule('second', [new Production([new Terminal('U')])]),
397            ],
398        );
399        $lexer = $this->createMock(LexicalGrammar::class);
400        $observations = [];
401        $lexer->method('isNonOutput')->willReturnCallback(
402            static function (string $terminal) use (&$observations): bool {
403                $observations[] = $terminal;
404                return false;
405            },
406        );
407        $lexer->method('resolveSequence')->willReturnCallback(
408            static fn (TerminalSequence $sequence) => (new ReverseLexemeGenerator(
409                new FixedLexemeGenerator(implode(' ', $sequence->names()), 'fixture', 'fixture-literal'),
410                new CandidateResolver(new CombinedSpacingRule()),
411                'fixture',
412            ))->generate(TerminalSequence::fromNames([implode(' ', $sequence->names())]), null, static fn (int $count): int => 0),
413        );
414        $generator = new SqlGenerator($grammar, Factory::create(), $lexer);
415        self::assertSame('T', $generator->generate(GenerationPlan::all()));
416        self::assertSame(['T', 'U'], $observations);
417        self::assertSame('U', $generator->generate(GenerationPlan::fromRule('second')));
418        self::assertSame(['T', 'U'], $observations);
419        self::assertSame('T', $generator->generate(GenerationPlan::all()));
420        self::assertSame(['T', 'U'], $observations);
421    }
422
423    public function testGenerateUsesTheGrammarEntryPointWithoutDialectKnowledge(): void
424    {
425        $grammar = new Grammar('custom_entry', ['custom_entry' => new ProductionRule('custom_entry', [new Production([new Terminal('CUSTOM')])])]);
426        $plan = GenerationPlan::all();
427        $lexer = $this->createMock(LexicalGrammar::class);
428        $lexer->expects(self::once())->method('resolveSequence')->with(self::callback(static fn (TerminalSequence $sequence): bool => $sequence->names() === ['CUSTOM']), $plan)->willReturn(
429            (new ReverseLexemeGenerator(
430                new FixedLexemeGenerator('custom sql', 'fixture', 'fixture-literal'),
431                new CandidateResolver(new CombinedSpacingRule()),
432                'fixture',
433            ))->generate(TerminalSequence::fromNames(['custom sql']), null, static fn (int $count): int => 0),
434        );
435        $generator = new SqlGenerator($grammar, Factory::create(), $lexer);
436        self::assertSame('custom sql', $generator->generate($plan));
437    }
438
439    public function testGenerateUsesExplicitRulesAndSuppliedParserSemantics(): void
440    {
441        $grammar = new Grammar('other', ['selected' => new ProductionRule('selected', [new Production([new Terminal('RAW')])])]);
442        $plan = GenerationPlan::fromRule('selected')->requiringNonEmpty();
443        $lexer = $this->createMock(LexicalGrammar::class);
444        $lexer->expects(self::once())->method('resolveSequence')->with(self::callback(static fn (TerminalSequence $sequence): bool => $sequence->names() === ['NORMALIZED', 'RAW']), $plan)->willReturn(
445            (new ReverseLexemeGenerator(
446                new FixedLexemeGenerator('normalized', 'fixture', 'fixture-literal'),
447                new CandidateResolver(new CombinedSpacingRule()),
448                'fixture',
449            ))->generate(TerminalSequence::fromNames(['normalized']), null, static fn (int $count): int => 0),
450        );
451        $rule = $this->createMock(RewriteRule::class);
452        $rule->method('rewrite')->willReturnCallback(
453            static fn (TerminalSequence $sequence): TerminalSequence => $sequence->replace(0, 0, [$sequence->inserted('NORMALIZED', $sequence->terminals[0], 'test.rule')], 'test.rule'),
454        );
455        $generator = new SqlGenerator($grammar, Factory::create(), $lexer, new TokenRewriter($rule));
456        self::assertSame('normalized', $generator->generate($plan));
457    }
458
459    public function testGenerateUsesTheSuppliedVersionSpecificRuleResolver(): void
460    {
461        $grammar = new Grammar('other', ['old_rule' => new ProductionRule('old_rule', [new Production([new Terminal('TOKEN')])])]);
462        $lexer = $this->createMock(LexicalGrammar::class);
463        $lexer->expects(self::once())->method('resolveSequence')->with(self::callback(static fn (TerminalSequence $sequence): bool => $sequence->names() === ['TOKEN']))->willReturn(
464            (new ReverseLexemeGenerator(
465                new FixedLexemeGenerator('token', 'fixture', 'fixture-literal'),
466                new CandidateResolver(new CombinedSpacingRule()),
467                'fixture',
468            ))->generate(TerminalSequence::fromNames(['token']), null, static fn (int $count): int => 0),
469        );
470        $generator = new SqlGenerator(
471            $grammar,
472            Factory::create(),
473            $lexer,
474            null,
475            static fn (?string $rule): string => $rule === 'new_rule' ? 'old_rule' : 'missing',
476        );
477        self::assertSame('token', $generator->generate(GenerationPlan::fromRule('new_rule')));
478    }
479
480    public function testGenerateLexicalPlansBypassGrammarDerivation(): void
481    {
482        $plan = GenerationPlan::lexical('identifier', []);
483        $lexer = $this->createMock(LexicalGrammar::class);
484        $lexer->expects(self::once())->method('generate')->with($plan)->willReturn('name');
485        $lexer->expects(self::never())->method('resolveSequence');
486        $generator = new SqlGenerator(new Grammar('missing', []), Factory::create(), $lexer);
487        self::assertSame('name', $generator->generate($plan));
488    }
489
490    public function testGeneratePreservesTheFirstLexicalFailureWithoutRetrying(): void
491    {
492        $grammar = new Grammar('stmt', ['stmt' => new ProductionRule('stmt', [new Production([])])]);
493        $lexer = $this->createMock(LexicalGrammar::class);
494        $failure = new LexicalException('last failure');
495        $lexer->expects(self::once())->method('resolveSequence')->willThrowException($failure);
496        $generator = new SqlGenerator($grammar, Factory::create(), $lexer);
497        $this->expectExceptionObject($failure);
498        $generator->generate(GenerationPlan::all());
499    }
500
501    public function testGenerateAllowsEmptyOutputWhenThePlanAllowsIt(): void
502    {
503        $grammar = new Grammar('stmt', ['stmt' => new ProductionRule('stmt', [new Production([])])]);
504        $lexer = $this->createMock(LexicalGrammar::class);
505        $lexer->expects(self::once())->method('resolveSequence')->willReturn(
506            (new ReverseLexemeGenerator(
507                new FixedLexemeGenerator('', 'fixture', 'fixture-literal'),
508                new CandidateResolver(new CombinedSpacingRule()),
509                'fixture',
510            ))->generate(TerminalSequence::fromNames(['']), null, static fn (int $count): int => 0),
511        );
512        $generator = new SqlGenerator($grammar, Factory::create(), $lexer);
513        self::assertSame('', $generator->generate(GenerationPlan::all()));
514    }
515
516    public function testGenerateRejectsUnexpectedEmptyOutputWithoutRetrying(): void
517    {
518        $grammar = new Grammar('stmt', ['stmt' => new ProductionRule('stmt', [new Production([new Terminal('T')])])]);
519        $lexer = $this->createMock(LexicalGrammar::class);
520        $lexer->method('version')->willReturn('custom-1');
521        $lexer->expects(self::once())->method('resolveSequence')->willReturn(
522            (new ReverseLexemeGenerator(
523                new FixedLexemeGenerator('', 'fixture', 'fixture-literal'),
524                new CandidateResolver(new CombinedSpacingRule()),
525                'fixture',
526            ))->generate(TerminalSequence::fromNames(['']), null, static fn (int $count): int => 0),
527        );
528        $generator = new SqlGenerator($grammar, Factory::create(), $lexer);
529        $this->expectException(GenerationException::class);
530        $this->expectExceptionMessage('custom-1 generation plan requires non-empty output.');
531        $generator->generate(GenerationPlan::all()->requiringNonEmpty());
532    }
533
534    public function testGenerateClearsPreviousGrammarTraceBeforeALexicalPlan(): void
535    {
536        $grammar = new Grammar('stmt', ['stmt' => new ProductionRule('stmt', [new Production([new Terminal('TOKEN')])])]);
537        $lexer = $this->createMock(LexicalGrammar::class);
538        $lexer->method('resolveSequence')->willReturn(
539            (new ReverseLexemeGenerator(
540                new FixedLexemeGenerator('token', 'fixture', 'fixture-literal'),
541                new CandidateResolver(new CombinedSpacingRule()),
542                'fixture',
543            ))->generate(TerminalSequence::fromNames(['token']), null, static fn (int $count): int => 0),
544        );
545        $lexer->method('generate')->willReturn('name');
546        $generator = new SqlGenerator($grammar, Factory::create(), $lexer);
547        self::assertSame('token', $generator->generate(GenerationPlan::all()));
548        self::assertSame(['TOKEN'], $generator->lastSequence?->names());
549        self::assertSame('name', $generator->generate(GenerationPlan::lexical('identifier', [])));
550        self::assertNull($generator->lastSequence);
551    }
552
553    public function testGenerateReportsOnlyTheLatestRewrittenDerivation(): void
554    {
555        $grammar = new Grammar(
556            'first',
557            [
558                'first' => new ProductionRule('first', [new Production([new Terminal('A')])]),
559                'second' => new ProductionRule('second', [new Production([new Terminal('B')])]),
560            ],
561        );
562        $lexer = $this->createMock(LexicalGrammar::class);
563        $lexer->method('resolveSequence')->willReturn(
564            (new ReverseLexemeGenerator(
565                new FixedLexemeGenerator('output', 'fixture', 'fixture-literal'),
566                new CandidateResolver(new CombinedSpacingRule()),
567                'fixture',
568            ))->generate(TerminalSequence::fromNames(['output']), null, static fn (int $count): int => 0),
569        );
570        $rule = $this->createMock(RewriteRule::class);
571        $rule->method('rewrite')->willReturnCallback(static fn (TerminalSequence $sequence): TerminalSequence => $sequence->replace(0, 0, [], 'observed'));
572        $generator = new SqlGenerator($grammar, Factory::create(), $lexer, new TokenRewriter($rule));
573        $generator->generate(GenerationPlan::all());
574        $first = $generator->lastSequence;
575        $generator->generate(GenerationPlan::fromRule('second'));
576        self::assertSame(['A'], $first?->names());
577        self::assertSame(['B'], $generator->lastSequence->names());
578        self::assertSame(['observed'], $generator->lastSequence->rewrites);
579        self::assertSame('second', $generator->lastSequence->productions[0]->rule);
580    }
581
582    public function testPlannerBindsTheSameGrammarEntryAndFreezesEveryChoice(): void
583    {
584        $lexical = $this->createMock(LexicalGrammar::class);
585        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
586        $lexemePipeline = new ReverseLexemeGenerator(
587            new ChoiceLexemeGenerator(
588                new ValueLexemeGenerator('SELECT', $literalDomain, ['SELECT'], 'fixture', 'fixture-literal'),
589                new ValueLexemeGenerator('DELETE', $literalDomain, ['DELETE'], 'fixture', 'fixture-literal'),
590                new ValueLexemeGenerator('FROM', $literalDomain, ['FROM'], 'fixture', 'fixture-literal'),
591                new ValueLexemeGenerator('missing', $literalDomain, ['missing'], 'fixture', 'fixture-literal'),
592                new ValueLexemeGenerator('1', $literalDomain, ['1'], 'fixture', 'fixture-literal'),
593                new ValueLexemeGenerator('+', $literalDomain, ['+'], 'fixture', 'fixture-literal'),
594                new ValueLexemeGenerator('AS', $literalDomain, ['AS'], 'fixture', 'fixture-literal'),
595                new ValueLexemeGenerator('name', $literalDomain, ['name'], 'fixture', 'fixture-literal'),
596                new ValueLexemeGenerator('CHANGED', $literalDomain, ['CHANGED'], 'fixture', 'fixture-literal'),
597            ),
598            new CandidateResolver(new CombinedSpacingRule()),
599            'fixture',
600        );
601        $lexical->method('resolveSequence')->willReturnCallback(
602            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
603        );
604        $generator = new SqlGenerator(
605            (new Grammar(
606                'stmt',
607                [
608                    'stmt' => new ProductionRule(
609                        'stmt',
610                        [
611                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
612                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
613                        ],
614                    ),
615                    'expr' => new ProductionRule(
616                        'expr',
617                        [
618                            new Production([new Terminal('1')]),
619                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
620                        ],
621                    ),
622                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
623                ],
624            ))->identified(),
625            Factory::create(),
626            $lexical,
627        );
628        $plan = (new BytePlanCompiler())->compile('', $generator->planner());
629        self::assertSame('stmt', $generator->planner()->root($plan));
630        self::assertSame($generator->generate($plan), $generator->generate($plan));
631    }
632
633    public function testRealizeRetainsTheActualTerminalAndLexicalTrace(): void
634    {
635        $lexical = $this->createMock(LexicalGrammar::class);
636        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
637        $lexemePipeline = new ReverseLexemeGenerator(
638            new ChoiceLexemeGenerator(
639                new ValueLexemeGenerator('SELECT', $literalDomain, ['SELECT'], 'fixture', 'fixture-literal'),
640                new ValueLexemeGenerator('DELETE', $literalDomain, ['DELETE'], 'fixture', 'fixture-literal'),
641                new ValueLexemeGenerator('FROM', $literalDomain, ['FROM'], 'fixture', 'fixture-literal'),
642                new ValueLexemeGenerator('missing', $literalDomain, ['missing'], 'fixture', 'fixture-literal'),
643                new ValueLexemeGenerator('1', $literalDomain, ['1'], 'fixture', 'fixture-literal'),
644                new ValueLexemeGenerator('+', $literalDomain, ['+'], 'fixture', 'fixture-literal'),
645                new ValueLexemeGenerator('AS', $literalDomain, ['AS'], 'fixture', 'fixture-literal'),
646                new ValueLexemeGenerator('name', $literalDomain, ['name'], 'fixture', 'fixture-literal'),
647                new ValueLexemeGenerator('CHANGED', $literalDomain, ['CHANGED'], 'fixture', 'fixture-literal'),
648            ),
649            new CandidateResolver(new CombinedSpacingRule()),
650            'fixture',
651        );
652        $lexical->method('resolveSequence')->willReturnCallback(
653            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
654        );
655        $generator = new SqlGenerator(
656            (new Grammar(
657                'stmt',
658                [
659                    'stmt' => new ProductionRule(
660                        'stmt',
661                        [
662                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
663                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
664                        ],
665                    ),
666                    'expr' => new ProductionRule(
667                        'expr',
668                        [
669                            new Production([new Terminal('1')]),
670                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
671                        ],
672                    ),
673                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
674                ],
675            ))->identified(),
676            Factory::create(),
677            $lexical,
678        );
679        self::assertSame('DELETE FROM missing', $generator->realize('stmt', GenerationPlan::all()->withMaxDepth(1)->withStepBudget()));
680        self::assertNotNull($generator->lastSequence);
681        self::assertSame(['DELETE', 'FROM', 'missing'], $generator->lastSequence->names());
682        self::assertNotNull($generator->lastOutput);
683        self::assertCount(3, $generator->lastOutput->candidates);
684    }
685}
686