packages/ztd-query-postgres/tests/Unit/Sql/PostgreSqlLexicalMaskerTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Sql;
6
7use Generator;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\TestCase;
11use ZtdQuery\Platform\Postgres\Sql\PostgreSqlLexicalMasker;
12
13#[CoversClass(PostgreSqlLexicalMasker::class)]
14#[CoversClass(\ZtdQuery\Platform\Postgres\Sql\Lexing\CommentSpan::class)]
15#[CoversClass(\ZtdQuery\Platform\Postgres\Sql\Lexing\QuotedSpan::class)]
16final class PostgreSqlLexicalMaskerTest extends TestCase
17{
18    #[DataProvider('providerComments')]
19    public function testMaskCommentsMasksCommentsAsSingleLexicalSeparators(string $sql, string $expected): void
20    {
21        self::assertSame($expected, PostgreSqlLexicalMasker::maskComments($sql));
22    }
23
24    #[DataProvider('providerQuotedForms')]
25    public function testPreservesCommentMarkersInsideQuotedForms(string $quoted, bool $terminated): void
26    {
27        $sql = 'SELECT ' . $quoted . '/* outside */1';
28        $expected = $terminated ? 'SELECT ' . $quoted . ' 1' : $sql;
29
30        self::assertSame($expected, PostgreSqlLexicalMasker::maskComments($sql));
31    }
32
33    public function testPreservesEscapeStringAtStatementStart(): void
34    {
35        $sql = "E'escaped \\' /* protected */'";
36
37        self::assertSame($sql, PostgreSqlLexicalMasker::maskComments($sql));
38    }
39
40    #[DataProvider('providerStringLiterals')]
41    public function testMaskStringLiteralsMasksStringLiteralsWithoutChangingOffsets(string $sql, string $expected): void
42    {
43        $masked = PostgreSqlLexicalMasker::maskStringLiterals($sql);
44        self::assertSame($expected, $masked);
45        self::assertSame(strlen($sql), strlen($masked));
46    }
47
48    /**
49     * @return Generator<string, array{string, string}>
50     */
51    public static function providerComments(): Generator
52    {
53        yield 'empty query' => ['', ''];
54        yield 'line comment' => ['SELECT-- comment', 'SELECT '];
55        yield 'line comment with newline' => ["SELECT-- comment\n1", "SELECT \n1"];
56        yield 'line comment with carriage return' => ["SELECT-- comment\r1", "SELECT \r1"];
57        yield 'empty line comment' => ["--\nSELECT", " \nSELECT"];
58        yield 'separated minus signs' => ['SELECT- -1', 'SELECT- -1'];
59        yield 'block comment' => ['SELECT/* comment */1', 'SELECT 1'];
60        yield 'minimal block comment' => ['SELECT/**/1', 'SELECT 1'];
61        yield 'nested block comment' => ['SELECT/* outer /* inner */ outer */1', 'SELECT 1'];
62        yield 'adjacent block comments' => ['SELECT/**//**/1', 'SELECT  1'];
63        yield 'unterminated block comment' => ['SELECT/* comment', 'SELECT '];
64        yield 'unterminated nested block comment' => ['SELECT/* outer /* inner */', 'SELECT '];
65        yield 'separated slash and asterisk' => ['SELECT/ *1', 'SELECT/ *1'];
66        yield 'comment-like closing marker' => ['SELECT*/1', 'SELECT*/1'];
67        yield 'comment closing boundary' => ['SELECT/**/*1', 'SELECT *1'];
68        yield 'comment after identifier E suffix' => ["AE'closed'/* comment */", "AE'closed' "];
69        yield 'comment after uppercase identifier E escape lookalike' => ["ZE'escaped \\'/* comment */1", "ZE'escaped \\' 1"];
70        yield 'comment after lowercase identifier E escape lookalike' => ["zE'escaped \\'/* comment */1", "zE'escaped \\' 1"];
71        yield 'comment after underscore E escape lookalike' => ["_E'escaped \\'/* comment */1", "_E'escaped \\' 1"];
72        yield 'comment after number E suffix' => ["0E'closed'/* comment */", "0E'closed' "];
73        yield 'comment after dollar E suffix' => ["\$E'closed'/* comment */", "\$E'closed' "];
74        yield 'comment after native parameter' => ['$1/* comment */', '$1 '];
75        yield 'comment between invalid numeric tags' => ['$9$/* comment */$9$', '$9$ $9$'];
76        yield 'comment after invalid hyphenated tag' => ['$a-b$/* comment */1', '$a-b$ 1'];
77        yield 'comment after incomplete tag' => ['$tag/* comment */1', '$tag 1'];
78        yield 'incomplete tag at end' => ['$tag', '$tag'];
79        yield 'standalone dollar at end' => ['$', '$'];
80        yield 'normal string does not use backslash escapes' => ["'escaped \\'/* comment */1", "'escaped \\' 1"];
81        yield 'qualified identifier E escape lookalike' => ["SELECT AE'escaped \\'/* comment */1", "SELECT AE'escaped \\' 1"];
82        yield 'underscored identifier E escape lookalike' => ["SELECT _AE'escaped \\'/* comment */1", "SELECT _AE'escaped \\' 1"];
83        yield 'identifier-attached dollar tag is not quoted' => ['name$tag$/* comment */$tag$', 'name$tag$ $tag$'];
84        yield 'digit-attached dollar tag is not quoted' => ['0$tag$/* comment */$tag$', '0$tag$ $tag$'];
85        yield 'underscore-attached dollar tag is not quoted' => ['_$tag$/* comment */$tag$', '_$tag$ $tag$'];
86    }
87
88    /**
89     * @return Generator<string, array{string, bool}>
90     */
91    public static function providerQuotedForms(): Generator
92    {
93        yield 'single quoted line marker' => ["'-- comment'", true];
94        yield 'single quoted block marker' => ["'/* comment */'", true];
95        yield 'empty single quote' => ["''", true];
96        yield 'doubled single quote' => ["'value''/* comment */'", true];
97        yield 'triple single quote closing run' => ["'value'''", true];
98        yield 'unterminated single quote' => ["'/* comment", false];
99        yield 'double quoted line marker' => ['"-- comment"', true];
100        yield 'double quoted block marker' => ['"/* comment */"', true];
101        yield 'empty double quote' => ['""', true];
102        yield 'doubled double quote' => ['"value""/* comment */"', true];
103        yield 'unterminated double quote' => ['"/* comment', false];
104        yield 'uppercase escape string' => ["E'escaped \\' /* comment */'", true];
105        yield 'lowercase escape string' => ["e'escaped \\' -- comment'", true];
106        yield 'escape string closes immediately after escaped quote' => ["E'\\''", true];
107        yield 'escape string ending in backslash' => ["E'escaped \\", false];
108        yield 'untagged dollar quote' => ['$$/* comment */$$', true];
109        yield 'uppercase boundary dollar tag' => ['$A$-- comment$A$', true];
110        yield 'uppercase ending boundary dollar tag' => ['$Z$-- comment$Z$', true];
111        yield 'lowercase boundary dollar tag' => ['$a$-- comment$a$', true];
112        yield 'lowercase ending boundary dollar tag' => ['$z$-- comment$z$', true];
113        yield 'tagged dollar quote' => ['$tag$-- comment$tag$', true];
114        yield 'delimiter prefix inside dollar quote' => ['$tag$value $tag/* protected */$tag$', true];
115        yield 'identifier dollar quote' => ['$_tag9$/* comment */$_tag9$', true];
116        yield 'unterminated dollar quote' => ['$tag$/* comment */', false];
117        yield 'standalone dollar' => ['$', true];
118    }
119
120    /**
121     * @return Generator<string, array{string, string}>
122     */
123    public static function providerStringLiterals(): Generator
124    {
125        yield 'empty query' => ['', ''];
126        yield 'empty string' => ["''", '  '];
127        yield 'single quoted string' => ["SELECT 'WHERE' FROM name", 'SELECT ' . str_repeat(' ', 7) . ' FROM name'];
128        yield 'doubled single quote' => ["'value''WHERE' FROM name", str_repeat(' ', 14) . ' FROM name'];
129        yield 'unterminated string' => ["'WHERE", str_repeat(' ', 6)];
130        yield 'uppercase escape string' => ["E'escaped \\' WHERE' FROM name", 'E' . str_repeat(' ', 18) . ' FROM name'];
131        yield 'lowercase escape string' => ["e'escaped \\' WHERE' FROM name", 'e' . str_repeat(' ', 18) . ' FROM name'];
132        yield 'standard backslash does not escape quote' => ["'closed \\' WHERE name", str_repeat(' ', 10) . ' WHERE name'];
133        yield 'untagged dollar quote' => ['$$WHERE$$ FROM name', str_repeat(' ', 9) . ' FROM name'];
134        yield 'tagged dollar quote' => ['$tag$WHERE$tag$ FROM name', str_repeat(' ', 15) . ' FROM name'];
135        yield 'prefixed untagged dollar quote' => ['SELECT $$WHERE$$ FROM name', 'SELECT ' . str_repeat(' ', 9) . ' FROM name'];
136        yield 'prefixed tagged dollar quote' => ['SELECT $tag$WHERE$tag$ FROM name', 'SELECT ' . str_repeat(' ', 15) . ' FROM name'];
137        yield 'unterminated dollar quote' => ['$tag$WHERE', str_repeat(' ', 10)];
138        yield 'double quoted identifier' => ['"WHERE" FROM name', '"WHERE" FROM name'];
139        yield 'native parameter' => ['$1 FROM name', '$1 FROM name'];
140        yield 'invalid numeric tag' => ['$9$WHERE$9$ FROM name', '$9$WHERE$9$ FROM name'];
141        yield 'identifier-attached tag' => ['name$tag$WHERE$tag$ FROM name', 'name$tag$WHERE$tag$ FROM name'];
142        yield 'digit-attached tag' => ['0$tag$WHERE$tag$ FROM name', '0$tag$WHERE$tag$ FROM name'];
143        yield 'underscore-attached tag' => ['_$tag$WHERE$tag$ FROM name', '_$tag$WHERE$tag$ FROM name'];
144        yield 'newlines preserve length' => ["'line1\nline2' FROM name", str_repeat(' ', 13) . ' FROM name'];
145    }
146}
147