diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php index 2152f5bf..d920c651 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php @@ -62,31 +62,9 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?array protected function transformFromPostgresTextArray(string $postgresValue): array { - $values = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue); - - // No matter what the original PHP array items' data types were, - // once they are stored in PostgreSQL, all of them will become strings. - // Therefore, we need to ensure all items in the returned PHP array are strings. - foreach ($values as $key => $value) { - if (\is_string($value)) { - continue; - } - - if (\is_bool($value)) { - $values[$key] = $value ? 'true' : 'false'; - - continue; - } - - if ($value === null) { - $values[$key] = 'null'; - - continue; - } - - $values[$key] = (string) $value; // @phpstan-ignore-line - } - - return $values; + return PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray( + $postgresValue, + preserveStringTypes: true + ); } } diff --git a/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php b/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php index 3fe97368..153f723e 100644 --- a/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php +++ b/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php @@ -24,9 +24,12 @@ class PostgresArrayToPHPArrayTransformer * This method supports only single-dimensional text arrays and * relies on the default escaping strategy in PostgreSQL (double quotes). * + * @param bool $preserveStringTypes When true, all unquoted values are preserved as strings without type inference. + * This is useful for text arrays where PostgreSQL may omit quotes for values that look numeric. + * * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has an invalid format */ - public static function transformPostgresArrayToPHPArray(string $postgresArray): array + public static function transformPostgresArrayToPHPArray(string $postgresArray, bool $preserveStringTypes = false): array { $trimmed = \trim($postgresArray); @@ -70,32 +73,32 @@ public static function transformPostgresArrayToPHPArray(string $postgresArray): } } - // Check for unclosed quotes if ($inQuotes) { throw InvalidArrayFormatException::invalidFormat('Unclosed quotes in array'); } - // First try with json_decode for properly quoted values + if ($preserveStringTypes) { + return self::parsePostgresArrayManually($content, true); + } + $jsonArray = '['.\trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY).']'; /** @var array|null $decoded */ $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING); - - // If json_decode fails, try manual parsing for unquoted strings - if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) { - return self::parsePostgresArrayManually($content); + $jsonDecodingFailed = $decoded === null && \json_last_error() !== JSON_ERROR_NONE; + if ($jsonDecodingFailed) { + return self::parsePostgresArrayManually($content, false); } return (array) $decoded; } - private static function parsePostgresArrayManually(string $content): array + private static function parsePostgresArrayManually(string $content, bool $preserveStringTypes): array { if ($content === '') { return []; } - // Parse the array manually, handling quoted and unquoted values $result = []; $inQuotes = false; $currentValue = ''; @@ -125,7 +128,7 @@ private static function parsePostgresArrayManually(string $content): array $currentValue .= $char; } elseif ($char === ',' && !$inQuotes) { // End of value - $result[] = self::processPostgresValue($currentValue); + $result[] = self::processPostgresValue($currentValue, $preserveStringTypes); $currentValue = ''; } else { $currentValue .= $char; @@ -134,7 +137,7 @@ private static function parsePostgresArrayManually(string $content): array // Add the last value if ($currentValue !== '') { - $result[] = self::processPostgresValue($currentValue); + $result[] = self::processPostgresValue($currentValue, $preserveStringTypes); } return $result; @@ -142,11 +145,21 @@ private static function parsePostgresArrayManually(string $content): array /** * Process a single value from a PostgreSQL array. + * + * @param bool $preserveStringTypes When true, skip type inference for unquoted values */ - private static function processPostgresValue(string $value): mixed + private static function processPostgresValue(string $value, bool $preserveStringTypes): mixed { $value = \trim($value); + if ($preserveStringTypes) { + if (self::isQuotedString($value)) { + return self::processQuotedString($value); + } + + return $value; + } + if (self::isNullValue($value)) { return null; } @@ -189,7 +202,6 @@ private static function isQuotedString(string $value): bool private static function processQuotedString(string $value): string { - // Remove the quotes and unescape the string $unquoted = \substr($value, 1, -1); return self::unescapeString($unquoted); @@ -202,7 +214,6 @@ private static function isNumericValue(string $value): bool private static function processNumericValue(string $value): float|int { - // Convert to int or float as appropriate if (\str_contains($value, '.') || \stripos($value, 'e') !== false) { return (float) $value; } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTypeTest.php index 7b991ac9..5a159b97 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTypeTest.php @@ -20,6 +20,7 @@ protected function getPostgresTypeName(): string } #[DataProvider('provideValidTransformations')] + #[DataProvider('provideTypeInferenceTestCases')] #[Test] public function can_handle_array_values(string $testName, array $arrayValue): void { @@ -73,4 +74,45 @@ public static function provideValidTransformations(): array ]], ]; } + + /** + * Verify that JsonbArray performs type inference correctly (default behavior) as + * JSON values should maintain their proper types (integers, floats, booleans, null). + */ + public static function provideTypeInferenceTestCases(): array + { + return [ + 'numeric types preserved' => ['numeric types should be preserved correctly', [ + [ + 'integer' => 42, + 'float' => 3.14, + 'zero' => 0, + 'negative' => -123, + ], + ]], + 'decimal numbers as floats' => ['decimal numbers should be floats', [ + [ + 'price' => 502.00, + 'tax' => 505.50, + 'discount' => 0.99, + ], + ]], + 'boolean and null types' => ['boolean and null types should be preserved', [ + [ + 'active' => true, + 'deleted' => false, + 'metadata' => null, + ], + ]], + 'mixed numeric and string types' => ['mixed types should maintain their types', [ + [ + 'id' => 123, + 'name' => 'Product', + 'price' => 99.99, + 'available' => true, + 'description' => null, + ], + ]], + ]; + } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTypeTest.php index 81dbf8f1..0e726319 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTypeTest.php @@ -21,6 +21,7 @@ protected function getPostgresTypeName(): string #[DataProvider('provideValidTransformations')] #[DataProvider('provideGithubIssue424TestCases')] + #[DataProvider('provideGithubIssue482TestCases')] #[Test] public function can_handle_array_values(string $testName, array $arrayValue): void { @@ -83,4 +84,25 @@ public static function provideGithubIssue424TestCases(): array ], ]; } + + /** + * This test scenarios specifically verify the fix for GitHub issue #482 + * where decimal strings with trailing zeros (e.g., "502.00", "505.00") were + * being truncated to "502" and "505" when round-tripping through the database. + * PostgreSQL returns these unquoted as {502.00,505.00}, and the fix ensures + * they are preserved as strings with trailing zeros intact. + */ + public static function provideGithubIssue482TestCases(): array + { + return [ + 'mixed decimal formats' => [ + 'Mixed decimal formats should be preserved', + ['42.00', '123.50', '0.00', '999.99', '1.0', '2.000'], + ], + 'decimal zero variations' => [ + 'Decimal zero variations should be preserved', + ['0.0', '0.00', '0.000'], + ], + ]; + } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php index f969587c..14b770e4 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php @@ -179,4 +179,19 @@ public static function provideGithubIssue424TestCases(): array ], ]; } + + #[Test] + public function can_preserve_trailing_zeros_in_strings_that_look_like_decimals(): void + { + $postgresValue = '{42.00,123.50,0.00,999.99,502.00,505.00}'; + $expectedResult = ['42.00', '123.50', '0.00', '999.99', '502.00', '505.00']; + + $result = $this->fixture->convertToPHPValue($postgresValue, $this->platform); + + $this->assertSame($expectedResult, $result, 'Trailing zeros in decimal strings should be preserved'); + + foreach ($result as $value) { + $this->assertIsString($value, \sprintf('All values in text[] should be strings, but %s is not', $value)); + } + } } diff --git a/tests/Unit/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php b/tests/Unit/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php index 8a7daa95..e43932f8 100644 --- a/tests/Unit/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php +++ b/tests/Unit/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php @@ -262,4 +262,67 @@ public static function provideInvalidPostgresArrays(): array ], ]; } + + #[DataProvider('providePreserveStringTypesTestCases')] + #[Test] + public function can_preserve_string_types_when_requested(array $expectedPhpValue, string $postgresValue): void + { + $result = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue, preserveStringTypes: true); + + $this->assertSame($expectedPhpValue, $result); + + // Verify all values are strings when preserveStringTypes is true + foreach ($result as $value) { + $this->assertIsString($value, \sprintf('All values should be strings when preserveStringTypes is true, but found a non-string value: %s', \var_export($value, true))); + } + } + + /** + * @return array + */ + public static function providePreserveStringTypesTestCases(): array + { + return [ + 'floats with trailing zeros - issue #482' => [ + 'expectedPhpValue' => ['502.00', '505.00', '123.50'], + 'postgresValue' => '{502.00,505.00,123.50}', + ], + 'zero with decimals' => [ + 'expectedPhpValue' => ['0.00', '0.0', '0.000'], + 'postgresValue' => '{0.00,0.0,0.000}', + ], + 'mixed numeric-looking and text values' => [ + 'expectedPhpValue' => ['502.00', 'some text', '123.50', 'another'], + 'postgresValue' => '{502.00,some text,123.50,another}', + ], + 'scientific notation as strings' => [ + 'expectedPhpValue' => ['1.23e10', '4.56E-5', '7.89e+3'], + 'postgresValue' => '{1.23e10,4.56E-5,7.89e+3}', + ], + 'already quoted values with decimals' => [ + 'expectedPhpValue' => ['502.00', '123.50'], + 'postgresValue' => '{"502.00","123.50"}', + ], + 'mixed quoted and unquoted with decimals' => [ + 'expectedPhpValue' => ['502.00', '123.50', 'text', '789.00'], + 'postgresValue' => '{502.00,"123.50",text,"789.00"}', + ], + 'integers should remain as strings' => [ + 'expectedPhpValue' => ['1', '2', '3', '100'], + 'postgresValue' => '{1,2,3,100}', + ], + 'boolean-like values as strings' => [ + 'expectedPhpValue' => ['true', 'false', 't', 'f'], + 'postgresValue' => '{true,false,t,f}', + ], + 'null values as strings' => [ + 'expectedPhpValue' => ['null', 'NULL'], + 'postgresValue' => '{null,NULL}', + ], + 'empty strings preserved' => [ + 'expectedPhpValue' => ['', 'text', ''], + 'postgresValue' => '{"",text,""}', + ], + ]; + } }