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/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/ArrayDataTransformer.php b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php new file mode 100644 index 00000000..505db92d --- /dev/null +++ b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php @@ -0,0 +1,170 @@ + + */ +class ArrayDataTransformer +{ + 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). + * + * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has invalid format + */ + public static function transformPostgresTextArrayToPHPArray(string $postgresArray): array + { + $trimmed = \trim($postgresArray); + + if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) { + return []; + } + + if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) { + throw InvalidArrayFormatException::multiDimensionalArrayNotSupported(); + } + + if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) { + return []; + } + + $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 InvalidArrayFormatException::invalidFormat(\json_last_error_msg()); + } + + return \array_map( + static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value, + (array) $decoded + ); + } + + /** + * 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 + { + if ($phpArray === []) { + return self::POSTGRESQL_EMPTY_ARRAY; + } + + if (\array_filter($phpArray, 'is_array')) { + throw InvalidArrayFormatException::multiDimensionalArrayNotSupported(); + } + + /** @var array */ + $processed = \array_map( + static fn (mixed $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; + } + + // 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 === '') { + 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); + + // Restore double backslashes + $value = \str_replace('___DBLBACK___', '\\\\', $value); + + // Finally restore quotes + return \str_replace('___QUOTE___', '"', $value); + } +} diff --git a/src/MartinGeorgiev/Utils/DataStructure.php b/src/MartinGeorgiev/Utils/DataStructure.php deleted file mode 100644 index 034d59ec..00000000 --- a/src/MartinGeorgiev/Utils/DataStructure.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ -class DataStructure -{ - /** - * 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]); - - break; - } - - $isInteger = \is_numeric($text) && ''.(int) $text === $text; - if ($isInteger) { - $phpArray[$i] = (int) $text; - - continue; - } - - $isFloat = \is_numeric($text) && ''.(float) $text === $text; - if ($isFloat) { - $phpArray[$i] = (float) $text; - - continue; - } - - $phpArray[$i] = \stripslashes(\str_replace('\"', '"', $text)); - } - - return $phpArray; - }; - - return $transform($postgresArray); - } - - /** - * 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); - } -} 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 @@ + [ 1, '2', + 3.4, + '5.6', 'text', 'some text here', 'and some here', <<<'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,"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/ArrayDataTransformerTest.php b/tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php new file mode 100644 index 00000000..c8c97209 --- /dev/null +++ b/tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php @@ -0,0 +1,351 @@ +> $phpValue + */ + public function can_transform_from_php_value(array $phpValue, string $postgresValue): void + { + self::assertEquals($postgresValue, ArrayDataTransformer::transformPHPArrayToPostgresTextArray($phpValue)); + } + + /** + * @test + * + * @dataProvider provideValidTransformations + * + * @param array> $phpValue + */ + public function can_transform_to_php_value(array $phpValue, string $postgresValue): void + { + self::assertEquals($phpValue, ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgresValue)); + } + + /** + * @return array + */ + 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', + 2 => "'qrer'", + 3 => 604, + 4 => '"aaa","b""bb","ccc"', + ], + '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"}', + ], + '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"}', + ], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidTransformations + * + * @param array $phpValue + */ + 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); + ArrayDataTransformer::transformPHPArrayToPostgresTextArray($phpValue); + } + + /** + * @test + * + * @dataProvider provideInvalidTransformations + * + * @param array $phpValue + */ + 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); + ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgresValue); + } + + /** + * @return list + */ + public static function provideInvalidTransformations(): array + { + return [ + [ + 'phpValue' => [ + [ + 0 => '1-1', + 1 => '1-2', + 2 => '1-3', + ], + [ + 0 => '2-1', + 1 => '2-2', + 2 => '2-3', + ], + ], + 'postgresValue' => '{{"1-1","1-2","1-3"},{"2-1","2-2","2-3"}}', + ], + ]; + } + + /** + * @test + */ + public function preserves_numeric_string_types(): void + { + $input = ['1', '1.0', '1.00', 1, 1.01]; + $postgres = ArrayDataTransformer::transformPHPArrayToPostgresTextArray($input); + $output = ArrayDataTransformer::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 = ArrayDataTransformer::transformPHPArrayToPostgresTextArray($input); + \fclose($resource); + + 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}', + ], + ]; + } +} diff --git a/tests/MartinGeorgiev/Utils/DataStructureTest.php b/tests/MartinGeorgiev/Utils/DataStructureTest.php deleted file mode 100644 index b79cd11b..00000000 --- a/tests/MartinGeorgiev/Utils/DataStructureTest.php +++ /dev/null @@ -1,141 +0,0 @@ -> $phpValue - */ - public function can_transform_from_php_value(array $phpValue, string $postgresValue): void - { - self::assertEquals($postgresValue, DataStructure::transformPHPArrayToPostgresTextArray($phpValue)); - } - - /** - * @test - * - * @dataProvider provideValidTransformations - * - * @param array> $phpValue - */ - public function can_transform_to_php_value(array $phpValue, string $postgresValue): void - { - self::assertEquals($phpValue, DataStructure::transformPostgresTextArrayToPHPArray($postgresValue)); - } - - /** - * @see https://stackoverflow.com/a/27964420/3425372 Kudos to dmikam for the inspiration - * - * @return list - */ - public static function provideValidTransformations(): array - { - return [ - [ - 'phpValue' => [ - 0 => '1', - 1 => '2', - 2 => '3', - 3 => '4', - ], - 'postgresValue' => '{1,2,3,4}', - ], - [ - 'phpValue' => [ - 0 => '1.23', - 1 => '2.34', - 2 => '3.45', - 3 => '4.56', - ], - 'postgresValue' => '{1.23,2.34,3.45,4.56}', - ], - [ - 'phpValue' => [ - 0 => 'dfasdf', - 1 => 'qw,,e{q"we', - 2 => "'qrer'", - 3 => 604, - 4 => '"aaa","b""bb","ccc"', - ], - 'postgresValue' => '{"dfasdf","qw,,e{q\"we","\'qrer\'",604,"\"aaa\",\"b\"\"bb\",\"ccc\""}', - ], - [ - 'phpValue' => [ - 0 => '', - 1 => '', - ], - 'postgresValue' => '{"",""}', - ], - [ - 'phpValue' => [], - 'postgresValue' => '{}', - ], - ]; - } - - /** - * @test - * - * @dataProvider provideInvalidTransformations - * - * @param array $phpValue - */ - 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); - } - - /** - * @test - * - * @dataProvider provideInvalidTransformations - * - * @param array $phpValue - */ - 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); - } - - /** - * @return list - */ - public static function provideInvalidTransformations(): array - { - return [ - [ - 'phpValue' => [ - [ - 0 => '1-1', - 1 => '1-2', - 2 => '1-3', - ], - [ - 0 => '2-1', - 1 => '2-2', - 2 => '2-3', - ], - ], - 'postgresValue' => '{{"1-1","1-2","1-3"},{"2-1","2-2","2-3"}}', - ], - ]; - } -}