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