packages/sql-faker/tests/Unit/Generation/Output/ReverseLexemeGeneratorTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\SqlFaker\Generation\Output;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlFaker\Generation\Candidate\ChoiceLexemeGenerator;
11use SqlFaker\Generation\Candidate\FixedLexemeGenerator;
12use SqlFaker\Generation\Candidate\MatchingLexemeGenerator;
13use SqlFaker\Generation\Candidate\SequenceLexemeGenerator;
14use SqlFaker\Generation\Candidate\ValueLexemeGenerator;
15use SqlFaker\Generation\Exception\LexicalException;
16use SqlFaker\Generation\Lexeme\Lexeme;
17use SqlFaker\Generation\Lexeme\LexemeBoundary;
18use SqlFaker\Generation\Lexeme\LexemeInput;
19use SqlFaker\Generation\Lexeme\LexemeSequence;
20use SqlFaker\Generation\Lexeme\ResolvedOutput;
21use SqlFaker\Generation\Lexeme\SpacingConstraint;
22use SqlFaker\Generation\Lexeme\SpacingRule;
23use SqlFaker\Generation\Output\CandidateResolver;
24use SqlFaker\Generation\Output\CombinedSpacingRule;
25use SqlFaker\Generation\Output\ReverseLexemeGenerator;
26use SqlFaker\Generation\Output\SqlSerializer;
27use SqlFaker\Generation\Plan\GenerationPlan;
28use SqlFaker\Generation\Token\TerminalSequence;
29
30#[CoversClass(ReverseLexemeGenerator::class)]
31#[UsesClass(GenerationPlan::class)]
32#[UsesClass(ChoiceLexemeGenerator::class)]
33#[UsesClass(FixedLexemeGenerator::class)]
34#[UsesClass(Lexeme::class)]
35#[UsesClass(\SqlFaker\Generation\Lexeme\LexemeCandidates::class)]
36#[UsesClass(LexemeInput::class)]
37#[UsesClass(LexemeSequence::class)]
38#[UsesClass(MatchingLexemeGenerator::class)]
39#[UsesClass(ValueLexemeGenerator::class)]
40#[UsesClass(SequenceLexemeGenerator::class)]
41#[UsesClass(CandidateResolver::class)]
42#[UsesClass(\SqlFaker\Generation\Lexeme\OutputPart::class)]
43#[UsesClass(ResolvedOutput::class)]
44#[UsesClass(SqlSerializer::class)]
45#[UsesClass(CombinedSpacingRule::class)]
46#[UsesClass(LexemeBoundary::class)]
47#[UsesClass(SpacingConstraint::class)]
48#[UsesClass(\SqlFaker\Generation\Token\TerminalOccurrence::class)]
49#[UsesClass(TerminalSequence::class)]
50#[UsesClass(LexicalException::class)]
51#[UsesClass(\SqlFaker\Generation\Plan\ProductionPattern::class)]
52#[UsesClass(\SqlFaker\Generation\Token\ProductionOccurrence::class)]
53#[UsesClass(\SqlFaker\Generation\Output\BoundaryCompletion::class)]
54#[UsesClass(\SqlFaker\Generation\Value\CharacterDomain::class)]
55#[UsesClass(\SqlFaker\Generation\Value\WordDomain::class)]
56#[UsesClass(\SqlFaker\Generation\Value\ValueChoices::class)]
57#[UsesClass(\SqlFaker\Generation\Choice\BytePlanCompiler::class)]
58final class ReverseLexemeGeneratorTest extends TestCase
59{
60 public function testGenerateCompoundOutputRemainsInOrderAndEofAddsNoBoundary(): void
61 {
62 $lexemes = new ChoiceLexemeGenerator(
63 new MatchingLexemeGenerator('WITH_ROLLUP_SYM', new SequenceLexemeGenerator(
64 new FixedLexemeGenerator('WITH', 'keyword', 'with'),
65 new FixedLexemeGenerator('ROLLUP', 'keyword', 'rollup'),
66 )),
67 new MatchingLexemeGenerator('EOF', new FixedLexemeGenerator('', 'marker', 'eof')),
68 );
69 $generator = new ReverseLexemeGenerator($lexemes, new CandidateResolver(new CombinedSpacingRule()), 'demo');
70 $output = $generator->generate(TerminalSequence::fromNames(['WITH_ROLLUP_SYM', 'EOF']), null, static fn (int $count): int => $count - 1);
71 self::assertSame('WITH ROLLUP', (new SqlSerializer())->serialize($output->pieces()));
72 self::assertSame([0, 0], array_map(static fn ($part): int => $part->lexeme->origin->id, $output->parts));
73 }
74
75 public function testConflictingCandidateIsNotSelectedOrAllowedToAlterAnotherCandidate(): void
76 {
77 $lexemes = new ChoiceLexemeGenerator(
78 new MatchingLexemeGenerator('NOW_SYM', new ChoiceLexemeGenerator(
79 new FixedLexemeGenerator('NOW', 'function', 'function'),
80 new FixedLexemeGenerator('CURRENT_TIMESTAMP', 'keyword', 'keyword'),
81 )),
82 new MatchingLexemeGenerator('(', new FixedLexemeGenerator('(', 'symbol', 'open')),
83 );
84 $spacing = $this->createMock(SpacingRule::class);
85 $spacing->method('apply')->willReturnCallback(static fn (LexemeBoundary $boundary, LexemeInput $input): SpacingConstraint =>
86 $boundary->left->kind === 'function' ? new SpacingConstraint(0, ['join', 'space']) : new SpacingConstraint());
87 $generator = new ReverseLexemeGenerator($lexemes, new CandidateResolver($spacing), 'demo');
88 $output = $generator->generate(TerminalSequence::fromNames(['NOW_SYM', '(']), null, static fn (int $count): int => 0);
89 self::assertSame('CURRENT_TIMESTAMP (', (new SqlSerializer())->serialize($output->pieces()));
90 }
91
92 public function testMissingHandlerFailsAtTheActualTerminal(): void
93 {
94 $generator = new ReverseLexemeGenerator(new ChoiceLexemeGenerator(), new CandidateResolver(new CombinedSpacingRule()), 'demo');
95 $this->expectException(LexicalException::class);
96 $this->expectExceptionMessage('UNKNOWN');
97 $generator->generate(TerminalSequence::fromNames(['UNKNOWN']), null, static fn (int $count): int => 0);
98 }
99 public function testGenerateRetainsAPlannedSpellingWhenAContextualRewriteRenamesItsTerminal(): void
100 {
101 $original = TerminalSequence::fromNames(['IDENT']);
102 $sequence = $original->replace(0, 1, [$original->terminals[0]->replaced('POLICY_MODE', 'policy')], 'policy');
103 $generator = new ReverseLexemeGenerator(
104 new ValueLexemeGenerator('POLICY_MODE', new \SqlFaker\Generation\Value\WordDomain(['PERMISSIVE', 'RESTRICTIVE'], true), ['PERMISSIVE'], 'identifier', 'policy-mode'),
105 new CandidateResolver(new CombinedSpacingRule()),
106 'test',
107 );
108 $plan = GenerationPlan::all()->withLexemes(['IDENT' => ['restrictive']]);
109 $result = $generator->generate($sequence, $plan, static fn (int $count): int => 0);
110 self::assertSame('restrictive', (new SqlSerializer())->serialize($result->pieces()));
111 $overridden = $plan->withLexemes(['IDENT' => ['restrictive'], 'POLICY_MODE' => ['permissive']]);
112 self::assertSame('permissive', (new SqlSerializer())->serialize($generator->generate($sequence, $overridden, static fn (int $count): int => 0)->pieces()));
113 }
114
115 public function testSelectRejectsAnOutOfRangeDecision(): void
116 {
117 $generator = new ReverseLexemeGenerator(new ChoiceLexemeGenerator(new FixedLexemeGenerator('A', 'keyword', 'a'), new FixedLexemeGenerator('B', 'keyword', 'b')), new CandidateResolver(new CombinedSpacingRule()), 'test');
118 $this->expectException(LexicalException::class);
119 $this->expectExceptionMessage('Candidate selector returned an out-of-range index for WORD');
120 $generator->select(new LexemeInput(TerminalSequence::fromNames(['WORD']), 0, new ResolvedOutput()), static fn (int $count): int => $count);
121 }
122
123 public function testMatchesRequestComparesTheWholeCompoundSpelling(): void
124 {
125 $input = new LexemeInput(TerminalSequence::fromNames(['WORD']), 0, new ResolvedOutput(), 'WITH ROLLUP');
126 $candidate = new LexemeSequence([new Lexeme('WITH', 'keyword', $input->terminal(), 'source'), new Lexeme('ROLLUP', 'keyword', $input->terminal(), 'source')], 'phrase');
127 $generator = new ReverseLexemeGenerator(new ChoiceLexemeGenerator(), new CandidateResolver(new CombinedSpacingRule()), 'test');
128 self::assertTrue($generator->matchesRequest($candidate, $input));
129 self::assertFalse($generator->matchesRequest(new LexemeSequence([], 'empty'), $input));
130 }
131
132 public function testGenerateRespectsTheOrderOfRepeatedPlannedOccurrencesAfterRenaming(): void
133 {
134 $original = TerminalSequence::fromNames(['IDENT', 'IDENT', 'IDENT']);
135 $sequence = $original->replace(0, 3, array_map(static fn ($terminal) => $terminal->replaced('NAME', 'context'), $original->terminals), 'context');
136 $generator = new ReverseLexemeGenerator(new ValueLexemeGenerator('NAME', new \SqlFaker\Generation\Value\CharacterDomain(str_split('abcdefghijklmnopqrstuvwxyz'), 1, 64), ['fallback'], 'identifier', 'names'), new CandidateResolver(new CombinedSpacingRule()), 'test');
137 $plan = GenerationPlan::all()->withLexemes(['IDENT' => ['first', 'second', 'third']]);
138 self::assertSame('first second third', (new SqlSerializer())->serialize($generator->generate($sequence, $plan, static fn (int $count): int => 0)->pieces()));
139 $overridden = $plan->withLexemes(['NAME' => ['fourth', 'fifth', 'sixth']]);
140 self::assertSame('fourth fifth sixth', (new SqlSerializer())->serialize($generator->generate($sequence, $overridden, static fn (int $count): int => 0)->pieces()));
141 }
142
143 public function testSelectCanChooseTheLastOfSeveralCompatibleCandidates(): void
144 {
145 $generator = new ReverseLexemeGenerator(new ChoiceLexemeGenerator(
146 new FixedLexemeGenerator('FIRST', 'keyword', 'first'),
147 new FixedLexemeGenerator('MIDDLE', 'keyword', 'middle'),
148 new FixedLexemeGenerator('LAST', 'keyword', 'last'),
149 ), new CandidateResolver(new CombinedSpacingRule()), 'test');
150 $output = $generator->generate(TerminalSequence::fromNames(['WORD']), null, static function (int $count): int {
151 self::assertSame(3, $count);
152 return 2;
153 });
154 self::assertSame('LAST', (new SqlSerializer())->serialize($output->pieces()));
155 }
156
157 public function testSelectReportsCandidateAndBoundarySourcesWhenAllCandidatesConflict(): void
158 {
159 $spacing = self::createStub(SpacingRule::class);
160 $spacing->method('apply')->willReturn(new SpacingConstraint(0, ['require-join', 'require-space']));
161 $generator = new ReverseLexemeGenerator(new FixedLexemeGenerator('WORD', 'keyword', 'word-definition'), new CandidateResolver($spacing), 'demo');
162 $this->expectException(LexicalException::class);
163 $this->expectExceptionMessage('No compatible lexeme for WORD at 0 in demo before WORD; word-definition:WORD: require-join, require-space');
164 $generator->generate(TerminalSequence::fromNames(['WORD', 'WORD']), null, static fn (int $count): int => 0);
165 }
166
167}
168