Skip to content

Commit 979a581

Browse files
fix(#351): correct round-trip handling of special characters between PHP and PostgreSQL (#391)
1 parent a31b944 commit 979a581

File tree

4 files changed

+225
-34
lines changed

4 files changed

+225
-34
lines changed

ci/php-cs-fixer/config.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
'simplified_null_return' => false,
4848
'single_line_comment_style' => false,
4949
'static_lambda' => true,
50+
'string_implicit_backslashes' => false,
5051
'yoda_style' => false,
5152
]
5253
)

src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ class PostgresArrayToPHPArrayTransformer
2121

2222
/**
2323
* Transforms a PostgreSQL text array to a PHP array.
24-
* This method supports only single-dimensioned text arrays and
25-
* relays on the default escaping strategy in PostgreSQL (double quotes).
24+
* This method supports only single-dimensional text arrays and
25+
* relies on the default escaping strategy in PostgreSQL (double quotes).
2626
*
27-
* @throws InvalidArrayFormatException when the input is a multi-dimensional array or has invalid format
27+
* @throws InvalidArrayFormatException when the input is a multi-dimensional array or has an invalid format
2828
*/
2929
public static function transformPostgresArrayToPHPArray(string $postgresArray): array
3030
{
@@ -86,10 +86,7 @@ public static function transformPostgresArrayToPHPArray(string $postgresArray):
8686
return self::parsePostgresArrayManually($content);
8787
}
8888

89-
return \array_map(
90-
static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value,
91-
(array) $decoded
92-
);
89+
return (array) $decoded;
9390
}
9491

9592
private static function parsePostgresArrayManually(string $content): array
@@ -215,33 +212,37 @@ private static function processNumericValue(string $value): float|int
215212

