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