packages/ztd-query-postgres/tests/Unit/Connection/Copy/PgSqlCopySupportTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Connection\Copy;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use ZtdQuery\Platform\Postgres\Connection\Copy\PgSqlCopySupport;
12use ZtdQuery\Schema\TableDefinition;
13
14#[CoversClass(PgSqlCopySupport::class)]
15#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\PgSqlLexerProfile::class)]
16#[CoversClass(\ZtdQuery\Platform\Postgres\Connection\Copy\TargetColumns::class)]
17#[CoversClass(\ZtdQuery\Platform\Postgres\Connection\Copy\TargetSql::class)]
18#[CoversClass(\ZtdQuery\Platform\Postgres\Connection\Copy\TextFields::class)]
19final class PgSqlCopySupportTest extends TestCase
20{
21 public function testTargetRelationRequotesEmbeddedIdentifierQuotes(): void
22 {
23 $support = new PgSqlCopySupport();
24 $target = $support->target(
25 '"Odd Schema"."a""b"',
26 null,
27 new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], ['id'], []),
28 );
29
30 self::assertSame(
31 'SELECT "id" FROM "Odd Schema"."a""b"',
32 $support->selectSql($target),
33 );
34 }
35
36
37
38
39
40 public function testSelectSqlColumnsExcludeGeneratedColumnsAndParseExplicitFields(): void
41 {
42 $codec = new PgSqlCopySupport();
43 $definition = new TableDefinition(
44 ['id', 'display_name', 'computed'],
45 ['id' => 'INTEGER', 'display_name' => 'TEXT', 'computed' => 'TEXT'],
46 ['id'],
47 ['id'],
48 [],
49 generatedExpressions: ['computed' => 'display_name'],
50 );
51
52 self::assertSame(['id', 'display_name'], $codec->target('items', null, $definition)->columns);
53 $target = $codec->target('items', 'ID, "Display Name"', $definition);
54 self::assertSame(['id', 'Display Name'], $target->columns);
55 self::assertSame('SELECT "id", "Display Name" FROM "items"', $codec->selectSql($target));
56 }
57
58
59
60
61
62
63
64
65
66
67
68 public function testEncodeRowUsesPostgreSqlTextEscapesAndNativeScalarOutput(): void
69 {
70 $stream = fopen('php://memory', 'r+');
71 self::assertIsResource($stream);
72 self::assertSame(2, fwrite($stream, "\0\xff"));
73 rewind($stream);
74
75 $row = (new PgSqlCopySupport())->encodeRow(
76 ["a\tb\nc\\d", null, true, false, $stream],
77 '|',
78 '\\N',
79 );
80
81 self::assertSame("a\\tb\\nc\\\\d|\\N|t|f|\\\\x00ff\n", $row);
82 }
83
84 public function testEncodeRowEscapesEveryPostgreSqlControlAndDelimiter(): void
85 {
86 self::assertSame(
87 "\\b\\f\\n\\r\\t\\v\\\\\\|\n",
88 (new PgSqlCopySupport())->encodeRow(["\x08\x0C\n\r\t\x0B\\|"], '|', '\\N'),
89 );
90 self::assertSame(
91 "7|1.5\n",
92 (new PgSqlCopySupport())->encodeRow([7, 1.5], '|', '\\N'),
93 );
94 }
95
96
97
98
99
100 public function testDecodeRowHonorsNullBeforeEscapesAndSupportsByteEscapes(): void
101 {
102 $values = (new PgSqlCopySupport())->decodeRow(
103 "\\N|\\\\N|a\\|b|line\\nfeed|\\101\\x42\r\n",
104 '|',
105 '\\N',
106 );
107
108 self::assertSame([null, '\\N', 'a|b', "line\nfeed", 'AB'], $values);
109 }
110
111 public function testDecodeRowSupportsEveryControlUnknownAndVariableLengthByteEscape(): void
112 {
113 $codec = new PgSqlCopySupport();
114
115 self::assertSame(
116 ["\x08\x0C\n\r\t\x0B\\|q"],
117 $codec->decodeRow('\\b\\f\\n\\r\\t\\v\\\\\\|\\q', '|', '\\N'),
118 );
119 self::assertSame(
120 ["\0", "\x07", "\n", 'A4', "\x04", 'O', 'Oa'],
121 $codec->decodeRow('\\0|\\7|\\12|\\1014|\\x4|\\x4F|\\x4Fa', '|', '\\N'),
122 );
123 self::assertSame(['S', 'tail'], $codec->decodeRow('\\1232tail', '2', '\\N'));
124 self::assertSame(
125 ['prefixN', 'octal-A', 'hex-A'],
126 $codec->decodeRow('prefix\\N|octal-\\101|hex-\\x41', '|', '\\N'),
127 );
128 self::assertSame(
129 ["\xff", '?', "\0", "\xff"],
130 $codec->decodeRow('\\377|\\77|\\x0|\\xff', '|', '\\N'),
131 );
132 self::assertSame(["\x01/"], $codec->decodeRow('\\1/', '|', '\\N'));
133 self::assertSame([null], $codec->decodeRow('prefix\\101', '|', 'prefix\\101'));
134 self::assertSame([null], $codec->decodeRow('prefix\\x41', '|', 'prefix\\x41'));
135 self::assertSame(['value', null, 'tail'], $codec->decodeRow('value|NULL|tail', '|', 'NULL'));
136 }
137
138 #[DataProvider('providerLineEnding')]
139 public function testDecodeRowAcceptsEverySupportedLineEnding(string $lineEnding): void
140 {
141 self::assertSame(['value'], (new PgSqlCopySupport())->decodeRow('value' . $lineEnding, '|', '\\N'));
142 }
143
144
145
146 public function testDecodeRowPreservesEmptyFields(): void
147 {
148 self::assertSame(['', ''], (new PgSqlCopySupport())->decodeRow('|', '|', '\\N'));
149 }
150
151
152
153 /**
154 * @return iterable<string, array{string}>
155 */
156 public static function providerInvalidRelation(): iterable
157 {
158 yield 'empty' => [''];
159 yield 'trailing qualifier' => ['users.'];
160 yield 'operator' => ['users+other'];
161 yield 'too many qualifiers' => ['catalog.public.users'];
162 yield 'non-identifier' => ['*'];
163 }
164
165 /**
166 * @return iterable<string, array{string}>
167 */
168 public static function providerInvalidFields(): iterable
169 {
170 yield 'empty' => [''];
171 yield 'trailing comma' => ['id,'];
172 yield 'expression' => ['id + value'];
173 yield 'duplicate' => ['id, id'];
174 yield 'non-identifier' => ['*'];
175 }
176
177 /**
178 * @return iterable<string, array{string}>
179 */
180 public static function providerLineEnding(): iterable
181 {
182 yield 'none' => [''];
183 yield 'line feed' => ["\n"];
184 yield 'carriage return' => ["\r"];
185 yield 'carriage return and line feed' => ["\r\n"];
186 }
187
188 /**
189 * @return iterable<string, array{string}>
190 */
191 public static function providerInvalidRow(): iterable
192 {
193 yield 'unescaped line feed' => ["first\nsecond"];
194 yield 'unescaped carriage return' => ["first\rsecond"];
195 yield 'incomplete escape' => ['value\\'];
196 yield 'end marker' => ['\\.'];
197 yield 'octal overflow' => ['\\400'];
198 }
199 public function testTableNameResolvesTheFinalQualifiedComponent(): void
200 {
201 self::assertSame('Users', (new PgSqlCopySupport())->tableName('public."Users"'));
202 }
203
204 public function testInsertSqlNumbersEveryRowAndPreservesIdentityOverride(): void
205 {
206 $target = new \ZtdQuery\Platform\CopyTarget(['public', 'users'], ['id', 'name']);
207 self::assertSame('INSERT INTO "public"."users" ("id", "name") OVERRIDING SYSTEM VALUE VALUES ($1, $2), ($3, $4)', (new PgSqlCopySupport())->insertSql($target, 2, true));
208 }
209
210 public function testIsCopyStatementReadsTheFirstSignificantKeyword(): void
211 {
212 $copy = new PgSqlCopySupport();
213 self::assertTrue($copy->isCopyStatement('/* input */ COPY users FROM STDIN'));
214 self::assertFalse($copy->isCopyStatement("SELECT 'COPY users'"));
215 }
216}
217