packages/sql-faker/tests/Unit/Generation/Choice/PlanBuilderTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\Generation\Choice;
6
7use Closure;
8use Faker\Factory;
9use PHPUnit\Framework\Attributes\CoversClass;
10use PHPUnit\Framework\Attributes\DataProvider;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use SqlFaker\Generation\Candidate\ChoiceLexemeGenerator;
14use SqlFaker\Generation\Candidate\ValueLexemeGenerator;
15use SqlFaker\Generation\Choice\ByteChoices;
16use SqlFaker\Generation\Choice\BytePlanCompiler;
17use SqlFaker\Generation\Choice\PlanBuilder;
18use SqlFaker\Generation\Derivation\Completion\CompletionWitness;
19use SqlFaker\Generation\Derivation\Completion\PatternProductions;
20use SqlFaker\Generation\Derivation\CompletionCosts;
21use SqlFaker\Generation\Derivation\CompletionFrontier;
22use SqlFaker\Generation\Derivation\CompletionMemo;
23use SqlFaker\Generation\Derivation\CompletionReduction;
24use SqlFaker\Generation\Derivation\CompletionState;
25use SqlFaker\Generation\Derivation\ConstrainedCompletion;
26use SqlFaker\Generation\Derivation\ConstraintDependencies;
27use SqlFaker\Generation\Derivation\Derivation;
28use SqlFaker\Generation\Derivation\DerivationNode;
29use SqlFaker\Generation\Derivation\DerivationTrace;
30use SqlFaker\Generation\Derivation\TerminationAnalyzer;
31use SqlFaker\Generation\Derivation\TerminationCost;
32use SqlFaker\Generation\Derivation\TokenGenerator;
33use SqlFaker\Generation\Exception\GenerationException;
34use SqlFaker\Generation\Exception\LexicalException;
35use SqlFaker\Generation\Lexeme\Lexeme;
36use SqlFaker\Generation\Lexeme\LexemeBoundary;
37use SqlFaker\Generation\Lexeme\LexemeCandidates;
38use SqlFaker\Generation\Lexeme\LexemeInput;
39use SqlFaker\Generation\Lexeme\LexemeSequence;
40use SqlFaker\Generation\Lexeme\LexicalGrammar;
41use SqlFaker\Generation\Lexeme\OutputPart;
42use SqlFaker\Generation\Lexeme\ResolvedOutput;
43use SqlFaker\Generation\Lexeme\SpacingConstraint;
44use SqlFaker\Generation\Output\BoundaryCompletion;
45use SqlFaker\Generation\Output\CandidateResolver;
46use SqlFaker\Generation\Output\CombinedSpacingRule;
47use SqlFaker\Generation\Output\ReverseLexemeGenerator;
48use SqlFaker\Generation\Output\SqlSerializer;
49use SqlFaker\Generation\Plan\GenerationPlan;
50use SqlFaker\Generation\Plan\LexemeConstraint;
51use SqlFaker\Generation\Plan\ProductionPattern;
52use SqlFaker\Generation\Plan\RulePlan;
53use SqlFaker\Generation\SqlGenerator;
54use SqlFaker\Generation\Token\ProductionOccurrence;
55use SqlFaker\Generation\Token\TerminalMappingRule;
56use SqlFaker\Generation\Token\TerminalOccurrence;
57use SqlFaker\Generation\Token\TerminalSequence;
58use SqlFaker\Generation\Token\TokenRewriter;
59use SqlFaker\Generation\Value\CharacterDomain;
60use SqlFaker\Generation\Value\ValueChoices;
61use SqlFaker\Grammar\Model\Grammar;
62use SqlFaker\Grammar\Model\NonTerminal;
63use SqlFaker\Grammar\Model\Production;
64use SqlFaker\Grammar\Model\ProductionRule;
65use SqlFaker\Grammar\Model\Terminal;
66
67#[CoversClass(PlanBuilder::class)]
68#[UsesClass(CompletionCosts::class)]
69#[UsesClass(GenerationPlan::class)]
70#[UsesClass(ProductionPattern::class)]
71#[UsesClass(Derivation::class)]
72#[UsesClass(TerminationAnalyzer::class)]
73#[UsesClass(TerminationCost::class)]
74#[UsesClass(DerivationNode::class)]
75#[UsesClass(ByteChoices::class)]
76#[UsesClass(Grammar::class)]
77#[UsesClass(NonTerminal::class)]
78#[UsesClass(Production::class)]
79#[UsesClass(ProductionRule::class)]
80#[UsesClass(Terminal::class)]
81#[UsesClass(GenerationException::class)]
82#[UsesClass(LexicalException::class)]
83#[UsesClass(SqlGenerator::class)]
84#[UsesClass(DerivationTrace::class)]
85#[UsesClass(ChoiceLexemeGenerator::class)]
86#[UsesClass(Lexeme::class)]
87#[UsesClass(LexemeCandidates::class)]
88#[UsesClass(LexemeInput::class)]
89#[UsesClass(LexemeSequence::class)]
90#[UsesClass(ValueLexemeGenerator::class)]
91#[UsesClass(CandidateResolver::class)]
92#[UsesClass(OutputPart::class)]
93#[UsesClass(ResolvedOutput::class)]
94#[UsesClass(ReverseLexemeGenerator::class)]
95#[UsesClass(SqlSerializer::class)]
96#[UsesClass(CombinedSpacingRule::class)]
97#[UsesClass(LexemeBoundary::class)]
98#[UsesClass(SpacingConstraint::class)]
99#[UsesClass(ProductionOccurrence::class)]
100#[UsesClass(TerminalMappingRule::class)]
101#[UsesClass(TerminalOccurrence::class)]
102#[UsesClass(TerminalSequence::class)]
103#[UsesClass(TokenGenerator::class)]
104#[UsesClass(TokenRewriter::class)]
105#[UsesClass(CompletionState::class)]
106#[UsesClass(CompletionFrontier::class)]
107#[UsesClass(ConstrainedCompletion::class)]
108#[UsesClass(ValueChoices::class)]
109#[UsesClass(BoundaryCompletion::class)]
110#[UsesClass(CompletionMemo::class)]
111#[UsesClass(CompletionReduction::class)]
112#[UsesClass(ConstraintDependencies::class)]
113#[UsesClass(BytePlanCompiler::class)]
114#[UsesClass(PatternProductions::class)]
115#[UsesClass(CompletionWitness::class)]
116#[UsesClass(CharacterDomain::class)]
117#[UsesClass(RulePlan::class)]
118#[UsesClass(LexemeConstraint::class)]
119#[UsesClass(\SqlFaker\Generation\Plan\Compilation\GrammarCompiler::class)]
120#[UsesClass(\SqlFaker\Generation\Plan\Compilation\Scope::class)]
121#[UsesClass(\SqlFaker\Generation\Plan\Compilation\PreparedGrammar::class)]
122#[UsesClass(\SqlFaker\Generation\Plan\Compilation\ScopedGeneration::class)]
123final class PlanBuilderTest extends TestCase
124{
125    public function testRootResolvesOnlyAnExplicitReleaseAlias(): void
126    {
127        $builder = new PlanBuilder(
128            (new Grammar(
129                'stmt',
130                [
131                    'stmt' => new ProductionRule(
132                        'stmt',
133                        [
134                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
135                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
136                        ],
137                    ),
138                    'expr' => new ProductionRule(
139                        'expr',
140                        [
141                            new Production([new Terminal('1')]),
142                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
143                        ],
144                    ),
145                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
146                ],
147            ))->identified(),
148            $this->createMock(LexicalGrammar::class),
149            startSymbol: static fn (?string $requested): string => 'expr',
150        );
151        self::assertSame('stmt', $builder->root(GenerationPlan::all()));
152        self::assertSame('expr', $builder->root(GenerationPlan::fromRule('alias')));
153    }
154
155    public function testMinimumExpansionsKeepsTheNonEmptyRequirementSeparateFromNullability(): void
156    {
157        $grammar = new Grammar(
158            'root',
159            [
160                'root' => new ProductionRule('root', [new Production([]), new Production([new NonTerminal('leaf')])]),
161                'leaf' => new ProductionRule('leaf', [new Production([new Terminal('T')])]),
162            ],
163        );
164        $builder = new PlanBuilder($grammar, $this->createMock(LexicalGrammar::class));
165        self::assertSame(1, $builder->minimumExpansions(GenerationPlan::all()));
166        self::assertSame(2, $builder->minimumExpansions(GenerationPlan::all()->requiringNonEmpty()));
167    }
168
169    public function testMinimumExpansionsIncludesTheSelectedRootAlternativeBeforeAllocatingInputBudget(): void
170    {
171        $grammar = new Grammar(
172            'root',
173            [
174                'root' => new ProductionRule('root', [new Production([new Terminal('T')]), new Production([new NonTerminal('leaf')])]),
175                'leaf' => new ProductionRule('leaf', [new Production([new NonTerminal('end')])]),
176                'end' => new ProductionRule('end', [new Production([new Terminal('U')])]),
177            ],
178        );
179        $lexical = $this->createMock(LexicalGrammar::class);
180        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
181        $lexemePipeline = new ReverseLexemeGenerator(
182            new ChoiceLexemeGenerator(
183                new ValueLexemeGenerator('T', $literalDomain, ['T'], 'fixture', 'fixture-literal'),
184                new ValueLexemeGenerator('U', $literalDomain, ['U'], 'fixture', 'fixture-literal'),
185            ),
186            new CandidateResolver(new CombinedSpacingRule()),
187            'fixture',
188        );
189        $lexical->method('resolveSequence')->willReturnCallback(
190            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
191        );
192        $builder = new PlanBuilder($grammar, $lexical);
193        $constraints = GenerationPlan::constrained('root', ['root' => [ProductionPattern::at(1)]])->requiringNonEmpty()->withExpansionBudget(5);
194        self::assertSame(3, $builder->minimumExpansions($constraints));
195        $plan = (new BytePlanCompiler())->compile('', $builder, $constraints);
196        self::assertSame(3, $plan->expansionBudget());
197        self::assertEquals(ProductionPattern::at(1), $plan->patternAt('root', 0));
198        self::assertSame('U', (new SqlGenerator($grammar, Factory::create(), $lexical))->generate($plan));
199    }
200
201    public function testMinimumExpansionsRetainsNonEmptyCostsAndTheUnreachableSentinelForRootPatterns(): void
202    {
203        $grammar = new Grammar('root', ['root' => new ProductionRule('root', [new Production([]), new Production([new Terminal('T')])])]);
204        $builder = new PlanBuilder($grammar, $this->createMock(LexicalGrammar::class));
205        $empty = GenerationPlan::constrained('root', ['root' => [ProductionPattern::at(0)]]);
206        self::assertSame(1, $builder->minimumExpansions($empty));
207        self::assertSame(PHP_INT_MAX, $builder->minimumExpansions($empty->requiringNonEmpty()));
208        self::assertSame(
209            PHP_INT_MAX,
210            $builder->minimumExpansions(GenerationPlan::constrained('root', ['root' => [ProductionPattern::at(5)]])),
211        );
212        self::assertSame(
213            PHP_INT_MAX,
214            $builder->minimumExpansions(GenerationPlan::constrained('missing', ['missing' => [ProductionPattern::at(0)]])),
215        );
216    }
217
218    public function testBuildUsesTheSameContextualRewriteAndPinsItsCompleteCandidate(): void
219    {
220        $grammar = new Grammar('root', ['root' => new ProductionRule('root', [new Production([new Terminal('T')])])]);
221        $lexical = $this->createMock(LexicalGrammar::class);
222        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
223        $lexemePipeline = new ReverseLexemeGenerator(
224            new ChoiceLexemeGenerator(
225                new ValueLexemeGenerator('CONTEXTUAL', $literalDomain, ['CONTEXTUAL'], 'fixture', 'fixture-literal'),
226                new ValueLexemeGenerator('U', $literalDomain, ['U'], 'fixture', 'fixture-literal'),
227            ),
228            new CandidateResolver(new CombinedSpacingRule()),
229            'fixture',
230        );
231        $lexical->method('resolveSequence')->willReturnCallback(
232            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
233        );
234        $rewriter = new TokenRewriter(new TerminalMappingRule('root', 'T', 'CONTEXTUAL', 'fixture'));
235        $constraints = GenerationPlan::all()->withLexemes(['T' => ['explicit']]);
236        $plan = (new PlanBuilder($grammar, $lexical, $rewriter))->build($constraints, 1, static fn (int $count): int => 0, static fn (int $count): int => $count - 1);
237        self::assertSame('explicit', $plan->lexemeAt('CONTEXTUAL', 0));
238        self::assertNotNull($plan->candidateKeyAt('CONTEXTUAL', 0));
239        self::assertNull($plan->startRule());
240        $generator = new SqlGenerator($grammar, Factory::create(), $lexical, $rewriter);
241        self::assertSame('explicit', $generator->generate($plan));
242        self::assertSame('explicit', $generator->generate($plan));
243    }
244
245    public function testBuildPreservesRepeatedProductionAndLexemeConstraints(): void
246    {
247        $grammar = new Grammar(
248            'root',
249            [
250                'root' => new ProductionRule('root', [new Production([new NonTerminal('leaf'), new NonTerminal('leaf'), new NonTerminal('leaf')])]),
251                'leaf' => new ProductionRule('leaf', [new Production([new Terminal('T')]), new Production([new Terminal('U')])]),
252            ],
253        );
254        $lexical = $this->createMock(LexicalGrammar::class);
255        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
256        $lexemePipeline = new ReverseLexemeGenerator(
257            new ChoiceLexemeGenerator(
258                new ValueLexemeGenerator('T', $literalDomain, ['first', 'second'], 'fixture', 'fixture-literal'),
259                new ValueLexemeGenerator('U', $literalDomain, ['first', 'second'], 'fixture', 'fixture-literal'),
260            ),
261            new CandidateResolver(new CombinedSpacingRule()),
262            'fixture',
263        );
264        $lexical->method('resolveSequence')->willReturnCallback(
265            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
266        );
267        $constraints = GenerationPlan::constrained('root', ['leaf' => [ProductionPattern::at(0), ProductionPattern::at(0), ProductionPattern::at(1)]])->withLexemes(['T' => ['first', 'second'], 'U' => ['third']]);
268        $plan = (new PlanBuilder($grammar, $lexical))->build($constraints, 4, static fn (int $count): ?int => null, static fn (int $count): ?int => null);
269        self::assertEquals(ProductionPattern::at(0), $plan->patternAt('leaf', 0));
270        self::assertEquals(ProductionPattern::at(0), $plan->patternAt('leaf', 1));
271        self::assertEquals(ProductionPattern::at(1), $plan->patternAt('leaf', 2));
272        self::assertSame('first', $plan->lexemeAt('T', 0));
273        self::assertSame('second', $plan->lexemeAt('T', 1));
274        self::assertSame('third', $plan->lexemeAt('U', 0));
275    }
276
277    public function testBuildPreservesEmptyMarkerCandidatesWithoutLosingNonEmptyCompletion(): void
278    {
279        $grammar = new Grammar(
280            'root',
281            [
282                'root' => new ProductionRule('root', [new Production([new Terminal('END'), new NonTerminal('leaf')])]),
283                'leaf' => new ProductionRule('leaf', [new Production([]), new Production([new Terminal('T')])]),
284            ],
285        );
286        $lexical = $this->createMock(LexicalGrammar::class);
287        $lexical->method('isNonOutput')->willReturnCallback(static fn (string $name): bool => $name === 'END');
288        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
289        $lexemePipeline = new ReverseLexemeGenerator(
290            new ChoiceLexemeGenerator(
291                new ValueLexemeGenerator('T', $literalDomain, ['T'], 'fixture', 'fixture-literal'),
292                new ValueLexemeGenerator('END', $literalDomain, [''], 'fixture', 'fixture-literal'),
293            ),
294            new CandidateResolver(new CombinedSpacingRule()),
295            'fixture',
296        );
297        $lexical->method('resolveSequence')->willReturnCallback(
298            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
299        );
300        $builder = new PlanBuilder($grammar, $lexical);
301        $empty = $builder->build(GenerationPlan::all(), 2, static fn (int $count): ?int => null, static fn (int $count): ?int => null);
302        $nonEmpty = $builder->build(
303            GenerationPlan::all()->requiringNonEmpty(),
304            2,
305            static fn (int $count): ?int => null,
306            static fn (int $count): ?int => null,
307        );
308        self::assertSame('', $empty->lexemeAt('END', 0));
309        self::assertNotNull($empty->candidateKeyAt('END', 0));
310        self::assertNull($empty->lexemeAt('T', 0));
311        self::assertSame('T', $nonEmpty->lexemeAt('T', 0));
312    }
313
314    public function testBuildReservesAllPendingSiblingsAndRetainsTheOriginalOrdinal(): void
315    {
316        $grammar = (new Grammar(
317            'stmt',
318            [
319                'stmt' => new ProductionRule(
320                    'stmt',
321                    [
322                        new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
323                        new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
324                    ],
325                ),
326                'expr' => new ProductionRule(
327                    'expr',
328                    [
329                        new Production([new Terminal('1')]),
330                        new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
331                    ],
332                ),
333                'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
334            ],
335        ))->identified();
336        $lexical = $this->createMock(LexicalGrammar::class);
337        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
338        $lexemePipeline = new ReverseLexemeGenerator(
339            new ChoiceLexemeGenerator(
340                new ValueLexemeGenerator('SELECT', $literalDomain, ['SELECT'], 'fixture', 'fixture-literal'),
341                new ValueLexemeGenerator('DELETE', $literalDomain, ['DELETE'], 'fixture', 'fixture-literal'),
342                new ValueLexemeGenerator('FROM', $literalDomain, ['FROM'], 'fixture', 'fixture-literal'),
343                new ValueLexemeGenerator('missing', $literalDomain, ['missing'], 'fixture', 'fixture-literal'),
344                new ValueLexemeGenerator('1', $literalDomain, ['1'], 'fixture', 'fixture-literal'),
345                new ValueLexemeGenerator('+', $literalDomain, ['+'], 'fixture', 'fixture-literal'),
346                new ValueLexemeGenerator('AS', $literalDomain, ['AS'], 'fixture', 'fixture-literal'),
347                new ValueLexemeGenerator('name', $literalDomain, ['name'], 'fixture', 'fixture-literal'),
348                new ValueLexemeGenerator('CHANGED', $literalDomain, ['CHANGED'], 'fixture', 'fixture-literal'),
349            ),
350            new CandidateResolver(new CombinedSpacingRule()),
351            'fixture',
352        );
353        $lexical->method('resolveSequence')->willReturnCallback(
354            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
355        );
356        $constraints = GenerationPlan::constrained('stmt', ['stmt' => [ProductionPattern::at(0)]])->requiringNonEmpty();
357        $plan = (new PlanBuilder($grammar, $lexical))->build($constraints, 3, static fn (int $count): ?int => null, static fn (int $count): ?int => null);
358        self::assertEquals(ProductionPattern::at(0), $plan->patternAt('tail', 0));
359        self::assertSame('SELECT 1', (new SqlGenerator($grammar, Factory::create(), $lexical))->generate($plan));
360    }
361
362    public function testBuildPropagatesMissingCandidateFailuresWithoutRetrying(): void
363    {
364        $lexical = $this->createMock(LexicalGrammar::class);
365        $lexical->expects(self::once())->method('resolveSequence')->willThrowException(new LexicalException('missing candidate'));
366        $builder = new PlanBuilder(
367            (new Grammar(
368                'stmt',
369                [
370                    'stmt' => new ProductionRule(
371                        'stmt',
372                        [
373                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
374                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
375                        ],
376                    ),
377                    'expr' => new ProductionRule(
378                        'expr',
379                        [
380                            new Production([new Terminal('1')]),
381                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
382                        ],
383                    ),
384                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
385                ],
386            ))->identified(),
387            $lexical,
388        );
389        $this->expectException(LexicalException::class);
390        $builder->build(GenerationPlan::all(), 5, static fn (int $count): ?int => null, static fn (int $count): ?int => null);
391    }
392
393    public function testBuildReportsAnUnknownRequestedRule(): void
394    {
395        $builder = new PlanBuilder(
396            (new Grammar(
397                'stmt',
398                [
399                    'stmt' => new ProductionRule(
400                        'stmt',
401                        [
402                            new Production([new Terminal('SELECT'), new NonTerminal('expr'), new NonTerminal('tail')]),
403                            new Production([new Terminal('DELETE'), new Terminal('FROM'), new Terminal('missing')]),
404                        ],
405                    ),
406                    'expr' => new ProductionRule(
407                        'expr',
408                        [
409                            new Production([new Terminal('1')]),
410                            new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
411                        ],
412                    ),
413                    'tail' => new ProductionRule('tail', [new Production([]), new Production([new Terminal('AS'), new Terminal('name')])]),
414                ],
415            ))->identified(),
416            $this->createMock(LexicalGrammar::class),
417        );
418        $this->expectException(GenerationException::class);
419        $builder->build(GenerationPlan::fromRule('missing'), 5, static fn (int $count): ?int => null, static fn (int $count): ?int => null);
420    }
421
422    public function testMinimumExpansionsIncludesTheConstrainedDescendantBeforeDrawingTheBudget(): void
423    {
424        $grammar = new Grammar(
425            'root',
426            [
427                'root' => new ProductionRule('root', [new Production([new NonTerminal('child')])]),
428                'child' => new ProductionRule('child', [new Production([new Terminal('T')]), new Production([new NonTerminal('leaf')])]),
429                'leaf' => new ProductionRule('leaf', [new Production([new Terminal('U')])]),
430            ],
431        );
432        $lexical = $this->createMock(LexicalGrammar::class);
433        $literalDomain = new CharacterDomain(array_map(chr(...), range(0, 255)), 0, 255);
434        $lexemePipeline = new ReverseLexemeGenerator(
435            new ChoiceLexemeGenerator(
436                new ValueLexemeGenerator('T', $literalDomain, ['T'], 'fixture', 'fixture-literal'),
437                new ValueLexemeGenerator('U', $literalDomain, ['U'], 'fixture', 'fixture-literal'),
438            ),
439            new CandidateResolver(new CombinedSpacingRule()),
440            'fixture',
441        );
442        $lexical->method('resolveSequence')->willReturnCallback(
443            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $lexemePipeline->generate($sequence, $plan, $choose),
444        );
445        $builder = new PlanBuilder($grammar, $lexical);
446        $constraints = GenerationPlan::constrained('root', ['child' => [ProductionPattern::at(1)]])->requiringNonEmpty()->withExpansionBudget(5);
447        self::assertSame(3, $builder->minimumExpansions($constraints));
448        $plan = (new BytePlanCompiler())->compile('', $builder, $constraints);
449        self::assertSame(3, $plan->expansionBudget());
450        self::assertSame('U', (new SqlGenerator($grammar, Factory::create(), $lexical))->generate($plan));
451    }
452
453    #[DataProvider('providerScopedLexicalChoice')]
454    public function testBuildFreezesScopedValuesAndDefaultsWhenChoiceBytesRunOut(?int $choice, string $name, string $value): void
455    {
456        $grammar = new Grammar('root', ['root' => new ProductionRule('root', [new Production([new Terminal('ID'), new Terminal('VALUE')])])]);
457        $domain = new CharacterDomain(range('a', 'z'), 1, 20);
458        $pipeline = new ReverseLexemeGenerator(new ChoiceLexemeGenerator(
459            new ValueLexemeGenerator('ID', $domain, ['unplanned'], 'fixture', 'identifier'),
460            new ValueLexemeGenerator('VALUE', $domain, ['first', 'last'], 'fixture', 'value'),
461        ), new CandidateResolver(new CombinedSpacingRule()), 'fixture');
462        $lexical = $this->createMock(LexicalGrammar::class);
463        $lexical->method('resolveSequence')->willReturnCallback(
464            static fn (TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose) => $pipeline->generate($sequence, $plan, $choose),
465        );
466        $constraints = GenerationPlan::fromRule('root')->withRule('root', RulePlan::any()->withLexeme('ID', LexemeConstraint::oneOf('users', 'orders')));
467        $frozen = (new PlanBuilder($grammar, $lexical))->build($constraints, 1, static fn (int $count): ?int => null, static fn (int $count): ?int => $choice);
468        self::assertSame($name, $frozen->lexemeAt('ID', 0));
469        self::assertSame($value, $frozen->lexemeAt('VALUE', 0));
470        $generator = new SqlGenerator($grammar, Factory::create(), $lexical);
471        self::assertSame($name . ' ' . $value, $generator->generate($frozen));
472        self::assertSame($name . ' ' . $value, $generator->generate($frozen));
473    }
474
475    /**
476     * @return iterable<string, array{?int, string, string}>
477     */
478    public static function providerScopedLexicalChoice(): iterable
479    {
480        yield 'exhausted choices use the first value' => [null, 'users', 'first'];
481        yield 'explicit choices remain selectable' => [1, 'orders', 'last'];
482    }
483
484}
485