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