Skip to content

Commit 77eed3e

Browse files
fix(#482): always preserve strings (even if they are obviously numerical) when transforming a TEXTARRAY value into a PHP array (#488)
1 parent b5aef7d commit 77eed3e

File tree

6 files changed

+171
-40
lines changed

6 files changed

+171
-40
lines changed

src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -62,31 +62,9 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?array
6262

6363
protected function transformFromPostgresTextArray(string $postgresValue): array
6464
{
65-
$values = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue);
66-
67-
// No matter what the original PHP array items' data types were,
68-
// once they are stored in PostgreSQL, all of them will become strings.
69-
// Therefore, we need to ensure all items in the returned PHP array are strings.
70-
foreach ($values as $key => $value) {
71-
if (\is_string($value)) {
72-
continue;
73-
}
74-
75-
if (\is_bool($value)) {
76-
$values[$key] = $value ? 'true' : 'false';
77-
78-
continue;
79-
}
80-
81-
if ($value === null) {
82-
$values[$key] = 'null';
83-
84-
continue;
85-
}
86-
87-
$values[$key] = (string) $value; // @phpstan-ignore-line
88-
}
89-
90-
return $values;
65+
return PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray(
66+
$postgresValue,
67+
preserveStringTypes: true
68+
);
9169
}
9270
}

src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ class PostgresArrayToPHPArrayTransformer
2424
* This method supports only single-dimensional text arrays and
2525
* relies on the default escaping strategy in PostgreSQL (double quotes).
2626
*
27+
* @param bool $preserveStringTypes When true, all unquoted values are preserved as strings without type inference.
28+
* This is useful for text arrays where PostgreSQL may omit quotes for values that look numeric.
29+
*
2730
* @throws InvalidArrayFormatException when the input is a multi-dimensional array or has an invalid format
2831
*/
29-
public static function transformPostgresArrayToPHPArray(string $postgresArray): array
32+
public static function transformPostgresArrayToPHPArray(string $postgresArray, bool $preserveStringTypes = false): array
3033
{
3134
$trimmed = \trim($postgresArray);
3235

@@ -70,32 +73,32 @@ public static function transformPostgresArrayToPHPArray(string $postgresArray):
7073
}
7174
}
7275

73-
// Check for unclosed quotes
7476
if ($inQuotes) {
7577
throw InvalidArrayFormatException::invalidFormat('Unclosed quotes in array');
7678
}
7779

78-
// First try with json_decode for properly quoted values
80+
if ($preserveStringTypes) {
81+
return self::parsePostgresArrayManually($content, true);
82+
}
83+
7984
$jsonArray = '['.\trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY).']';
8085

8186
/** @var array<int, mixed>|null $decoded */
8287
$decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
83-
84-
// If json_decode fails, try manual parsing for unquoted strings
85-
if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) {
86-
return self::parsePostgresArrayManually($content);
88+
$jsonDecodingFailed = $decoded === null && \json_last_error() !== JSON_ERROR_NONE;
89+
if ($jsonDecodingFailed) {
90+
return self::parsePostgresArrayManually($content, false);
8791
}
8892

8993
return (array) $decoded;
9094
}
9195

92-
private static function parsePostgresArrayManually(string $content): array
96+
private static function parsePostgresArrayManually(string $content, bool $preserveStringTypes): array
9397
{
9498
if ($content === '') {
9599
return [];
96100
}
97101

98-
// Parse the array manually, handling quoted and unquoted values
99102
$result = [];
100103
$inQuotes = false;
101104
$currentValue = '';
@@ -125,7 +128,7 @@ private static function parsePostgresArrayManually(string $content): array
125128
$currentValue .= $char;
126129
} elseif ($char === ',' && !$inQuotes) {
127130
// End of value
128-
$result[] = self::processPostgresValue($currentValue);
131+
$result[] = self::processPostgresValue($currentValue, $preserveStringTypes);
129132
$currentValue = '';
130133
} else {
131134
$currentValue .= $char;
@@ -134,19 +137,29 @@ private static function parsePostgresArrayManually(string $content): array
134137

135138
// Add the last value
136139
if ($currentValue !== '') {
137-
$result[] = self::processPostgresValue($currentValue);
140+
$result[] = self::processPostgresValue($currentValue, $preserveStringTypes);
138141
}
139142

140143
return $result;
141144
}
142145