216213
private static function unescapeString(string $value): string
217214
{
215+
/**
216+
* PostgreSQL array escaping rules:
217+
* \\ -> \ (escaped backslash becomes literal backslash)
218+
* \" -> " (escaped quote becomes literal quote)
219+
* Everything else remains as-is
220+
*/
218221
$result = '';
219-
$len = \strlen($value);
220-
$i = 0;
221-
while ($i < $len) {
222-
if ($value[$i] === '\\') {
223-
$start = $i;
224-
while ($i < $len && $value[$i] === '\\') {
225-
$i++;
226-
}
227-
228-
$slashCount = $i - $start;
229-
$nextChar = $i < $len ? $value[$i] : '';
230-
if ($nextChar === '"' || $nextChar === '\\') {
231-
$result .= \str_repeat('\\', \intdiv($slashCount, 2));
232-
if ($slashCount % 2 === 1) {
233-
$result .= $nextChar;
234-
$i++;
235-
}
222+
$length = \strlen($value);
223+
$position = 0;
224+
225+
while ($position < $length) {
226+
if ($value[$position] === '\\' && $position + 1 < $length) {
227+
$nextChar = $value[$position + 1];
228+
229+
if ($nextChar === '\\') {
230+
// \\ -> \
231+
$result .= '\\';
232+
$position += 2;
233+
} elseif ($nextChar === '"') {
234+
// \" -> "
235+
$result .= '"';
236+
$position += 2;
236237
} else {
237-
$result .= \str_repeat('\\', $slashCount);
238+
// \ followed by anything else - keep the backslash
239+
$result .= '\\';
240+
$position++;
238241
}
239-
240-
continue;
242+
} else {
243+
$result .= $value[$position];
244+
$position++;
241245
}
242-
243-
$result .= $value[$i];
244-
$i++;
245246
}
246247

247248
return $result;
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Integration\MartinGeorgiev\Utils;
6+
7+
use MartinGeorgiev\Utils\Exception\InvalidArrayFormatException;
8+
use MartinGeorgiev\Utils\PostgresArrayToPHPArrayTransformer;
9+
use PHPUnit\Framework\Attributes\DataProvider;
10+
use Tests\Integration\MartinGeorgiev\TestCase;
11+
12+
class PostgresArrayToPHPArrayTransformerTest extends TestCase
13+
{
14+
private const TABLE_NAME = 'array_test_table';
15+
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
$this->createTestTable();
20+
}
21+
22+
/**
23+
* @return array<int, array{0: array{description: string, input: array<int, string>}}>
24+
*/
25+
public static function provideArrayTestCases(): array
26+
{
27+
return [
28+
[['description' => 'Simple array', 'input' => ['hello', 'world']]],
29+
[['description' => 'Empty array', 'input' => []]],
30+
[['description' => 'Single empty string', 'input' => ['']]],
31+
[['description' => 'Quotes and backslashes', 'input' => ['"quoted"', 'back\\slash']]],
32+
[['description' => 'Windows paths', 'input' => ['C:\\Windows\\System32']]],
33+
[['description' => 'Escaped quotes', 'input' => ['\"escaped\"']]],
34+
[['description' => 'Double backslashes', 'input' => ['\\\\double\\\\']]],
35+
[['description' => 'Unicode', 'input' => ['Hello 世界', '🌍 Earth']]],
36+
[['description' => 'Special chars', 'input' => ['!@#$%^&*()_+=-}{[]|":;\'?><,./']]],
37+
[['description' => 'GitHub #351 - regression #1', 'input' => ['⥀!@#$%^&*()_+=-}{[]|":;\'\?><,./']]],
38+
[['description' => 'GitHub #351 - regression #2', 'input' => ['⥀!@#$%^&*()_+=-}{[]|":;\'\?><,./', 'text']]],
39+
[['description' => 'Curly braces', 'input' => ['{foo,bar}']]],
40+
[['description' => 'Spaces', 'input' => [' spaces ']]],
41+
[['description' => 'Trailing backslash', 'input' => ['trailing\\']]],
42+
[['description' => 'Leading backslash', 'input' => ['\\leading']]],
43+
[['description' => 'Mixed', 'input' => ['simple', '"quoted"', 'back\\slash', '']]],
44+
];
45+
}
46+
47+
/**
48+
* @param array{description: string, input: array<int, string>} $testCase
49+
*/
50+
#[DataProvider('provideArrayTestCases')]
51+
public function test_array_round_trip(array $testCase): void
52+
{
53+
$id = $this->insertArray($testCase['input']);
54+
55+
$this->assertArrayRoundTrip($id, $testCase['input'], $testCase['description']);
56+
}
57+
58+
/**
59+
* @return array<int, array{0: array{description: string, input: string}}>
60+
*/
61+
public static function provideInvalidArrayFormats(): array
62+
{
63+
return [
64+
[['description' => 'Multi-dimensional', 'input' => '{{1,2},{3,4}}']],
65+
[['description' => 'Unclosed quote', 'input' => '{1,2,"unclosed']],
66+
[['description' => 'Invalid format', 'input' => '{invalid"format}']],
67+
[['description' => 'Malformed nesting', 'input' => '{1,{2,3},4}']],
68+
];
69+
}
70+
71+
#[DataProvider('provideInvalidArrayFormats')]
72+
public function test_invalid_array_formats_throw_exceptions(array $testCase): void
73+
{
74+
$this->expectException(InvalidArrayFormatException::class);
75+
PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($testCase['input']); // @phpstan-ignore-line
76+
}
77+
78+
private function createTestTable(): void
79+
{
80+
$this->dropTestTableIfItExists(self::TABLE_NAME);
81+
$this->connection->executeStatement(\sprintf('
82+
CREATE TABLE %s (
83+
id SERIAL PRIMARY KEY,
84+
test_array TEXT[]
85+
)
86+
', self::TABLE_NAME));
87+
}
88+
89+
/**
90+
* @template T
91+
*
92+
* @param array<string, mixed> $params
93+
* @param callable(string): T $transform
94+
*
95+
* @return T
96+
*/
97+
private function retrieveFromDatabase(string $sql, array $params, callable $transform): mixed
98+
{
99+
$row = $this->connection->executeQuery($sql, $params)->fetchAssociative();
100+
101+
if ($row === false || !isset($row['test_array']) || !\is_string($row['test_array'])) {
102+
throw new \RuntimeException('Failed to retrieve array data');
103+
}
104+
105+
return $transform($row['test_array']);
106+
}
107+
108+
/**
109+
* @return array<int, string>
110+
*/
111+
private function retrieveArray(int $id): array
112+
{
113+
$row = $this->connection->executeQuery(
114+
\sprintf('SELECT test_array FROM %s WHERE id = :id', self::TABLE_NAME),
115+
['id' => $id]
116+
)->fetchAssociative();
117+
118+
if ($row === false || !isset($row['test_array'])) {
119+
throw new \RuntimeException(\sprintf('Failed to retrieve array data for ID %d', $id));
120+
}
121+
122+
if (!\is_string($row['test_array'])) {
123+
throw new \RuntimeException(\sprintf('Expected string for test_array, got %s', \gettype($row['test_array'])));
124+
}
125+
126+
/** @var string $postgresArray */
127+
$postgresArray = $row['test_array'];
128+
129+
return PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresArray); // @phpstan-ignore-line
130+
}
131+
132+
private function retrieveArrayAsText(int $id): string
133+
{
134+
/** @var string $result */
135+
$result = $this->retrieveFromDatabase(
136+
\sprintf('SELECT test_array::text FROM %s WHERE id = :id', self::TABLE_NAME),
137+
['id' => $id],
138+
static fn (string $value): string => $value
139+
);
140+
141+
return $result;
142+
}
143+
144+
/**
145+
* @param array<int, string> $arrayData
146+
*/
147+
private function insertArray(array $arrayData): int
148+
{
149+
$result = $this->connection->executeQuery(
150+
\sprintf('INSERT INTO %s (test_array) VALUES (:arrayData) RETURNING id', self::TABLE_NAME),
151+
['arrayData' => $arrayData],
152+
['arrayData' => 'text[]']
153+
);
154+
155+
$row = $result->fetchAssociative();
156+
if ($row === false || !isset($row['id']) || !\is_numeric($row['id'])) {
157+
throw new \RuntimeException('Failed to insert array data');
158+
}
159+
160+
return (int) $row['id'];
161+
}
162+
163+
/**
164+
* @param array<int, string> $expected
165+
*/
166+
private function assertArrayRoundTrip(int $id, array $expected, string $description): void
167+
{
168+
// Test direct retrieval
169+
$retrieved = $this->retrieveArray($id);
170+
self::assertEquals(
171+
$expected,
172+
$retrieved,
173+
\sprintf('Direct retrieval failed for %s', $description)
174+
);
175+
176+
// Test text representation
177+
$postgresText = $this->retrieveArrayAsText($id);
178+
$parsed = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresText);
179+
self::assertEquals(
180+
$expected,
181+
$parsed,
182+
\sprintf('Text representation parsing failed for %s', $description)
183+
);
184+
}
185+
}

tests/Unit/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ public static function provideValidTransformations(): array
132132
],
133133
'with only backslashes' => [
134134
'phpValue' => ['\\'],
135-
'postgresValue' => '{\}',
135+
'postgresValue' => '{"\\\\"}',
136136
],
137137
'with only double quotes' => [
138138
'phpValue' => ['"'],
@@ -142,9 +142,13 @@ public static function provideValidTransformations(): array
142142
'phpValue' => ['', ''],
143143
'postgresValue' => '{"",""}',
144144
],
145-
'github #351 regression: string with special characters and backslash' => [
146-
'phpValue' => ["!@#\\$%^&*()_+=-}{[]|\":;'\\?><,./"],
147-
'postgresValue' => '{"!@#\$%^&*()_+=-}{[]|\":;\'\?><,./"}',
145+
'github #351 regression #1: string with special characters and backslash' => [
146+
'phpValue' => ['⥀!@#$%^&*()_+=-}{[]|":;\'\?><,./'],
147+
'postgresValue' => '{"⥀!@#$%^&*()_+=-}{[]|\":;\'\\?><,./"}',
148+
],
149+
'github #351 regression #2: string with special characters, backslash and additional element' => [
150+
'phpValue' => ['⥀!@#$%^&*()_+=-}{[]|":;\'\?><,./', 'text'],
151+
'postgresValue' => '{"⥀!@#$%^&*()_+=-}{[]|\":;\'\\?><,./",text}',
148152
],
149153
'backslash before backslash' => [
150154
'phpValue' => ['a\b'],

0 commit comments

Comments
 (0)