From f8b99a4a31821b08c93256495f0141967c57e96d Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 25 Mar 2025 00:50:00 +0000 Subject: [PATCH 1/5] feat!: preserve the type of floats and integers when transforming back and forth between PostgreSQL and PHP --- .../Doctrine/DBAL/Types/BaseIntegerArray.php | 7 +- src/MartinGeorgiev/Utils/DataStructure.php | 159 ++++++++++++------ .../Doctrine/DBAL/Types/TextArrayTest.php | 4 +- .../Utils/DataStructureTest.php | 60 ++++++- 4 files changed, 168 insertions(+), 62 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseIntegerArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseIntegerArray.php index af52a7ba..a99bd3eb 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseIntegerArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseIntegerArray.php @@ -47,9 +47,10 @@ public function transformArrayItemForPHP($item): ?int return null; } - $isInvalidPHPInt = !(bool) \preg_match('/^-?\d+$/', (string) $item) - || (string) $item < $this->getMinValue() - || (string) $item > $this->getMaxValue(); + $stringValue = (string) $item; + $isInvalidPHPInt = !(bool) \preg_match('/^-?\d+$/', $stringValue) + || $stringValue < $this->getMinValue() + || $stringValue > $this->getMaxValue(); if ($isInvalidPHPInt) { throw new ConversionException(\sprintf('Given value of %s content cannot be transformed to valid PHP integer.', \var_export($item, true))); } diff --git a/src/MartinGeorgiev/Utils/DataStructure.php b/src/MartinGeorgiev/Utils/DataStructure.php index 034d59ec..afb94415 100644 --- a/src/MartinGeorgiev/Utils/DataStructure.php +++ b/src/MartinGeorgiev/Utils/DataStructure.php @@ -13,78 +13,131 @@ */ class DataStructure { + private const POSTGRESQL_EMPTY_ARRAY = '{}'; + + private const POSTGRESQL_NULL_VALUE = 'null'; + /** * This method supports only single-dimensioned text arrays and * relays on the default escaping strategy in PostgreSQL (double quotes). */ public static function transformPostgresTextArrayToPHPArray(string $postgresArray): array { - $transform = static function (string $textArrayToTransform): array { - $indicatesMultipleDimensions = \mb_strpos($textArrayToTransform, '},{') !== false - || \mb_strpos($textArrayToTransform, '{{') === 0; - if ($indicatesMultipleDimensions) { - throw new \InvalidArgumentException('Only single-dimensioned arrays are supported'); - } - - $phpArray = \str_getcsv(\trim($textArrayToTransform, '{}'), escape: '\\'); - foreach ($phpArray as $i => $text) { - if ($text === null) { - unset($phpArray[$i]); + $trimmed = \trim($postgresArray); - break; - } + if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) { + return []; + } - $isInteger = \is_numeric($text) && ''.(int) $text === $text; - if ($isInteger) { - $phpArray[$i] = (int) $text; + if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) { + throw new \InvalidArgumentException('Only single-dimensioned arrays are supported'); + } - continue; - } + if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) { + return []; + } - $isFloat = \is_numeric($text) && ''.(float) $text === $text; - if ($isFloat) { - $phpArray[$i] = (float) $text; + $jsonArray = '['.\trim($trimmed, '{}').']'; - continue; - } + $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING); + if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException('Invalid array format: '.\json_last_error_msg()); + } - $phpArray[$i] = \stripslashes(\str_replace('\"', '"', $text)); - } - - return $phpArray; - }; - - return $transform($postgresArray); + return \array_map( + static fn ($value): mixed => \is_string($value) ? self::unescapeString($value) : $value, + $decoded + ); } /** * This method supports only single-dimensioned PHP arrays. * This method relays on the default escaping strategy in PostgreSQL (double quotes). - * - * @see https://stackoverflow.com/a/5632171/3425372 Kudos to jmz for the inspiration */ public static function transformPHPArrayToPostgresTextArray(array $phpArray): string { - $transform = static function (array $phpArrayToTransform): string { - $result = []; - foreach ($phpArrayToTransform as $text) { - if (\is_array($text)) { - throw new \InvalidArgumentException('Only single-dimensioned arrays are supported'); - } - - if (\is_numeric($text) || \ctype_digit($text)) { - $escapedText = $text; - } else { - \assert(\is_string($text)); - $escapedText = \sprintf('"%s"', \addcslashes($text, '"\\')); - } - - $result[] = $escapedText; - } - - return '{'.\implode(',', $result).'}'; - }; - - return $transform($phpArray); + if ($phpArray === []) { + return self::POSTGRESQL_EMPTY_ARRAY; + } + + if (\array_filter($phpArray, 'is_array')) { + throw new \InvalidArgumentException('Only single-dimensioned arrays are supported'); + } + + $processed = \array_map(static fn ($value): string => self::formatValue($value), $phpArray); + + return '{'.\implode(',', $processed).'}'; + } + + /** + * Formats a single value for PostgreSQL array. + */ + private static function formatValue(mixed $value): string + { + // Handle null + if ($value === null) { + return 'NULL'; + } + + // Handle actual numbers + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + + // Convert to string if not already + $stringValue = (string) $value; + + // Handle empty string + if ($stringValue === '') { + return '""'; + } + + if (self::isNumericSimple($stringValue)) { + return '"'.$stringValue.'"'; + } + + // Double the backslashes and escape quotes + $escaped = \str_replace( + ['\\', '"'], + ['\\\\', '\"'], + $stringValue + ); + + return '"'.$escaped.'"'; + } + + private static function isNumericSimple(string $value): bool + { + // Fast path for obvious numeric strings + if ($value === '' || $value[0] === '"') { + return false; + } + + // Handle scientific notation + $lower = \strtolower($value); + if (\str_contains($lower, 'e')) { + $value = \str_replace('e', '', $lower); + } + + // Use built-in numeric check + return \is_numeric($value); + } + + private static function unescapeString(string $value): string + { + // First handle escaped quotes + $value = \str_replace('\"', '___QUOTE___', $value); + + // Handle double backslashes + $value = \str_replace('\\\\', '___DBLBACK___', $value); + + // Handle remaining single backslashes + $value = \str_replace('\\', '\\', $value); + + // Restore double backslashes + $value = \str_replace('___DBLBACK___', '\\\\', $value); + + // Finally restore quotes + return \str_replace('___QUOTE___', '"', $value); } } diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php index 9282ae8e..653f0148 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php @@ -74,6 +74,8 @@ public static function provideValidTransformations(): array 'phpValue' => [ 1, '2', + 3.4, + '5.6', 'text', 'some text here', 'and some here', @@ -84,7 +86,7 @@ public static function provideValidTransformations(): array 'and "double-quotes"', ], 'postgresValue' => <<<'END' -{1,2,"text","some text here","and some here","''\"quotes\"'' ain't no \"\"\"worry\"\"\", '''right''' Alexander O'Vechkin?","back-slashing\\double-slashing\\\\hooking though","and \"double-quotes\""} +{1,"2",3.4,"5.6","text","some text here","and some here","''\"quotes\"'' ain't no \"\"\"worry\"\"\", '''right''' Alexander O'Vechkin?","back-slashing\\double-slashing\\\\hooking though","and \"double-quotes\""} END, ], ]; diff --git a/tests/MartinGeorgiev/Utils/DataStructureTest.php b/tests/MartinGeorgiev/Utils/DataStructureTest.php index b79cd11b..6fd574bc 100644 --- a/tests/MartinGeorgiev/Utils/DataStructureTest.php +++ b/tests/MartinGeorgiev/Utils/DataStructureTest.php @@ -44,25 +44,43 @@ public function can_transform_to_php_value(array $phpValue, string $postgresValu public static function provideValidTransformations(): array { return [ - [ + 'simple integer strings as strings are preserved as strings' => [ 'phpValue' => [ 0 => '1', 1 => '2', 2 => '3', 3 => '4', ], + 'postgresValue' => '{"1","2","3","4"}', + ], + 'simple integer strings' => [ + 'phpValue' => [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + ], 'postgresValue' => '{1,2,3,4}', ], - [ + 'decimal numbers represented as strings are preserved as strings' => [ 'phpValue' => [ 0 => '1.23', 1 => '2.34', 2 => '3.45', 3 => '4.56', ], + 'postgresValue' => '{"1.23","2.34","3.45","4.56"}', + ], + 'decimal numbers' => [ + 'phpValue' => [ + 0 => 1.23, + 1 => 2.34, + 2 => 3.45, + 3 => 4.56, + ], 'postgresValue' => '{1.23,2.34,3.45,4.56}', ], - [ + 'mixed content with special characters' => [ 'phpValue' => [ 0 => 'dfasdf', 1 => 'qw,,e{q"we', @@ -72,17 +90,49 @@ public static function provideValidTransformations(): array ], 'postgresValue' => '{"dfasdf","qw,,e{q\"we","\'qrer\'",604,"\"aaa\",\"b\"\"bb\",\"ccc\""}', ], - [ + 'empty strings' => [ 'phpValue' => [ 0 => '', 1 => '', ], 'postgresValue' => '{"",""}', ], - [ + 'empty array' => [ 'phpValue' => [], 'postgresValue' => '{}', ], + 'scientific notation as strings' => [ + 'phpValue' => ['1.23e4', '2.34e5', '3.45e6'], + 'postgresValue' => '{"1.23e4","2.34e5","3.45e6"}', + ], + 'scientific notation with negative exponents' => [ + 'phpValue' => ['1.23e-4', '2.34e-5', '3.45e-6'], + 'postgresValue' => '{"1.23e-4","2.34e-5","3.45e-6"}', + ], + 'whole floats that look like integers' => [ + 'phpValue' => ['1.0', '2.00', '3.000', '4.0000'], + 'postgresValue' => '{"1.0","2.00","3.000","4.0000"}', + ], + 'large integers beyond PHP_INT_MAX' => [ + 'phpValue' => [ + '9223372036854775808', // PHP_INT_MAX + 1 + '9999999999999999999', + '-9223372036854775809', // PHP_INT_MIN - 1 + ], + 'postgresValue' => '{"9223372036854775808","9999999999999999999","-9223372036854775809"}', + ], + 'mixed numeric formats' => [ + 'phpValue' => [ + '1.23', // regular float string + 1.23, // regular float + '1.230', // float with trailing zeros + '1.23e4', // scientific notation + '1.0', // whole float as string + 1.0, // whole float + '9999999999999999999', // large integer + ], + 'postgresValue' => '{"1.23",1.23,"1.230","1.23e4","1.0",1,"9999999999999999999"}', + ], ]; } From 02f4207bd158e2119ec7d240927afcbf10ba88d9 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 25 Mar 2025 01:06:22 +0000 Subject: [PATCH 2/5] further edge case handlings --- src/MartinGeorgiev/Utils/DataStructure.php | 36 ++++++++++++++++--- .../Utils/DataStructureTest.php | 7 +--- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/MartinGeorgiev/Utils/DataStructure.php b/src/MartinGeorgiev/Utils/DataStructure.php index afb94415..1f5ddab6 100644 --- a/src/MartinGeorgiev/Utils/DataStructure.php +++ b/src/MartinGeorgiev/Utils/DataStructure.php @@ -39,14 +39,15 @@ public static function transformPostgresTextArrayToPHPArray(string $postgresArra $jsonArray = '['.\trim($trimmed, '{}').']'; + /** @var array|null $decoded */ $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING); if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) { throw new \InvalidArgumentException('Invalid array format: '.\json_last_error_msg()); } return \array_map( - static fn ($value): mixed => \is_string($value) ? self::unescapeString($value) : $value, - $decoded + static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value, + (array) $decoded ); } @@ -64,7 +65,11 @@ public static function transformPHPArrayToPostgresTextArray(array $phpArray): st throw new \InvalidArgumentException('Only single-dimensioned arrays are supported'); } - $processed = \array_map(static fn ($value): string => self::formatValue($value), $phpArray); + /** @var array */ + $processed = \array_map( + static fn (mixed $value): string => self::formatValue($value), + $phpArray + ); return '{'.\implode(',', $processed).'}'; } @@ -84,8 +89,29 @@ private static function formatValue(mixed $value): string return (string) $value; } - // Convert to string if not already - $stringValue = (string) $value; + // Handle booleans + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + // Handle objects that implement __toString() + if (\is_object($value)) { + if (\method_exists($value, '__toString')) { + $stringValue = $value->__toString(); + } else { + // For objects without __toString, use a default representation + $stringValue = $value::class; + } + } else { + // For all other types, force string conversion + // This covers strings, resources, and other types + $stringValue = match (true) { + \is_resource($value) => '(resource)', + default => (string) $value // @phpstan-ignore-line + }; + } + + \assert(\is_string($stringValue)); // Handle empty string if ($stringValue === '') { diff --git a/tests/MartinGeorgiev/Utils/DataStructureTest.php b/tests/MartinGeorgiev/Utils/DataStructureTest.php index 6fd574bc..df9a119e 100644 --- a/tests/MartinGeorgiev/Utils/DataStructureTest.php +++ b/tests/MartinGeorgiev/Utils/DataStructureTest.php @@ -34,12 +34,7 @@ public function can_transform_to_php_value(array $phpValue, string $postgresValu } /** - * @see https://stackoverflow.com/a/27964420/3425372 Kudos to dmikam for the inspiration - * - * @return list + * @return array */ public static function provideValidTransformations(): array { From 1b8dd5f37000701dad25dd785f5034565cbe6b59 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 25 Mar 2025 01:19:01 +0000 Subject: [PATCH 3/5] Add some more tests :) --- .../Utils/DataStructureTest.php | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/MartinGeorgiev/Utils/DataStructureTest.php b/tests/MartinGeorgiev/Utils/DataStructureTest.php index df9a119e..52ffcdd5 100644 --- a/tests/MartinGeorgiev/Utils/DataStructureTest.php +++ b/tests/MartinGeorgiev/Utils/DataStructureTest.php @@ -128,6 +128,27 @@ public static function provideValidTransformations(): array ], 'postgresValue' => '{"1.23",1.23,"1.230","1.23e4","1.0",1,"9999999999999999999"}', ], + 'boolean values' => [ + 'phpValue' => [true, false], + 'postgresValue' => '{true,false}', + ], + 'objects with __toString' => [ + 'phpValue' => [new class { + public function __toString(): string + { + return 'custom string'; + } + }], + 'postgresValue' => '{"custom string"}', + ], + 'strings with backslashes' => [ + 'phpValue' => ['path\to\file', 'C:\Windows\System32'], + 'postgresValue' => '{"path\\\to\\\file","C:\\\Windows\\\System32"}', + ], + 'strings with unicode characters' => [ + 'phpValue' => ['Hello δΈ–η•Œ', '🌍 Earth'], + 'postgresValue' => '{"Hello δΈ–η•Œ","🌍 Earth"}', + ], ]; } @@ -183,4 +204,46 @@ public static function provideInvalidTransformations(): array ], ]; } + + /** + * @test + */ + public function throws_exception_for_invalid_postgres_array_format(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid array format'); + DataStructure::transformPostgresTextArrayToPHPArray('{invalid"format}'); + } + + /** + * @test + */ + public function preserves_numeric_string_types(): void + { + $input = ['1', '1.0', '1.00', 1, 1.01]; + $postgres = DataStructure::transformPHPArrayToPostgresTextArray($input); + $output = DataStructure::transformPostgresTextArrayToPHPArray($postgres); + + self::assertSame([ + '1', + '1.0', + '1.00', + 1, + 1.01, + ], $output); + } + + /** + * @test + */ + public function handles_resource_cleanup(): void + { + $resource = \fopen('php://memory', 'r'); + \assert(\is_resource($resource)); + $input = [$resource]; + $result = DataStructure::transformPHPArrayToPostgresTextArray($input); + \fclose($resource); + + self::assertSame('{"(resource)"}', $result); + } } From d487a4ae02fe17be48f0d7986cac2bb7cd80c53c Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 26 Mar 2025 02:20:40 +0000 Subject: [PATCH 4/5] tests tests tests --- .../Doctrine/DBAL/Types/TextArray.php | 6 ++-- ...Structure.php => ArrayDataTransformer.php} | 9 ++--- .../Doctrine/DBAL/Types/TextArrayTest.php | 4 +-- ...eTest.php => ArrayDataTransformerTest.php} | 34 +++++++++---------- 4 files changed, 24 insertions(+), 29 deletions(-) rename src/MartinGeorgiev/Utils/{DataStructure.php => ArrayDataTransformer.php} (95%) rename tests/MartinGeorgiev/Utils/{DataStructureTest.php => ArrayDataTransformerTest.php} (84%) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php index d9360e98..67ab54b3 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php @@ -5,7 +5,7 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types; use Doctrine\DBAL\Platforms\AbstractPlatform; -use MartinGeorgiev\Utils\DataStructure; +use MartinGeorgiev\Utils\ArrayDataTransformer; /** * Implementation of PostgreSQL TEXT[] data type. @@ -42,7 +42,7 @@ protected function transformToPostgresTextArray(array $phpTextArray): string return '{}'; } - return DataStructure::transformPHPArrayToPostgresTextArray($phpTextArray); + return ArrayDataTransformer::transformPHPArrayToPostgresTextArray($phpTextArray); } /** @@ -65,6 +65,6 @@ protected function transformFromPostgresTextArray(string $postgresValue): array return []; } - return DataStructure::transformPostgresTextArrayToPHPArray($postgresValue); + return ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgresValue); } } diff --git a/src/MartinGeorgiev/Utils/DataStructure.php b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php similarity index 95% rename from src/MartinGeorgiev/Utils/DataStructure.php rename to src/MartinGeorgiev/Utils/ArrayDataTransformer.php index 1f5ddab6..458f3277 100644 --- a/src/MartinGeorgiev/Utils/DataStructure.php +++ b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php @@ -5,13 +5,11 @@ namespace MartinGeorgiev\Utils; /** - * Util class with helpers for working with PostgreSQL data structures. - * - * @since 0.9 + * @since 3.0 * * @author Martin Georgiev */ -class DataStructure +class ArrayDataTransformer { private const POSTGRESQL_EMPTY_ARRAY = '{}'; @@ -157,9 +155,6 @@ private static function unescapeString(string $value): string // Handle double backslashes $value = \str_replace('\\\\', '___DBLBACK___', $value); - // Handle remaining single backslashes - $value = \str_replace('\\', '\\', $value); - // Restore double backslashes $value = \str_replace('___DBLBACK___', '\\\\', $value); diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php index 653f0148..ca6199ea 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php @@ -82,11 +82,11 @@ public static function provideValidTransformations(): array <<<'END' ''"quotes"'' ain't no """worry""", '''right''' Alexander O'Vechkin? END, - 'back-slashing\double-slashing\\\hooking though', + 'back-slashing\double-slashing\\\triple-slashing\\\\\hooking though', 'and "double-quotes"', ], 'postgresValue' => <<<'END' -{1,"2",3.4,"5.6","text","some text here","and some here","''\"quotes\"'' ain't no \"\"\"worry\"\"\", '''right''' Alexander O'Vechkin?","back-slashing\\double-slashing\\\\hooking though","and \"double-quotes\""} +{1,"2",3.4,"5.6","text","some text here","and some here","''\"quotes\"'' ain't no \"\"\"worry\"\"\", '''right''' Alexander O'Vechkin?","back-slashing\\double-slashing\\\\triple-slashing\\\\\\hooking though","and \"double-quotes\""} END, ], ]; diff --git a/tests/MartinGeorgiev/Utils/DataStructureTest.php b/tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php similarity index 84% rename from tests/MartinGeorgiev/Utils/DataStructureTest.php rename to tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php index 52ffcdd5..a6e3d16c 100644 --- a/tests/MartinGeorgiev/Utils/DataStructureTest.php +++ b/tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php @@ -4,10 +4,10 @@ namespace Tests\MartinGeorgiev\Utils; -use MartinGeorgiev\Utils\DataStructure; +use MartinGeorgiev\Utils\ArrayDataTransformer; use PHPUnit\Framework\TestCase; -class DataStructureTest extends TestCase +class ArrayDataTransformerTest extends TestCase { /** * @test @@ -18,7 +18,7 @@ class DataStructureTest extends TestCase */ public function can_transform_from_php_value(array $phpValue, string $postgresValue): void { - self::assertEquals($postgresValue, DataStructure::transformPHPArrayToPostgresTextArray($phpValue)); + self::assertEquals($postgresValue, ArrayDataTransformer::transformPHPArrayToPostgresTextArray($phpValue)); } /** @@ -30,7 +30,7 @@ public function can_transform_from_php_value(array $phpValue, string $postgresVa */ public function can_transform_to_php_value(array $phpValue, string $postgresValue): void { - self::assertEquals($phpValue, DataStructure::transformPostgresTextArrayToPHPArray($postgresValue)); + self::assertEquals($phpValue, ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgresValue)); } /** @@ -118,13 +118,13 @@ public static function provideValidTransformations(): array ], 'mixed numeric formats' => [ 'phpValue' => [ - '1.23', // regular float string - 1.23, // regular float - '1.230', // float with trailing zeros - '1.23e4', // scientific notation - '1.0', // whole float as string - 1.0, // whole float - '9999999999999999999', // large integer + '1.23', // regular float string + 1.23, // regular float + '1.230', // float with trailing zeros + '1.23e4', // scientific notation + '1.0', // whole float as string + 1.0, // whole float + '9999999999999999999', // large integer ], 'postgresValue' => '{"1.23",1.23,"1.230","1.23e4","1.0",1,"9999999999999999999"}', ], @@ -162,7 +162,7 @@ public function __toString(): string public function throws_invalid_argument_exception_when_tries_to_non_single_dimensioned_array_from_php_value(array $phpValue, string $postgresValue): void { $this->expectException(\InvalidArgumentException::class); - DataStructure::transformPHPArrayToPostgresTextArray($phpValue); + ArrayDataTransformer::transformPHPArrayToPostgresTextArray($phpValue); } /** @@ -175,7 +175,7 @@ public function throws_invalid_argument_exception_when_tries_to_non_single_dimen public function throws_invalid_argument_exception_when_tries_to_non_single_dimensioned_array_to_php_value(array $phpValue, string $postgresValue): void { $this->expectException(\InvalidArgumentException::class); - DataStructure::transformPostgresTextArrayToPHPArray($postgresValue); + ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgresValue); } /** @@ -212,7 +212,7 @@ public function throws_exception_for_invalid_postgres_array_format(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid array format'); - DataStructure::transformPostgresTextArrayToPHPArray('{invalid"format}'); + ArrayDataTransformer::transformPostgresTextArrayToPHPArray('{invalid"format}'); } /** @@ -221,8 +221,8 @@ public function throws_exception_for_invalid_postgres_array_format(): void public function preserves_numeric_string_types(): void { $input = ['1', '1.0', '1.00', 1, 1.01]; - $postgres = DataStructure::transformPHPArrayToPostgresTextArray($input); - $output = DataStructure::transformPostgresTextArrayToPHPArray($postgres); + $postgres = ArrayDataTransformer::transformPHPArrayToPostgresTextArray($input); + $output = ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgres); self::assertSame([ '1', @@ -241,7 +241,7 @@ public function handles_resource_cleanup(): void $resource = \fopen('php://memory', 'r'); \assert(\is_resource($resource)); $input = [$resource]; - $result = DataStructure::transformPHPArrayToPostgresTextArray($input); + $result = ArrayDataTransformer::transformPHPArrayToPostgresTextArray($input); \fclose($resource); self::assertSame('{"(resource)"}', $result); From cd1ee09d7ffed7f8a5408a6c245fbe760d368f9f Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 26 Mar 2025 02:36:48 +0000 Subject: [PATCH 5/5] add domain exception --- .../Utils/ArrayDataTransformer.php | 12 +- .../Exception/InvalidArrayFormatException.php | 23 ++++ .../Utils/ArrayDataTransformerTest.php | 122 ++++++++++++++++-- 3 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 src/MartinGeorgiev/Utils/Exception/InvalidArrayFormatException.php diff --git a/src/MartinGeorgiev/Utils/ArrayDataTransformer.php b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php index 458f3277..505db92d 100644 --- a/src/MartinGeorgiev/Utils/ArrayDataTransformer.php +++ b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php @@ -4,6 +4,8 @@ namespace MartinGeorgiev\Utils; +use MartinGeorgiev\Utils\Exception\InvalidArrayFormatException; + /** * @since 3.0 * @@ -18,6 +20,8 @@ class ArrayDataTransformer /** * This method supports only single-dimensioned text arrays and * relays on the default escaping strategy in PostgreSQL (double quotes). + * + * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has invalid format */ public static function transformPostgresTextArrayToPHPArray(string $postgresArray): array { @@ -28,7 +32,7 @@ public static function transformPostgresTextArrayToPHPArray(string $postgresArra } if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) { - throw new \InvalidArgumentException('Only single-dimensioned arrays are supported'); + throw InvalidArrayFormatException::multiDimensionalArrayNotSupported(); } if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) { @@ -40,7 +44,7 @@ public static function transformPostgresTextArrayToPHPArray(string $postgresArra /** @var array|null $decoded */ $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING); if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) { - throw new \InvalidArgumentException('Invalid array format: '.\json_last_error_msg()); + throw InvalidArrayFormatException::invalidFormat(\json_last_error_msg()); } return \array_map( @@ -52,6 +56,8 @@ public static function transformPostgresTextArrayToPHPArray(string $postgresArra /** * This method supports only single-dimensioned PHP arrays. * This method relays on the default escaping strategy in PostgreSQL (double quotes). + * + * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has invalid format */ public static function transformPHPArrayToPostgresTextArray(array $phpArray): string { @@ -60,7 +66,7 @@ public static function transformPHPArrayToPostgresTextArray(array $phpArray): st } if (\array_filter($phpArray, 'is_array')) { - throw new \InvalidArgumentException('Only single-dimensioned arrays are supported'); + throw InvalidArrayFormatException::multiDimensionalArrayNotSupported(); } /** @var array */ diff --git a/src/MartinGeorgiev/Utils/Exception/InvalidArrayFormatException.php b/src/MartinGeorgiev/Utils/Exception/InvalidArrayFormatException.php new file mode 100644 index 00000000..d2af6908 --- /dev/null +++ b/src/MartinGeorgiev/Utils/Exception/InvalidArrayFormatException.php @@ -0,0 +1,23 @@ +expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid array format'); - ArrayDataTransformer::transformPostgresTextArrayToPHPArray('{invalid"format}'); - } - /** * @test */ @@ -246,4 +237,115 @@ public function handles_resource_cleanup(): void self::assertSame('{"(resource)"}', $result); } + + /** + * @test + */ + public function handles_empty_input(): void + { + $input = ''; + $result = ArrayDataTransformer::transformPostgresTextArrayToPHPArray($input); + self::assertSame([], $result); + } + + /** + * @test + */ + public function handles_null_string_input(): void + { + $input = 'null'; + $result = ArrayDataTransformer::transformPostgresTextArrayToPHPArray($input); + self::assertSame([], $result); + } + + /** + * @test + */ + public function preserves_numeric_precision(): void + { + $input = ['9223372036854775808', '1.23456789012345']; + $postgres = ArrayDataTransformer::transformPHPArrayToPostgresTextArray($input); + $output = ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgres); + + self::assertSame($input, $output); + } + + /** + * @test + * + * @dataProvider provideMultiDimensionalArrays + * + * @param array $phpValue + */ + public function throws_exception_for_multi_dimensional_arrays(array $phpValue): void + { + $this->expectException(InvalidArrayFormatException::class); + $this->expectExceptionMessage('Only single-dimensioned arrays are supported'); + ArrayDataTransformer::transformPHPArrayToPostgresTextArray($phpValue); + } + + /** + * @return array + */ + public static function provideMultiDimensionalArrays(): array + { + return [ + 'nested arrays' => [ + 'phpValue' => [ + [1, 2, 3], + [4, 5, 6], + ], + ], + 'deeply nested arrays' => [ + 'phpValue' => [ + 1, + [2, [3, 4]], + 5, + ], + ], + 'associative nested arrays' => [ + 'phpValue' => [ + 'first' => [1, 2, 3], + 'second' => [4, 5, 6], + ], + ], + 'mixed nesting' => [ + 'phpValue' => [ + 1, + 'string', + ['nested' => ['deep' => 'value']], + ], + ], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidPostgresArrays + */ + public function throws_exception_for_invalid_postgres_arrays(string $postgresValue): void + { + $this->expectException(InvalidArrayFormatException::class); + $this->expectExceptionMessage('Invalid array format'); + ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgresValue); + } + + /** + * @return array + */ + public static function provideInvalidPostgresArrays(): array + { + return [ + 'unclosed string' => [ + 'postgresValue' => '{1,2,"unclosed string}', + ], + 'invalid format' => [ + 'postgresValue' => '{invalid"format}', + ], + 'malformed nesting' => [ + 'postgresValue' => '{1,{2,3},4}', + ], + ]; + } }