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