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