packages/sql-faker/tests/Unit/MySql/Generation/LexicalGrammarTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\SqlFaker\MySql\Generation;
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\Exception\LexicalException;
14use SqlFaker\Generation\Plan\GenerationPlan;
15use SqlFaker\Generation\Token\TerminalSequence;
16use SqlFaker\Generation\Value\RandomCharacters;
17use SqlFaker\Grammar\Resource\SqlVersion;
18use SqlFaker\Grammar\Resource\SqlVersionRegistry;
19use SqlFaker\MySql\Generation\LexicalGrammar;
20use SqlFaker\MySql\Generation\Tokenization\KeywordIndex;
21use SqlFaker\MySql\Generation\Tokenization\MySqlTokenizer;
22use SqlFaker\MySql\Generation\Value\LiteralGenerator;
23
24#[CoversClass(LexicalGrammar::class)]
25#[CoversClass(LiteralGenerator::class)]
26#[UsesClass(SqlVersion::class)]
27#[UsesClass(LexicalException::class)]
28#[UsesClass(KeywordIndex::class)]
29#[UsesClass(RandomCharacters::class)]
30#[UsesClass(SqlVersionRegistry::class)]
31#[UsesClass(MySqlTokenizer::class)]
32#[UsesClass(\SqlFaker\MySql\Generation\Tokenization\MySqlQuoting::class)]
33#[UsesClass(GenerationPlan::class)]
34#[UsesClass(\SqlFaker\Generation\Candidate\ChoiceLexemeGenerator::class)]
35#[UsesClass(\SqlFaker\Generation\Candidate\FixedLexemeGenerator::class)]
36#[UsesClass(\SqlFaker\Generation\Candidate\IntegerLexemeGenerator::class)]
37#[UsesClass(\SqlFaker\Generation\Lexeme\Lexeme::class)]
38#[UsesClass(\SqlFaker\Generation\Lexeme\LexemeCandidates::class)]
39#[UsesClass(\SqlFaker\Generation\Lexeme\LexemeInput::class)]
40#[UsesClass(\SqlFaker\Generation\Lexeme\LexemeSequence::class)]
41#[UsesClass(\SqlFaker\Generation\Candidate\MatchingLexemeGenerator::class)]
42#[UsesClass(\SqlFaker\Generation\Candidate\ValueLexemeGenerator::class)]
43#[UsesClass(\SqlFaker\Generation\Candidate\SequenceLexemeGenerator::class)]
44#[UsesClass(\SqlFaker\Generation\Output\CandidateResolver::class)]
45#[UsesClass(\SqlFaker\Generation\Lexeme\OutputPart::class)]
46#[UsesClass(\SqlFaker\Generation\Lexeme\ResolvedOutput::class)]
47#[UsesClass(\SqlFaker\Generation\Output\ReverseLexemeGenerator::class)]
48#[UsesClass(\SqlFaker\Generation\Output\SqlSerializer::class)]
49#[UsesClass(\SqlFaker\Generation\Output\CombinedSpacingRule::class)]
50#[UsesClass(\SqlFaker\MySql\Generation\Spacing\KeywordPhraseSpacingRule::class)]
51#[UsesClass(\SqlFaker\Generation\Lexeme\LexemeBoundary::class)]
52#[UsesClass(\SqlFaker\Generation\Lexeme\SpacingConstraint::class)]
53#[UsesClass(\SqlFaker\Generation\Token\TerminalOccurrence::class)]
54#[UsesClass(TerminalSequence::class)]
55#[UsesClass(\SqlFaker\Generation\Candidate\VersionCase::class)]
56#[UsesClass(\SqlFaker\Generation\Candidate\VersionedLexemeGenerator::class)]
57#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\DefinitionFactory::class)]
58#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\KeywordLexemeGenerator::class)]
59#[UsesClass(\SqlFaker\MySql\Generation\Spacing\CloneAddressSpacingRule::class)]
60#[UsesClass(\SqlFaker\MySql\Generation\Spacing\FunctionSpacingRule::class)]
61#[UsesClass(\SqlFaker\MySql\Generation\Spacing\QualifiedNameSpacingRule::class)]
62#[UsesClass(\SqlFaker\MySql\Generation\Spacing\VariableSpacingRule::class)]
63#[UsesClass(\SqlFaker\Generation\Plan\ProductionPattern::class)]
64#[UsesClass(\SqlFaker\Generation\Token\ProductionOccurrence::class)]
65#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\BoundedIntegerLexemeGenerator::class)]
66#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\ReplicationTablePatternLexemeGenerator::class)]
67#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\SizeNumberLexemeGenerator::class)]
68#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\FactorLexemeGenerator::class)]
69#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\PrecisionLexemeGenerator::class)]
70#[UsesClass(\SqlFaker\Generation\Value\CharacterDomain::class)]
71#[UsesClass(\SqlFaker\Generation\Value\ChoiceDomain::class)]
72#[UsesClass(\SqlFaker\Generation\Value\IntegerDomain::class)]
73#[UsesClass(\SqlFaker\Generation\Value\SequenceDomain::class)]
74#[UsesClass(\SqlFaker\MySql\Generation\Value\DollarQuotedDomain::class)]
75#[UsesClass(\SqlFaker\Generation\Output\BoundaryCompletion::class)]
76#[UsesClass(\SqlFaker\Generation\Value\ValueChoices::class)]
77#[UsesClass(\SqlFaker\Generation\Choice\BytePlanCompiler::class)]
78#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\CharsetLexemeGenerator::class)]
79#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\CharsetValueLexemeGenerator::class)]
80#[UsesClass(\SqlFaker\MySql\Generation\Value\IdentifierDomain::class)]
81#[UsesClass(\SqlFaker\MySql\Generation\Value\QuotedDomain::class)]
82#[UsesClass(\SqlFaker\MySql\Generation\Value\RadixDomain::class)]
83#[UsesClass(\SqlFaker\Generation\Value\Utf8::class)]
84#[UsesClass(\SqlFaker\Generation\Value\WordDomain::class)]
85#[UsesClass(\SqlFaker\MySql\Generation\Lexeme\LexicalDefinition::class)]
86final class LexicalGrammarTest extends TestCase
87{
88    public function testGeneratesPublicProviderLexemesThroughDialectGrammar(): void
89    {
90        $faker = Factory::create();
91        $faker->seed(12345);
92        $lexical = new LexicalGrammar($faker, 'mysql-8.4.7');
93        $sql = implode(' ', [
94            $lexical->generateQuotedIdentifier(3, 3),
95            $lexical->generateStringLiteral(3, 3),
96            $lexical->generateNationalStringLiteral(3, 3),
97            $lexical->generateDollarQuotedString(3, 3),
98            $lexical->generateIntegerLiteral(10, 10),
99            $lexical->generateDecimalLiteral(4, 2),
100            $lexical->generateFloatLiteral(4, 2, 2, 2),
101            $lexical->generateHexLiteral(4, 4),
102            $lexical->generateQuotedHexLiteral(2, 2),
103            $lexical->generateBinaryLiteral(4, 4),
104        ]);
105
106        self::assertSame([
107            'IDENT_QUOTED', 'TEXT_STRING', 'NCHAR_STRING', 'DOLLAR_QUOTED_STRING_SYM', 'NUM',
108            'DECIMAL_NUM', 'FLOAT_NUM', 'HEX_NUM', 'HEX_NUM', 'BIN_NUM',
109        ], $lexical->tokenize($sql));
110        self::assertSame('10', $lexical->generateLongIntegerLiteral(10, 10));
111        self::assertMatchesRegularExpression('/^[0-9]{1,20}$/', $lexical->generateUnsignedBigIntLiteral());
112        self::assertSame(
113            ['@', 'LEX_HOSTNAME'],
114            $lexical->tokenize('@' . $lexical->generateHostname(2, 2, 3)),
115        );
116    }
117
118    /**
119     * @param Closure(LexicalGrammar): string $withDefaults
120     * @param Closure(LexicalGrammar): string $withExplicitBounds
121     */
122    #[DataProvider('providerPublicLexemeDefaults')]
123    public function testPublicLexemeDefaultBounds(Closure $withDefaults, Closure $withExplicitBounds): void
124    {
125        $faker = Factory::create();
126        $grammar = new LexicalGrammar($faker, 'mysql-8.4.7');
127
128        $faker->seed(20_260_824);
129        $generated = $withDefaults($grammar);
130
131        $faker->seed(20_260_824);
132        $explicit = $withExplicitBounds($grammar);
133
134        self::assertNotSame('', $generated);
135        self::assertSame($generated, $explicit);
136    }
137
138    /**
139     * @return iterable<string, array{Closure(LexicalGrammar): string, Closure(LexicalGrammar): string}>
140     */
141    public static function providerPublicLexemeDefaults(): iterable
142    {
143        yield 'quoted identifier' => [
144            static fn (LexicalGrammar $grammar): string => $grammar->generateQuotedIdentifier(),
145            static fn (LexicalGrammar $grammar): string => $grammar->generateQuotedIdentifier(1, 64),
146        ];
147        yield 'string' => [
148            static fn (LexicalGrammar $grammar): string => $grammar->generateStringLiteral(),
149            static fn (LexicalGrammar $grammar): string => $grammar->generateStringLiteral(1, 255),
150        ];
151        yield 'national string' => [
152            static fn (LexicalGrammar $grammar): string => $grammar->generateNationalStringLiteral(),
153            static fn (LexicalGrammar $grammar): string => $grammar->generateNationalStringLiteral(1, 255),
154        ];
155        yield 'dollar quoted string' => [
156            static fn (LexicalGrammar $grammar): string => $grammar->generateDollarQuotedString(),
157            static fn (LexicalGrammar $grammar): string => $grammar->generateDollarQuotedString(1, 255),
158        ];
159        yield 'integer' => [
160            static fn (LexicalGrammar $grammar): string => $grammar->generateIntegerLiteral(),
161            static fn (LexicalGrammar $grammar): string => $grammar->generateIntegerLiteral(1, 2147483647),
162        ];
163        yield 'long integer' => [
164            static fn (LexicalGrammar $grammar): string => $grammar->generateLongIntegerLiteral(),
165            static fn (LexicalGrammar $grammar): string => $grammar->generateLongIntegerLiteral(0, 2147483647),
166        ];
167        yield 'unsigned big integer' => [
168            static fn (LexicalGrammar $grammar): string => $grammar->generateUnsignedBigIntLiteral(),
169            static fn (LexicalGrammar $grammar): string => $grammar->generateUnsignedBigIntLiteral(1, 20),
170        ];
171        yield 'decimal' => [
172            static fn (LexicalGrammar $grammar): string => $grammar->generateDecimalLiteral(),
173            static fn (LexicalGrammar $grammar): string => $grammar->generateDecimalLiteral(10, 2),
174        ];
175        yield 'float' => [
176            static fn (LexicalGrammar $grammar): string => $grammar->generateFloatLiteral(),
177            static fn (LexicalGrammar $grammar): string => $grammar->generateFloatLiteral(10, 2, -38, 38),
178        ];
179        yield 'hex' => [
180            static fn (LexicalGrammar $grammar): string => $grammar->generateHexLiteral(),
181            static fn (LexicalGrammar $grammar): string => $grammar->generateHexLiteral(1, 16),
182        ];
183        yield 'quoted hex' => [
184            static fn (LexicalGrammar $grammar): string => $grammar->generateQuotedHexLiteral(),
185            static fn (LexicalGrammar $grammar): string => $grammar->generateQuotedHexLiteral(1, 8),
186        ];
187        yield 'binary' => [
188            static fn (LexicalGrammar $grammar): string => $grammar->generateBinaryLiteral(),
189            static fn (LexicalGrammar $grammar): string => $grammar->generateBinaryLiteral(1, 64),
190        ];
191        yield 'hostname' => [
192            static fn (LexicalGrammar $grammar): string => $grammar->generateHostname(),
193            static fn (LexicalGrammar $grammar): string => $grammar->generateHostname(1, 4, 63),
194        ];
195    }
196
197    public function testTokenizesQuotedValuesHexValuesAndCommentsAsSingleTokens(): void
198    {
199        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
200        $sql = <<<'SQL'
201SELECT `values`, 'FROM ''items''', X'af', B'101' /* UPDATE */ # DELETE
202FROM items
203SQL;
204
205        self::assertSame([
206            'SELECT_SYM', 'IDENT_QUOTED', ',', 'TEXT_STRING', ',', 'HEX_NUM', ',', 'BIN_NUM', 'FROM', 'IDENT',
207        ], $lexical->tokenize($sql));
208    }
209
210    public function testTokenizesEveryLiteralNumberAndOperatorClass(): void
211    {
212        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
213        $sql = <<<'SQL'
2140 2147483647 2147483648 9223372036854775807 9223372036854775808 000 000000000002147483648
2150XAF 0b10 x'af' b'01' n'x' 1.2 .5 1E2 _UTF8MB4 _name ?
216WITH ROLLUP || <=> ->> && <= <> != >= << >> := ->
217! % & ( ) * + , - . / : ; @ ^ { } | ~ = < >
218SQL;
219
220        self::assertSame([
221            'NUM', 'NUM', 'LONG_NUM', 'LONG_NUM', 'ULONGLONG_NUM', 'NUM', 'LONG_NUM',
222            'HEX_NUM', 'BIN_NUM', 'HEX_NUM', 'BIN_NUM', 'NCHAR_STRING', 'DECIMAL_NUM', 'DECIMAL_NUM',
223            'FLOAT_NUM', 'UNDERSCORE_CHARSET', 'IDENT', 'PARAM_MARKER',
224            'WITH_ROLLUP_SYM', 'OR2_SYM', 'EQUAL_SYM', '->>', 'AND_AND_SYM', 'LE', 'NE', 'NE', 'GE',
225            'SHIFT_LEFT', 'SHIFT_RIGHT', ':=', '->',
226            '!', '%', '&', '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '@', '^', '{', '}', '|', '~',
227            'EQ', 'LT', 'GT_SYM',
228        ], $lexical->tokenize($sql));
229    }
230
231    public function testTokenizesDollarQuotedStringAndContinuesWithTheRemainingInput(): void
232    {
233        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
234
235        self::assertSame(
236            ['DOLLAR_QUOTED_STRING_SYM', 'PARAM_MARKER'],
237            $lexical->tokenize('$$value$$ ?'),
238        );
239    }
240
241    public function testCombinesWithRollupAtTheEndOfInput(): void
242    {
243        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
244
245        self::assertSame(['WITH_ROLLUP_SYM'], $lexical->tokenize('WITH ROLLUP'));
246    }
247
248    public function testUsesFunctionTokensOnlyWhenFollowedByAnOpeningParenthesis(): void
249    {
250        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
251
252        self::assertSame(
253            ['SELECT_SYM', 'IDENT', 'COUNT_SYM', '(', '*', ')'],
254            $lexical->tokenize('select count COUNT(*)'),
255        );
256    }
257
258    public function testVersionProfileControlsDollarQuotedStringSupport(): void
259    {
260        $faker = Factory::create();
261
262        $beforeSupport = new LexicalGrammar($faker, 'mysql-8.0.44');
263        $afterSupport = new LexicalGrammar($faker, 'mysql-8.1.0');
264
265        self::assertSame('mysql-8.0.44', $beforeSupport->version());
266        self::assertSame('$tag$text$tag$', $afterSupport->realize(['DOLLAR_QUOTED_STRING_SYM'], GenerationPlan::all()->withLexemes([
267            'DOLLAR_QUOTED_STRING_SYM' => ['$tag$text$tag$'],
268        ])));
269        $this->expectException(LexicalException::class);
270        $this->expectExceptionMessage('DOLLAR_QUOTED_STRING_SYM');
271        $beforeSupport->realize(['DOLLAR_QUOTED_STRING_SYM']);
272    }
273
274    #[DataProvider('providerInvalidSql')]
275    public function testRejectsInvalidSql(string $version, string $sql, string $message): void
276    {
277        $lexical = new LexicalGrammar(Factory::create(), $version);
278
279        $this->expectException(LexicalException::class);
280        $this->expectExceptionMessage($message);
281
282        $lexical->tokenize($sql);
283    }
284
285    public function testTokenizesHostnameWithoutStoppingTheRemainingInput(): void
286    {
287        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
288
289        self::assertSame(
290            ['SELECT_SYM', 'IDENT', '@', 'LEX_HOSTNAME', 'FROM', 'IDENT'],
291            $lexical->tokenize('SELECT sqlfakeruser@host.example FROM items'),
292        );
293    }
294
295    public function testRealizesAndChecksAParserTokenSequence(): void
296    {
297        $faker = Factory::create();
298        $faker->seed(12);
299        $lexical = new LexicalGrammar($faker, 'mysql-8.4.7');
300
301        $sql = $lexical->realize(['SELECT_SYM', 'IDENT_QUOTED', 'FROM', 'IDENT', 'WHERE', 'IDENT', 'EQ', 'HEX_NUM']);
302
303        self::assertSame([
304            'SELECT_SYM', 'IDENT_QUOTED', 'FROM', 'IDENT', 'WHERE', 'IDENT', 'EQ', 'HEX_NUM',
305        ], $lexical->tokenize($sql));
306    }
307
308    #[DataProvider('providerFixedTerminal')]
309    public function testRealizeOfFixedTerminal(string $terminal, string $expected): void
310    {
311        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
312
313        self::assertSame($expected, $lexical->realize([$terminal]));
314    }
315
316    public function testRealizeOfGeneratedTerminals(): void
317    {
318        $faker = Factory::create();
319        $faker->seed(17);
320        $lexical = new LexicalGrammar($faker, 'mysql-8.4.7');
321
322        self::assertMatchesRegularExpression('/^_[A-Za-z0-9_]+$/', $lexical->realize(['IDENT']));
323        self::assertStringStartsWith('`', $lexical->realize(['IDENT_QUOTED']));
324        self::assertStringStartsWith("'", $lexical->realize(['TEXT_STRING']));
325        self::assertStringStartsWith("N'", $lexical->realize(['NCHAR_STRING']));
326        self::assertContains($lexical->realize(['DOLLAR_QUOTED_STRING_SYM']), ['$$text$$', '$tag$text$tag$']);
327        self::assertMatchesRegularExpression('/^\d+$/', $lexical->realize(['NUM']));
328        self::assertMatchesRegularExpression('/^\d+$/', $lexical->realize(['LONG_NUM']));
329        self::assertSame('18446744073709551615', $lexical->realize(['ULONGLONG_NUM']));
330        self::assertSame(['DECIMAL_NUM'], $lexical->tokenize($lexical->realize(['DECIMAL_NUM'])));
331        self::assertSame(['UNDERSCORE_CHARSET'], $lexical->tokenize($lexical->realize(['UNDERSCORE_CHARSET'])));
332        self::assertMatchesRegularExpression('/^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/', $lexical->realize(['FLOAT_NUM']));
333        self::assertSame(['HEX_NUM'], $lexical->tokenize($lexical->realize(['HEX_NUM'])));
334        self::assertSame(['BIN_NUM'], $lexical->tokenize($lexical->realize(['BIN_NUM'])));
335    }
336
337    /**
338     * @return iterable<string, array{string, string, string}>
339     */
340    public static function providerInvalidSql(): iterable
341    {
342        yield 'backtick quoted identifier' => ['mysql-8.4.7', '`name', 'Unterminated MySQL quoted token'];
343        yield 'single quoted string' => ['mysql-8.4.7', "'value", 'Unterminated MySQL quoted token'];
344        yield 'dollar quoted string' => ['mysql-8.4.7', '$$value', 'Unterminated MySQL dollar-quoted string.'];
345        yield 'block comment' => ['mysql-8.4.7', '/* comment', 'Unterminated MySQL block comment.'];
346        yield 'unsupported character' => ['mysql-8.4.7', 'SELECT \\', 'offset 7: SELECT \\'];
347        yield 'dollar quote before support' => ['mysql-8.0.44', '$$value$$', 'offset 0: $$value$$'];
348    }
349
350    /**
351     * @return iterable<string, array{string, string}>
352     */
353    public static function providerFixedTerminal(): iterable
354    {
355        yield 'EQ' => ['EQ', '='];
356        yield 'EQUAL_SYM' => ['EQUAL_SYM', '<=>'];
357        yield 'LT' => ['LT', '<'];
358        yield 'GT_SYM' => ['GT_SYM', '>'];
359        yield 'LE' => ['LE', '<='];
360        yield 'GE' => ['GE', '>='];
361        yield 'SHIFT_LEFT' => ['SHIFT_LEFT', '<<'];
362        yield 'SHIFT_RIGHT' => ['SHIFT_RIGHT', '>>'];
363        yield 'AND_AND_SYM' => ['AND_AND_SYM', '&&'];
364        yield 'OR_OR_SYM' => ['OR_OR_SYM', '||'];
365        yield 'NOT2_SYM' => ['NOT2_SYM', 'NOT'];
366        yield 'SET_VAR' => ['SET_VAR', ':='];
367        yield 'JSON_SEPARATOR_SYM' => ['JSON_SEPARATOR_SYM', '->'];
368        yield 'JSON_UNQUOTED_SEPARATOR_SYM' => ['JSON_UNQUOTED_SEPARATOR_SYM', '->>'];
369        yield 'WITH_ROLLUP_SYM' => ['WITH_ROLLUP_SYM', 'WITH ROLLUP'];
370        yield 'PARAM_MARKER' => ['PARAM_MARKER', '?'];
371    }
372
373    public function testGenerateQuotedIdentifierWrapsTheNameInBackticks(): void
374    {
375        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
376
377        self::assertMatchesRegularExpression(
378            '/^`.+`$/',
379            $lexical->generateQuotedIdentifier(),
380        );
381    }
382
383    public function testGenerateStringLiteralWrapsTheBodyInSingleQuotes(): void
384    {
385        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
386
387        self::assertMatchesRegularExpression(
388            '/^\'.*\'$/s',
389            $lexical->generateStringLiteral(),
390        );
391    }
392
393    public function testGenerateNationalStringLiteralPrefixesTheStringWithN(): void
394    {
395        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
396
397        self::assertMatchesRegularExpression(
398            '/^N\'.*\'$/s',
399            $lexical->generateNationalStringLiteral(),
400        );
401    }
402
403    public function testGenerateDollarQuotedStringWrapsTheBodyInDoubleDollars(): void
404    {
405        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
406
407        self::assertMatchesRegularExpression(
408            '/^\$\$.*\$\$$/s',
409            $lexical->generateDollarQuotedString(),
410        );
411    }
412
413    public function testGenerateIntegerLiteralWritesOnlyDigits(): void
414    {
415        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
416
417        self::assertMatchesRegularExpression(
418            '/^\d+$/',
419            $lexical->generateIntegerLiteral(),
420        );
421    }
422
423    public function testGenerateLongIntegerLiteralWritesOnlyDigits(): void
424    {
425        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
426
427        self::assertMatchesRegularExpression(
428            '/^\d+$/',
429            $lexical->generateLongIntegerLiteral(),
430        );
431    }
432
433    public function testGenerateUnsignedBigIntLiteralWritesOnlyDigits(): void
434    {
435        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
436
437        self::assertMatchesRegularExpression(
438            '/^\d+$/',
439            $lexical->generateUnsignedBigIntLiteral(),
440        );
441    }
442
443    public function testGenerateDecimalLiteralWritesDigitsAroundAPoint(): void
444    {
445        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
446
447        self::assertMatchesRegularExpression(
448            '/^-?\d+\.\d+$/',
449            $lexical->generateDecimalLiteral(),
450        );
451    }
452
453    public function testGenerateFloatLiteralWritesAnExponent(): void
454    {
455        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
456
457        self::assertMatchesRegularExpression(
458            '/[eE][+-]?\d+$/',
459            $lexical->generateFloatLiteral(),
460        );
461    }
462
463    public function testGenerateHexLiteralWritesHexDigitsAfterAPrefix(): void
464    {
465        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
466
467        self::assertMatchesRegularExpression(
468            '/^0x[0-9a-fA-F]+$/',
469            $lexical->generateHexLiteral(),
470        );
471    }
472
473    public function testGenerateQuotedHexLiteralWritesWholeBytesInQuotes(): void
474    {
475        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
476
477        self::assertMatchesRegularExpression(
478            '/^X\'(?:[0-9a-fA-F]{2})+\'$/',
479            $lexical->generateQuotedHexLiteral(),
480        );
481    }
482
483    public function testGenerateBinaryLiteralWritesBitsAfterAPrefix(): void
484    {
485        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
486
487        self::assertMatchesRegularExpression(
488            '/^0b[01]+$/',
489            $lexical->generateBinaryLiteral(),
490        );
491    }
492
493    public function testGenerateHostnameWritesDotSeparatedParts(): void
494    {
495        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
496
497        self::assertMatchesRegularExpression(
498            '/^[A-Za-z0-9.-]+$/',
499            $lexical->generateHostname(),
500        );
501    }
502    public function testRealizeSequenceHonorsThePlannedStringWithoutRequiringTrivia(): void
503    {
504        $grammar = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
505        $plan = GenerationPlan::all()->withLexemes(['TEXT_STRING' => ["'a''b'"]]);
506        self::assertSame("'a''b'", $grammar->realizeSequence(TerminalSequence::fromNames(['TEXT_STRING']), $plan));
507    }
508
509    public function testIsNonOutputDoesNotConfuseOrdinaryValuesWithParserMarkers(): void
510    {
511        $grammar = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
512        self::assertFalse($grammar->isNonOutput('TEXT_STRING'));
513        self::assertFalse($grammar->isNonOutput('UNIMPLEMENTED'));
514    }
515
516    public function testRealizeReportsAnUnimplementedTerminalAtItsActualUse(): void
517    {
518        $grammar = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
519        $this->expectException(LexicalException::class);
520        $this->expectExceptionMessage('UNIMPLEMENTED');
521        $grammar->realize(['UNIMPLEMENTED']);
522    }
523
524    public function testRealizeKeepsContextualUserVariableSpellingWithoutTokenizerEqualityAsAGate(): void
525    {
526        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
527        self::assertSame('@_sqlfaker_identifier', $lexical->realize(['@', 'IDENT']));
528    }
529
530    public function testRealizeRetainsBothRegisteredInequalitySpellings(): void
531    {
532        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
533        self::assertSame('!=', $lexical->realizeSequence(TerminalSequence::fromNames(['NE']), GenerationPlan::all()->withLexemes(['NE' => ['!=']])));
534        self::assertSame('<>', $lexical->realizeSequence(TerminalSequence::fromNames(['NE']), GenerationPlan::all()->withLexemes(['NE' => ['<>']])));
535    }
536    public function testResolveSequenceExposesTheChosenCandidateAndItsOutput(): void
537    {
538        $lexical = new LexicalGrammar(Factory::create(), 'mysql-8.4.7');
539        $output = $lexical->resolveSequence(TerminalSequence::fromNames(['NUM']), null, static fn (int $count): int => $count - 1);
540        self::assertSame('2', $output->parts[0]->lexeme->text);
541        self::assertCount(1, $output->candidates);
542        self::assertSame('', $output->parts[0]->separator);
543    }
544}
545