Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 4 additions & 26 deletions src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
39 changes: 25 additions & 14 deletions src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<int, mixed>|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 = '';
Expand Down Expand Up @@ -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;
Expand All @@ -134,19 +137,29 @@ private static function parsePostgresArrayManually(string $content): array

// Add the last value
if ($currentValue !== '') {
$result[] = self::processPostgresValue($currentValue);
$result[] = self::processPostgresValue($currentValue, $preserveStringTypes);
}

return $result;
}

/**
* 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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ protected function getPostgresTypeName(): string
}

#[DataProvider('provideValidTransformations')]
#[DataProvider('provideTypeInferenceTestCases')]
#[Test]
public function can_handle_array_values(string $testName, array $arrayValue): void
{
Expand Down Expand Up @@ -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,
],
]],
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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'],
],
];
}
}
15 changes: 15 additions & 0 deletions tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{expectedPhpValue: array, postgresValue: string}>
*/
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,""}',
],
];
}
}
Loading