143146
/**
144147
* Process a single value from a PostgreSQL array.
148+
*
149+
* @param bool $preserveStringTypes When true, skip type inference for unquoted values
145150
*/
146-
private static function processPostgresValue(string $value): mixed
151+
private static function processPostgresValue(string $value, bool $preserveStringTypes): mixed
147152
{
148153
$value = \trim($value);
149154

155+
if ($preserveStringTypes) {
156+
if (self::isQuotedString($value)) {
157+
return self::processQuotedString($value);
158+
}
159+
160+
return $value;
161+
}
162+
150163
if (self::isNullValue($value)) {
151164
return null;
152165
}
@@ -189,7 +202,6 @@ private static function isQuotedString(string $value): bool
189202

190203
private static function processQuotedString(string $value): string
191204
{
192-
// Remove the quotes and unescape the string
193205
$unquoted = \substr($value, 1, -1);
194206

195207
return self::unescapeString($unquoted);
@@ -202,7 +214,6 @@ private static function isNumericValue(string $value): bool
202214

203215
private static function processNumericValue(string $value): float|int
204216
{
205-
// Convert to int or float as appropriate
206217
if (\str_contains($value, '.') || \stripos($value, 'e') !== false) {
207218
return (float) $value;
208219
}

tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTypeTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ protected function getPostgresTypeName(): string
2020
}
2121

2222
#[DataProvider('provideValidTransformations')]
23+
#[DataProvider('provideTypeInferenceTestCases')]
2324
#[Test]
2425
public function can_handle_array_values(string $testName, array $arrayValue): void
2526
{
@@ -73,4 +74,45 @@ public static function provideValidTransformations(): array
7374
]],
7475
];
7576
}
77+
78+
/**
79+
* Verify that JsonbArray performs type inference correctly (default behavior) as
80+
* JSON values should maintain their proper types (integers, floats, booleans, null).
81+
*/
82+
public static function provideTypeInferenceTestCases(): array
83+
{
84+
return [
85+
'numeric types preserved' => ['numeric types should be preserved correctly', [
86+
[
87+
'integer' => 42,
88+
'float' => 3.14,
89+
'zero' => 0,
90+
'negative' => -123,
91+
],
92+
]],
93+
'decimal numbers as floats' => ['decimal numbers should be floats', [
94+
[
95+
'price' => 502.00,
96+
'tax' => 505.50,
97+
'discount' => 0.99,
98+
],
99+
]],
100+
'boolean and null types' => ['boolean and null types should be preserved', [
101+
[
102+
'active' => true,
103+
'deleted' => false,
104+
'metadata' => null,
105+
],
106+
]],
107+
'mixed numeric and string types' => ['mixed types should maintain their types', [
108+
[
109+
'id' => 123,
110+
'name' => 'Product',
111+
'price' => 99.99,
112+
'available' => true,
113+
'description' => null,
114+
],
115+
]],
116+
];
117+
}
76118
}

tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTypeTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ protected function getPostgresTypeName(): string
2121

2222
#[DataProvider('provideValidTransformations')]
2323
#[DataProvider('provideGithubIssue424TestCases')]
24+
#[DataProvider('provideGithubIssue482TestCases')]
2425
#[Test]
2526
public function can_handle_array_values(string $testName, array $arrayValue): void
2627
{
@@ -83,4 +84,25 @@ public static function provideGithubIssue424TestCases(): array
8384
],
8485
];
8586
}
87+
88+
/**
89+
* This test scenarios specifically verify the fix for GitHub issue #482
90+
* where decimal strings with trailing zeros (e.g., "502.00", "505.00") were
91+
* being truncated to "502" and "505" when round-tripping through the database.
92+
* PostgreSQL returns these unquoted as {502.00,505.00}, and the fix ensures
93+
* they are preserved as strings with trailing zeros intact.
94+
*/
95+
public static function provideGithubIssue482TestCases(): array
96+
{
97+
return [
98+
'mixed decimal formats' => [
99+
'Mixed decimal formats should be preserved',
100+
['42.00', '123.50', '0.00', '999.99', '1.0', '2.000'],
101+
],
102+
'decimal zero variations' => [
103+
'Decimal zero variations should be preserved',
104+
['0.0', '0.00', '0.000'],
105+
],
106+
];
107+
}
86108
}

tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,19 @@ public static function provideGithubIssue424TestCases(): array
179179
],
180180
];
181181
}
182+
183+
#[Test]
184+
public function can_preserve_trailing_zeros_in_strings_that_look_like_decimals(): void
185+
{
186+
$postgresValue = '{42.00,123.50,0.00,999.99,502.00,505.00}';
187+
$expectedResult = ['42.00', '123.50', '0.00', '999.99', '502.00', '505.00'];
188+
189+
$result = $this->fixture->convertToPHPValue($postgresValue, $this->platform);
190+
191+
$this->assertSame($expectedResult, $result, 'Trailing zeros in decimal strings should be preserved');
192+
193+
foreach ($result as $value) {
194+
$this->assertIsString($value, \sprintf('All values in text[] should be strings, but %s is not', $value));
195+
}
196+
}
182197
}

tests/Unit/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,67 @@ public static function provideInvalidPostgresArrays(): array
262262
],
263263
];
264264
}
265+
266+
#[DataProvider('providePreserveStringTypesTestCases')]
267+
#[Test]
268+
public function can_preserve_string_types_when_requested(array $expectedPhpValue, string $postgresValue): void
269+
{
270+
$result = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue, preserveStringTypes: true);
271+
272+
$this->assertSame($expectedPhpValue, $result);
273+
274+
// Verify all values are strings when preserveStringTypes is true
275+
foreach ($result as $value) {
276+
$this->assertIsString($value, \sprintf('All values should be strings when preserveStringTypes is true, but found a non-string value: %s', \var_export($value, true)));
277+
}
278+
}
279+
280+
/**
281+
* @return array<string, array{expectedPhpValue: array, postgresValue: string}>
282+
*/
283+
public static function providePreserveStringTypesTestCases(): array
284+
{
285+
return [
286+
'floats with trailing zeros - issue #482' => [
287+
'expectedPhpValue' => ['502.00', '505.00', '123.50'],
288+
'postgresValue' => '{502.00,505.00,123.50}',
289+
],
290+
'zero with decimals' => [
291+
'expectedPhpValue' => ['0.00', '0.0', '0.000'],
292+
'postgresValue' => '{0.00,0.0,0.000}',
293+
],
294+
'mixed numeric-looking and text values' => [
295+
'expectedPhpValue' => ['502.00', 'some text', '123.50', 'another'],
296+
'postgresValue' => '{502.00,some text,123.50,another}',
297+
],
298+
'scientific notation as strings' => [
299+
'expectedPhpValue' => ['1.23e10', '4.56E-5', '7.89e+3'],
300+
'postgresValue' => '{1.23e10,4.56E-5,7.89e+3}',
301+
],
302+
'already quoted values with decimals' => [
303+
'expectedPhpValue' => ['502.00', '123.50'],
304+
'postgresValue' => '{"502.00","123.50"}',
305+
],
306+
'mixed quoted and unquoted with decimals' => [
307+
'expectedPhpValue' => ['502.00', '123.50', 'text', '789.00'],
308+
'postgresValue' => '{502.00,"123.50",text,"789.00"}',
309+
],
310+
'integers should remain as strings' => [
311+
'expectedPhpValue' => ['1', '2', '3', '100'],
312+
'postgresValue' => '{1,2,3,100}',
313+
],
314+
'boolean-like values as strings' => [
315+
'expectedPhpValue' => ['true', 'false', 't', 'f'],
316+
'postgresValue' => '{true,false,t,f}',
317+
],
318+
'null values as strings' => [
319+
'expectedPhpValue' => ['null', 'NULL'],
320+
'postgresValue' => '{null,NULL}',
321+
],
322+
'empty strings preserved' => [
323+
'expectedPhpValue' => ['', 'text', ''],
324+
'postgresValue' => '{"",text,""}',
325+
],
326+
];
327+
}
265328
}

0 commit comments

Comments
 (0)