From 5fdddd290c5c6bba29a1898bd234232440726779 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Thu, 3 Apr 2025 00:57:28 +0100 Subject: [PATCH 1/6] fix: bring back support of unquoted strings that can be encountered during the transformation from PostgreSQL back into PHP in `TextArray` --- .../Utils/ArrayDataTransformer.php | 226 +++++++++++++++--- .../Doctrine/DBAL/Types/TextArrayTest.php | 36 ++- 2 files changed, 230 insertions(+), 32 deletions(-) diff --git a/src/MartinGeorgiev/Utils/ArrayDataTransformer.php b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php index 505db92d..9bf8f93b 100644 --- a/src/MartinGeorgiev/Utils/ArrayDataTransformer.php +++ b/src/MartinGeorgiev/Utils/ArrayDataTransformer.php @@ -39,12 +39,48 @@ public static function transformPostgresTextArrayToPHPArray(string $postgresArra return []; } + // Check for malformed nesting - this is a more specific check than the one above + // But we need to exclude cases where curly braces are part of quoted strings + $content = \trim($trimmed, '{}'); + $inQuotes = false; + $escaping = false; + + for ($i = 0, $len = \strlen($content); $i < $len; $i++) { + $char = $content[$i]; + + if ($escaping) { + $escaping = false; + + continue; + } + + if ($char === '\\' && $inQuotes) { + $escaping = true; + + continue; + } + + if ($char === '"') { + $inQuotes = !$inQuotes; + } elseif (($char === '{' || $char === '}') && !$inQuotes) { + throw InvalidArrayFormatException::invalidFormat('Malformed array nesting detected'); + } + } + + // Check for unclosed quotes + if ($inQuotes) { + throw InvalidArrayFormatException::invalidFormat('Unclosed quotes in array'); + } + + // First try with json_decode for properly quoted values $jsonArray = '['.\trim($trimmed, '{}').']'; /** @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) { - throw InvalidArrayFormatException::invalidFormat(\json_last_error_msg()); + return self::parsePostgresArrayManually($content); } return \array_map( @@ -53,6 +89,151 @@ public static function transformPostgresTextArrayToPHPArray(string $postgresArra ); } + /** + * Manually parse a PostgreSQL array content string. + */ + private static function parsePostgresArrayManually(string $content): array + { + if ($content === '') { + return []; + } + + // Parse the array manually, handling quoted and unquoted values + $result = []; + $inQuotes = false; + $currentValue = ''; + $escaping = false; + + for ($i = 0, $len = \strlen($content); $i < $len; $i++) { + $char = $content[$i]; + + // Handle escaping within quotes + if ($escaping) { + $currentValue .= $char; + $escaping = false; + + continue; + } + + if ($char === '\\' && $inQuotes) { + $escaping = true; + $currentValue .= $char; + + continue; + } + + if ($char === '"') { + $inQuotes = !$inQuotes; + // For quoted values, we include the quotes for later processing + $currentValue .= $char; + } elseif ($char === ',' && !$inQuotes) { + // End of value + $result[] = self::processPostgresValue($currentValue); + $currentValue = ''; + } else { + $currentValue .= $char; + } + } + + // Add the last value + if ($currentValue !== '') { + $result[] = self::processPostgresValue($currentValue); + } + + return $result; + } + + /** + * Process a single value from a PostgreSQL array. + */ + private static function processPostgresValue(string $value): mixed + { + $value = \trim($value); + + if (self::isNullValue($value)) { + return null; + } + + if (self::isBooleanValue($value)) { + return self::processBooleanValue($value); + } + + if (self::isQuotedString($value)) { + return self::processQuotedString($value); + } + + if (self::isNumericValue($value)) { + return self::processNumericValue($value); + } + + // For unquoted strings, return as is + return $value; + } + + /** + * Check if the value is a NULL value. + */ + private static function isNullValue(string $value): bool + { + return $value === 'NULL' || $value === 'null'; + } + + /** + * Check if the value is a boolean value. + */ + private static function isBooleanValue(string $value): bool + { + return \in_array($value, ['true', 't', 'false', 'f'], true); + } + + /** + * Process a boolean value. + */ + private static function processBooleanValue(string $value): bool + { + return $value === 'true' || $value === 't'; + } + + /** + * Check if the value is a quoted string. + */ + private static function isQuotedString(string $value): bool + { + return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"'; + } + + /** + * Process a quoted string. + */ + private static function processQuotedString(string $value): string + { + // Remove the quotes and unescape the string + $unquoted = \substr($value, 1, -1); + + return self::unescapeString($unquoted); + } + + /** + * Check if the value is a numeric value. + */ + private static function isNumericValue(string $value): bool + { + return \is_numeric($value); + } + + /** + * Process a numeric value. + */ + 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; + } + + return (int) $value; + } + /** * This method supports only single-dimensioned PHP arrays. * This method relays on the default escaping strategy in PostgreSQL (double quotes). @@ -98,7 +279,6 @@ private static function formatValue(mixed $value): string return $value ? 'true' : 'false'; } - // Handle objects that implement __toString() if (\is_object($value)) { if (\method_exists($value, '__toString')) { $stringValue = $value->__toString(); @@ -106,13 +286,19 @@ private static function formatValue(mixed $value): string // For objects without __toString, use a default representation $stringValue = $value::class; } + } elseif (\is_resource($value)) { + $stringValue = '(resource)'; } 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 - }; + $valueType = \get_debug_type($value); + + if ($valueType === 'string') { + $stringValue = $value; + } elseif (\in_array($valueType, ['int', 'float', 'bool'], true)) { + /** @var bool|float|int $value */ + $stringValue = (string) $value; + } else { + $stringValue = $valueType; + } } \assert(\is_string($stringValue)); @@ -122,10 +308,7 @@ private static function formatValue(mixed $value): string return '""'; } - if (self::isNumericSimple($stringValue)) { - return '"'.$stringValue.'"'; - } - + // Always quote strings to match the test expectations // Double the backslashes and escape quotes $escaped = \str_replace( ['\\', '"'], @@ -136,23 +319,6 @@ private static function formatValue(mixed $value): string 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 @@ -162,7 +328,7 @@ private static function unescapeString(string $value): string $value = \str_replace('\\\\', '___DBLBACK___', $value); // Restore double backslashes - $value = \str_replace('___DBLBACK___', '\\\\', $value); + $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 ca6199ea..7d1707f2 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php @@ -70,6 +70,14 @@ public static function provideValidTransformations(): array 'phpValue' => [], 'postgresValue' => '{}', ], + [ + 'phpValue' => ["single-back-slash\\"], + 'postgresValue' => '{"single-back-slash\\\\"}', + ], + [ + 'phpValue' => ["double-back-slash\\\\"], + 'postgresValue' => '{"double-back-slash\\\\\\\\"}', + ], [ 'phpValue' => [ 1, @@ -82,13 +90,37 @@ public static function provideValidTransformations(): array <<<'END' ''"quotes"'' ain't no """worry""", '''right''' Alexander O'Vechkin? END, - '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\\\\triple-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?","and \"double-quotes\""} END, ], + [ + 'phpValue' => ['STRING_A', 'STRING_B', 'STRING_C', 'STRING_D'], + 'postgresValue' => '{"STRING_A","STRING_B","STRING_C","STRING_D"}', + ], ]; } + + /** + * @test + */ + public function can_transform_unquoted_postgres_array_to_php(): void + { + $postgresValue = '{STRING_A,STRING_B,STRING_C,STRING_D}'; + $expectedPhpValue = ['STRING_A', 'STRING_B', 'STRING_C', 'STRING_D']; + + self::assertEquals($expectedPhpValue, $this->fixture->convertToPHPValue($postgresValue, $this->platform)); + } + + /** + * @test + */ + public function can_handle_backslashes_correctly(): void + { + $postgresValue = '{"simple\\\backslash"}'; + $result = $this->fixture->convertToPHPValue($postgresValue, $this->platform); + self::assertEquals(['simple\backslash'], $result); + } } From d561e853f6887f0f217876139fa3e168e0ec0821 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Apr 2025 21:39:09 +0100 Subject: [PATCH 2/6] try to json decode array items; if this fails, try to process manually --- docs/UPGRADE.md | 6 +- ...> InvalidJsonArrayItemForPHPException.php} | 4 +- .../InvalidJsonItemForPHPException.php | 25 ++ .../Doctrine/DBAL/Types/JsonTransformer.php | 4 +- .../Doctrine/DBAL/Types/JsonbArray.php | 25 +- .../Doctrine/DBAL/Types/TextArray.php | 7 +- .../PHPArrayToPostgresValueTransformer.php | 101 +++++ ...=> PostgresArrayToPHPArrayTransformer.php} | 153 ++------ .../PostgresJsonToPHPArrayTransformer.php | 65 ++++ .../Doctrine/DBAL/Types/JsonbArrayTest.php | 6 +- .../Doctrine/DBAL/Types/TextArrayTest.php | 21 +- .../Utils/ArrayDataTransformerTest.php | 351 ------------------ ...PHPArrayToPostgresValueTransformerTest.php | 163 ++++++++ ...PostgresArrayToPHPArrayTransformerTest.php | 159 ++++++++ .../PostgresJsonToPHPArrayTransformerTest.php | 148 ++++++++ 15 files changed, 732 insertions(+), 506 deletions(-) rename src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/{InvalidJsonbArrayItemForPHPException.php => InvalidJsonArrayItemForPHPException.php} (78%) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonItemForPHPException.php create mode 100644 src/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformer.php rename src/MartinGeorgiev/Utils/{ArrayDataTransformer.php => PostgresArrayToPHPArrayTransformer.php} (64%) create mode 100644 src/MartinGeorgiev/Utils/PostgresJsonToPHPArrayTransformer.php delete mode 100644 tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php create mode 100644 tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php create mode 100644 tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php create mode 100644 tests/MartinGeorgiev/Utils/PostgresJsonToPHPArrayTransformerTest.php diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 755d671c..677a7c99 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -1,6 +1,6 @@ # Upgrade Instructions -## How to Upgrade to Version 3.0.0 +## How to Upgrade to Version 3.0 ### 1. Review type handling in your code If your application relies on automatic type conversion between PostgreSQL and PHP (e.g., expecting string numbers to be converted to actual numbers or vice versa), you'll need to update your code to explicitly handle type conversion where needed. @@ -16,7 +16,7 @@ $numericValue = (float)$tags[0] + 2; // Explicit conversion needed ``` ### 2. Update your code to handle exceptions -If you're catching specific exception types when working with `JsonbArray`, update your exception handling to catch the new `InvalidJsonbArrayItemForPHPException`. +If you're catching specific exception types when working with `JsonbArray`, update your exception handling to catch the new `InvalidJsonItemForPHPException` and `InvalidJsonArrayItemForPHPException`. ```php // Before @@ -29,7 +29,7 @@ try { // After try { $jsonArray = $jsonbArrayType->convertToPHPValue($postgresValue, $platform); -} catch (\MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidJsonbArrayItemForPHPException $e) { +} catch (\MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidJsonArrayItemForPHPException $e) { // Handle exception } ``` diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonbArrayItemForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonArrayItemForPHPException.php similarity index 78% rename from src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonbArrayItemForPHPException.php rename to src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonArrayItemForPHPException.php index 9fd2633e..261407d6 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonbArrayItemForPHPException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonArrayItemForPHPException.php @@ -6,7 +6,7 @@ use Doctrine\DBAL\Types\ConversionException; -class InvalidJsonbArrayItemForPHPException extends ConversionException +class InvalidJsonArrayItemForPHPException extends ConversionException { private static function create(string $message, mixed $value): self { @@ -20,6 +20,6 @@ public static function forInvalidType(mixed $value): self public static function forInvalidFormat(mixed $value): self { - return self::create('Invalid JSONB format in array: %s', $value); + return self::create('Invalid JSON format in array: %s', $value); } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonItemForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonItemForPHPException.php new file mode 100644 index 00000000..eab0c146 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidJsonItemForPHPException.php @@ -0,0 +1,25 @@ +transformFromPostgresJson($item); - if (!\is_array($transformedValue)) { - throw InvalidJsonbArrayItemForPHPException::forInvalidType($item); - } - - return $transformedValue; - } catch (\JsonException) { - throw InvalidJsonbArrayItemForPHPException::forInvalidFormat($item); - } + return PostgresJsonToPHPArrayTransformer::transformPostgresJsonEncodedValueToPHPArray($item); } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php index 67ab54b3..8c091558 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/TextArray.php @@ -5,7 +5,8 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types; use Doctrine\DBAL\Platforms\AbstractPlatform; -use MartinGeorgiev\Utils\ArrayDataTransformer; +use MartinGeorgiev\Utils\PHPArrayToPostgresValueTransformer; +use MartinGeorgiev\Utils\PostgresArrayToPHPArrayTransformer; /** * Implementation of PostgreSQL TEXT[] data type. @@ -42,7 +43,7 @@ protected function transformToPostgresTextArray(array $phpTextArray): string return '{}'; } - return ArrayDataTransformer::transformPHPArrayToPostgresTextArray($phpTextArray); + return PHPArrayToPostgresValueTransformer::transformToPostgresTextArray($phpTextArray); } /** @@ -65,6 +66,6 @@ protected function transformFromPostgresTextArray(string $postgresValue): array return []; } - return ArrayDataTransformer::transformPostgresTextArrayToPHPArray($postgresValue); + return PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue); } } diff --git a/src/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformer.php b/src/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformer.php new file mode 100644 index 00000000..705e8dd3 --- /dev/null +++ b/src/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformer.php @@ -0,0 +1,101 @@ + + */ +class PHPArrayToPostgresValueTransformer +{ + private const POSTGRESQL_EMPTY_ARRAY = '{}'; + + /** + * Transforms a PHP array to a PostgreSQL text array. + * 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 transformToPostgresTextArray(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 + { + if ($value === null) { + return 'NULL'; + } + + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (\is_object($value)) { + if (\method_exists($value, '__toString')) { + $stringValue = $value->__toString(); + } else { + // For objects without __toString, use a default representation + $stringValue = $value::class; + } + } elseif (\is_resource($value)) { + $stringValue = '(resource)'; + } else { + $valueType = \get_debug_type($value); + + if ($valueType === 'string') { + $stringValue = $value; + } elseif (\in_array($valueType, ['int', 'float', 'bool'], true)) { + /** @var bool|float|int $value */ + $stringValue = (string) $value; + } else { + $stringValue = $valueType; + } + } + + \assert(\is_string($stringValue)); + + if ($stringValue === '') { + return '""'; + } + + // Make sure strings are quoted, PostgreSQL will handle this gracefully + // Double the backslashes and escape quotes + $escaped = \str_replace( + ['\\', '"'], + ['\\\\', '\"'], + $stringValue + ); + + return '"'.$escaped.'"'; + } +} diff --git a/src/MartinGeorgiev/Utils/ArrayDataTransformer.php b/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php similarity index 64% rename from src/MartinGeorgiev/Utils/ArrayDataTransformer.php rename to src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php index 9bf8f93b..692e696c 100644 --- a/src/MartinGeorgiev/Utils/ArrayDataTransformer.php +++ b/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php @@ -7,23 +7,26 @@ use MartinGeorgiev\Utils\Exception\InvalidArrayFormatException; /** + * Handles transformation from PostgreSQL text arrays to PHP values. + * * @since 3.0 * * @author Martin Georgiev */ -class ArrayDataTransformer +class PostgresArrayToPHPArrayTransformer { private const POSTGRESQL_EMPTY_ARRAY = '{}'; private const POSTGRESQL_NULL_VALUE = 'null'; /** + * Transforms a PostgreSQL text array to a PHP array. * 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 + public static function transformPostgresArrayToPHPArray(string $postgresArray): array { $trimmed = \trim($postgresArray); @@ -89,9 +92,6 @@ public static function transformPostgresTextArrayToPHPArray(string $postgresArra ); } - /** - * Manually parse a PostgreSQL array content string. - */ private static function parsePostgresArrayManually(string $content): array { if ($content === '') { @@ -170,41 +170,26 @@ private static function processPostgresValue(string $value): mixed return $value; } - /** - * Check if the value is a NULL value. - */ private static function isNullValue(string $value): bool { return $value === 'NULL' || $value === 'null'; } - /** - * Check if the value is a boolean value. - */ private static function isBooleanValue(string $value): bool { return \in_array($value, ['true', 't', 'false', 'f'], true); } - /** - * Process a boolean value. - */ private static function processBooleanValue(string $value): bool { return $value === 'true' || $value === 't'; } - /** - * Check if the value is a quoted string. - */ private static function isQuotedString(string $value): bool { return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"'; } - /** - * Process a quoted string. - */ private static function processQuotedString(string $value): string { // Remove the quotes and unescape the string @@ -213,17 +198,11 @@ private static function processQuotedString(string $value): string return self::unescapeString($unquoted); } - /** - * Check if the value is a numeric value. - */ private static function isNumericValue(string $value): bool { return \is_numeric($value); } - /** - * Process a numeric value. - */ private static function processNumericValue(string $value): float|int { // Convert to int or float as appropriate @@ -234,103 +213,49 @@ private static function processNumericValue(string $value): float|int return (int) $value; } - /** - * 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 + private static function unescapeString(string $value): string { - // Handle null - if ($value === null) { - return 'NULL'; - } - - // Handle actual numbers - if (\is_int($value) || \is_float($value)) { - return (string) $value; - } + $result = ''; + $len = \strlen($value); + $i = 0; + $backslashCount = 0; - // Handle booleans - if (\is_bool($value)) { - return $value ? 'true' : 'false'; - } + while ($i < $len) { + if ($value[$i] === '\\') { + $backslashCount++; + $i++; - if (\is_object($value)) { - if (\method_exists($value, '__toString')) { - $stringValue = $value->__toString(); - } else { - // For objects without __toString, use a default representation - $stringValue = $value::class; + continue; } - } elseif (\is_resource($value)) { - $stringValue = '(resource)'; - } else { - $valueType = \get_debug_type($value); - - if ($valueType === 'string') { - $stringValue = $value; - } elseif (\in_array($valueType, ['int', 'float', 'bool'], true)) { - /** @var bool|float|int $value */ - $stringValue = (string) $value; + + if ($backslashCount > 0) { + if ($value[$i] === '"') { + // This is an escaped quote + $result .= \str_repeat('\\', (int) ($backslashCount / 2)); + if ($backslashCount % 2 === 1) { + $result .= '"'; + } else { + $result .= '\"'; + } + } else { + // These are literal backslashes + $result .= \str_repeat('\\', $backslashCount); + $result .= $value[$i]; + } + + $backslashCount = 0; } else { - $stringValue = $valueType; + $result .= $value[$i]; } - } - \assert(\is_string($stringValue)); - - // Handle empty string - if ($stringValue === '') { - return '""'; + $i++; } - // Always quote strings to match the test expectations - // Double the backslashes and escape quotes - $escaped = \str_replace( - ['\\', '"'], - ['\\\\', '\"'], - $stringValue - ); - - return '"'.$escaped.'"'; - } - - 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); + // Handle any trailing backslashes + if ($backslashCount > 0) { + $result .= \str_repeat('\\', $backslashCount); + } - // Finally restore quotes - return \str_replace('___QUOTE___', '"', $value); + return $result; } } diff --git a/src/MartinGeorgiev/Utils/PostgresJsonToPHPArrayTransformer.php b/src/MartinGeorgiev/Utils/PostgresJsonToPHPArrayTransformer.php new file mode 100644 index 00000000..da55e048 --- /dev/null +++ b/src/MartinGeorgiev/Utils/PostgresJsonToPHPArrayTransformer.php @@ -0,0 +1,65 @@ + + */ +class PostgresJsonToPHPArrayTransformer +{ + private const POSTGRESQL_EMPTY_ARRAY = '{}'; + + public static function transformPostgresArrayToPHPArray(string $postgresValue): array + { + if ($postgresValue === self::POSTGRESQL_EMPTY_ARRAY) { + return []; + } + + $trimmedPostgresArray = \mb_substr($postgresValue, 2, -2); + $phpArray = \explode('},{', $trimmedPostgresArray); + foreach ($phpArray as &$item) { + $item = '{'.$item.'}'; + } + + return $phpArray; + } + + /** + * @throws InvalidJsonArrayItemForPHPException When the PostgreSQL value is not a JSON + */ + public static function transformPostgresJsonEncodedValueToPHPArray(string $postgresValue): array + { + try { + $transformedValue = \json_decode($postgresValue, true, 512, JSON_THROW_ON_ERROR); + if (!\is_array($transformedValue)) { + throw InvalidJsonArrayItemForPHPException::forInvalidType($postgresValue); + } + + return $transformedValue; + } catch (\JsonException) { + throw InvalidJsonArrayItemForPHPException::forInvalidFormat($postgresValue); + } + } + + /** + * @throws InvalidJsonItemForPHPException When the PostgreSQL value is not JSON-decodable + */ + public static function transformPostgresJsonEncodedValueToPHPValue(string $postgresValue): null|array|bool|float|int|string + { + try { + // @phpstan-ignore-next-line + return \json_decode($postgresValue, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw InvalidJsonItemForPHPException::forInvalidType($postgresValue); + } + } +} diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTest.php index 0d2f80bb..368ec42d 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/JsonbArrayTest.php @@ -5,7 +5,7 @@ namespace Tests\MartinGeorgiev\Doctrine\DBAL\Types; use Doctrine\DBAL\Platforms\AbstractPlatform; -use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidJsonbArrayItemForPHPException; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidJsonArrayItemForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -97,8 +97,8 @@ public static function provideValidTransformations(): array */ public function throws_exception_when_invalid_data_provided_to_convert_to_php_value(string $postgresValue): void { - $this->expectException(InvalidJsonbArrayItemForPHPException::class); - $this->expectExceptionMessage('Invalid JSONB format in array'); + $this->expectException(InvalidJsonArrayItemForPHPException::class); + $this->expectExceptionMessage('Invalid JSON format in array'); $this->fixture->convertToPHPValue($postgresValue, $this->platform); } diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php index 7d1707f2..ae6f9780 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php @@ -71,12 +71,20 @@ public static function provideValidTransformations(): array 'postgresValue' => '{}', ], [ - 'phpValue' => ["single-back-slash\\"], - 'postgresValue' => '{"single-back-slash\\\\"}', + 'phpValue' => ['\single-back-slash-at-the-start-and-end\\'], + 'postgresValue' => '{"\\\single-back-slash-at-the-start-and-end\\\"}', ], [ - 'phpValue' => ["double-back-slash\\\\"], - 'postgresValue' => '{"double-back-slash\\\\\\\\"}', + 'phpValue' => ['double-back-slash-at-the-end\\\\'], + 'postgresValue' => '{"double-back-slash-at-the-end\\\\\\\"}', + ], + [ + 'phpValue' => ['triple-\\\\\-back-slash-in-the-middle'], + 'postgresValue' => '{"triple-\\\\\\\\\\\-back-slash-in-the-middle"}', + ], + [ + 'phpValue' => ['quadruple-back-slash\\\\\\\\'], + 'postgresValue' => '{"quadruple-back-slash\\\\\\\\\\\\\\\"}', ], [ 'phpValue' => [ @@ -120,7 +128,8 @@ public function can_transform_unquoted_postgres_array_to_php(): void public function can_handle_backslashes_correctly(): void { $postgresValue = '{"simple\\\backslash"}'; - $result = $this->fixture->convertToPHPValue($postgresValue, $this->platform); - self::assertEquals(['simple\backslash'], $result); + $expectedPhpValue = ['simple\backslash']; + + self::assertEquals($expectedPhpValue, $this->fixture->convertToPHPValue($postgresValue, $this->platform); } } diff --git a/tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php b/tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php deleted file mode 100644 index c8c97209..00000000 --- a/tests/MartinGeorgiev/Utils/ArrayDataTransformerTest.php +++ /dev/null @@ -1,351 +0,0 @@ -> $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/PHPArrayToPostgresValueTransformerTest.php b/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php new file mode 100644 index 00000000..ede49482 --- /dev/null +++ b/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php @@ -0,0 +1,163 @@ +> $phpValue + */ + public function can_transform_from_php_value(array $phpValue, string $postgresValue): void + { + self::assertEquals($postgresValue, PHPArrayToPostgresValueTransformer::transformToPostgresTextArray($phpValue)); + } + + /** + * @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 as integers are preserved as integers' => [ + 'phpValue' => [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + ], + 'postgresValue' => '{1,2,3,4}', + ], + 'simple strings' => [ + 'phpValue' => [ + 0 => 'this', + 1 => 'is', + 2 => 'a', + 3 => 'test', + ], + 'postgresValue' => '{"this","is","a","test"}', + ], + 'strings with special characters' => [ + 'phpValue' => [ + 0 => 'this has "quotes"', + 1 => 'this has \backslashes\\', + ], + 'postgresValue' => '{"this has \"quotes\"","this has \\\backslashes\\\"}', + ], + '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): void + { + $this->expectException(InvalidArrayFormatException::class); + PHPArrayToPostgresValueTransformer::transformToPostgresTextArray($phpValue); + } + + /** + * @return array + */ + public static function provideInvalidTransformations(): array + { + return [ + 'multi-dimensioned array' => [ + 'phpValue' => [ + 0 => [ + 'this', + 'is', + 'a', + 'test', + ], + ], + 'postgresValue' => '', + ], + ]; + } + + /** + * @test + */ + public function handles_resource_cleanup(): void + { + $resource = \fopen('php://memory', 'r'); + \assert(\is_resource($resource)); + $input = [$resource]; + $result = PHPArrayToPostgresValueTransformer::transformToPostgresTextArray($input); + \fclose($resource); + + self::assertSame('{"(resource)"}', $result); + } + + /** + * @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'); + PHPArrayToPostgresValueTransformer::transformToPostgresTextArray($phpValue); + } + + /** + * @return array + */ + public static function provideMultiDimensionalArrays(): array + { + return [ + 'array with nested array' => [ + 'phpValue' => [ + 'nested' => ['array'], + ], + ], + 'array with multiple nested arrays' => [ + 'phpValue' => [ + ['array1'], + ['array2'], + ], + ], + 'deeply nested array' => [ + 'phpValue' => [ + 'deeply' => [ + 'nested' => ['array'], + ], + ], + ], + ]; + } +} diff --git a/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php b/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php new file mode 100644 index 00000000..b34cc59f --- /dev/null +++ b/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php @@ -0,0 +1,159 @@ +> $phpValue + */ + public function can_transform_to_php_value(array $phpValue, string $postgresValue): void + { + self::assertEquals($phpValue, PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($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 as integers are preserved as integers' => [ + 'phpValue' => [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + ], + 'postgresValue' => '{1,2,3,4}', + ], + 'simple strings' => [ + 'phpValue' => [ + 0 => 'this', + 1 => 'is', + 2 => 'a', + 3 => 'test', + ], + 'postgresValue' => '{"this","is","a","test"}', + ], + 'strings with special characters' => [ + 'phpValue' => [ + 0 => 'this has "quotes"', + 1 => 'this has \\\backslashes\\\\', + ], + 'postgresValue' => '{"this has \"quotes\"","this has \\\\\\\backslashes\\\\\\\"}', + ], + '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 + */ + public function throws_invalid_argument_exception_when_tries_to_transform_invalid_postgres_array(string $postgresValue): void + { + $this->expectException(InvalidArrayFormatException::class); + PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue); + } + + /** + * @return array + */ + public static function provideInvalidTransformations(): array + { + return [ + 'multi-dimensioned array' => [ + 'postgresValue' => '{{1,2,3},{4,5,6}}', + ], + ]; + } + + /** + * @test + */ + public function handles_empty_input(): void + { + $input = ''; + $result = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); + self::assertSame([], $result); + } + + /** + * @test + */ + public function handles_null_string_input(): void + { + $input = 'null'; + $result = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); + self::assertSame([], $result); + } + + /** + * @test + */ + public function preserves_numeric_precision(): void + { + $input = '{"9223372036854775808","1.23456789012345"}'; + $output = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); + + self::assertSame(['9223372036854775808', '1.23456789012345'], $output); + } + + /** + * @test + * + * @dataProvider provideInvalidPostgresArrays + */ + public function throws_exception_for_invalid_postgres_arrays(string $postgresValue): void + { + $this->expectException(InvalidArrayFormatException::class); + $this->expectExceptionMessage('Invalid array format'); + PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($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/PostgresJsonToPHPArrayTransformerTest.php b/tests/MartinGeorgiev/Utils/PostgresJsonToPHPArrayTransformerTest.php new file mode 100644 index 00000000..1c71893d --- /dev/null +++ b/tests/MartinGeorgiev/Utils/PostgresJsonToPHPArrayTransformerTest.php @@ -0,0 +1,148 @@ + + */ + public static function provideValidJsonTransformations(): array + { + return [ + 'simple object' => [ + 'phpValue' => ['key' => 'value'], + 'postgresValue' => '{"key":"value"}', + ], + 'nested object' => [ + 'phpValue' => ['key' => ['nested' => 'value']], + 'postgresValue' => '{"key":{"nested":"value"}}', + ], + 'array' => [ + 'phpValue' => [1, 2, 3], + 'postgresValue' => '[1,2,3]', + ], + 'string' => [ + 'phpValue' => 'string', + 'postgresValue' => '"string"', + ], + 'number' => [ + 'phpValue' => 123, + 'postgresValue' => '123', + ], + 'boolean' => [ + 'phpValue' => true, + 'postgresValue' => 'true', + ], + 'null' => [ + 'phpValue' => null, + 'postgresValue' => 'null', + ], + ]; + } + + /** + * @test + * + * @dataProvider provideValidJsonbArrayTransformations + */ + public function can_transform_json_array_to_php_array(array $phpArray, string $postgresArray): void + { + self::assertEquals($phpArray, PostgresJsonToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresArray)); + } + + /** + * @return array + */ + public static function provideValidJsonbArrayTransformations(): array + { + return [ + 'empty array' => [ + 'phpArray' => [], + 'postgresArray' => '{}', + ], + 'array with one object' => [ + 'phpArray' => ['{key:value}'], + 'postgresArray' => '{{key:value}}', + ], + 'array with multiple objects' => [ + 'phpArray' => ['{key1:value1}', '{key2:value2}'], + 'postgresArray' => '{{key1:value1},{key2:value2}}', + ], + ]; + } + + /** + * @test + * + * @dataProvider provideValidJsonbArrayItemTransformations + */ + public function can_transform_json_array_item_to_php_array(array $phpArray, string $item): void + { + self::assertEquals($phpArray, PostgresJsonToPHPArrayTransformer::transformPostgresJsonEncodedValueToPHPArray($item)); + } + + /** + * @return array + */ + public static function provideValidJsonbArrayItemTransformations(): array + { + return [ + 'simple object' => [ + 'phpArray' => ['key' => 'value'], + 'item' => '{"key":"value"}', + ], + 'nested object' => [ + 'phpArray' => ['key' => ['nested' => 'value']], + 'item' => '{"key":{"nested":"value"}}', + ], + ]; + } + + /** + * @test + */ + public function throws_exception_for_invalid_json(): void + { + $this->expectException(InvalidJsonItemForPHPException::class); + $this->expectExceptionMessage("Postgres value must be single, valid JSON object, '{invalid json}' given"); + PostgresJsonToPHPArrayTransformer::transformPostgresJsonEncodedValueToPHPValue('{invalid json}'); + } + + /** + * @test + */ + public function throws_exception_for_invalid_json_array_item(): void + { + $this->expectException(InvalidJsonArrayItemForPHPException::class); + $this->expectExceptionMessage("Invalid JSON format in array: '{invalid json}'"); + PostgresJsonToPHPArrayTransformer::transformPostgresJsonEncodedValueToPHPArray('{invalid json}'); + } + + /** + * @test + */ + public function throws_exception_for_non_array_json_array_item(): void + { + $this->expectException(InvalidJsonArrayItemForPHPException::class); + $this->expectExceptionMessage('Array values must be valid JSON objects, \'"string"\' given'); + PostgresJsonToPHPArrayTransformer::transformPostgresJsonEncodedValueToPHPArray('"string"'); + } +} From 3ce33c4367a920c1a4e2b254f3bada4194421ae5 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Apr 2025 21:42:54 +0100 Subject: [PATCH 3/6] =?UTF-8?q?accidentally=20deleted=20the=20closing=20br?= =?UTF-8?q?acket=20=F0=9F=A4=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php index ae6f9780..58f34d84 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php @@ -130,6 +130,6 @@ public function can_handle_backslashes_correctly(): void $postgresValue = '{"simple\\\backslash"}'; $expectedPhpValue = ['simple\backslash']; - self::assertEquals($expectedPhpValue, $this->fixture->convertToPHPValue($postgresValue, $this->platform); + self::assertEquals($expectedPhpValue, $this->fixture->convertToPHPValue($postgresValue, $this->platform)); } } From 26d121af13f0b6bd901dfac584c6e2fa98d7e5ff Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Apr 2025 21:51:48 +0100 Subject: [PATCH 4/6] no message --- .../Utils/PostgresArrayToPHPArrayTransformer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php b/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php index 692e696c..b2071bfa 100644 --- a/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php +++ b/src/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformer.php @@ -44,7 +44,7 @@ public static function transformPostgresArrayToPHPArray(string $postgresArray): // Check for malformed nesting - this is a more specific check than the one above // But we need to exclude cases where curly braces are part of quoted strings - $content = \trim($trimmed, '{}'); + $content = \trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY); $inQuotes = false; $escaping = false; @@ -76,7 +76,7 @@ public static function transformPostgresArrayToPHPArray(string $postgresArray): } // First try with json_decode for properly quoted values - $jsonArray = '['.\trim($trimmed, '{}').']'; + $jsonArray = '['.\trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY).']'; /** @var array|null $decoded */ $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING); From ffe1c09b074118a5d93db56b17d3ab79d7b85196 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 6 Apr 2025 00:42:57 +0100 Subject: [PATCH 5/6] add complete tests --- ...PHPArrayToPostgresValueTransformerTest.php | 102 +++++++++++++++++- ...PostgresArrayToPHPArrayTransformerTest.php | 99 +++++++++++++++-- 2 files changed, 185 insertions(+), 16 deletions(-) diff --git a/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php b/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php index ede49482..7ae8b17a 100644 --- a/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php +++ b/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php @@ -28,6 +28,10 @@ public function can_transform_from_php_value(array $phpValue, string $postgresVa public static function provideValidTransformations(): array { return [ + 'empty array' => [ + 'phpValue' => [], + 'postgresValue' => '{}', + ], 'simple integer strings as strings are preserved as strings' => [ 'phpValue' => [ 0 => '1', @@ -46,6 +50,36 @@ public static function provideValidTransformations(): array ], 'postgresValue' => '{1,2,3,4}', ], + 'float values' => [ + 'phpValue' => [ + 0 => 1.5, + 1 => 2.75, + 2 => -3.25, + ], + 'postgresValue' => '{1.5,2.75,-3.25}', + ], + 'boolean values' => [ + 'phpValue' => [ + 0 => true, + 1 => false, + ], + 'postgresValue' => '{true,false}', + ], + 'null values' => [ + 'phpValue' => [ + 0 => null, + 1 => 'not null', + 2 => null, + ], + 'postgresValue' => '{NULL,"not null",NULL}', + ], + 'empty string' => [ + 'phpValue' => [ + 0 => '', + 1 => 'not empty', + ], + 'postgresValue' => '{"","not empty"}', + ], 'simple strings' => [ 'phpValue' => [ 0 => 'this', @@ -109,15 +143,73 @@ public static function provideInvalidTransformations(): array /** * @test */ - public function handles_resource_cleanup(): void + public function can_can_transform_object_with_to_string_method(): void { - $resource = \fopen('php://memory', 'r'); + $object = new class { + public function __toString(): string + { + return 'object string representation'; + } + }; + + self::assertSame('{"object string representation"}', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$object])); + } + + /** + * @test + */ + public function can_transform_object_without_to_string_method(): void + { + $object = new class {}; + + // Should contain the class name + self::assertStringContainsString('class@anonymous', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$object])); + } + + /** + * @test + */ + public function can_transform_closed_resource(): void + { + $resource = \fopen('php://temp', 'r'); \assert(\is_resource($resource)); - $input = [$resource]; - $result = PHPArrayToPostgresValueTransformer::transformToPostgresTextArray($input); \fclose($resource); - self::assertSame('{"(resource)"}', $result); + self::assertStringContainsString('resource (closed)', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$resource])); + } + + /** + * @test + */ + public function can_transform_open_resource(): void + { + $resource = \fopen('php://temp', 'r'); + \assert(\is_resource($resource)); + + self::assertStringContainsString('(resource)', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$resource])); + } + + /** + * @test + */ + public function can_transform_mixed_types_in_array(): void + { + $input = [ + 'string', + 123, + 1.5, + true, + null, + new class { + public function __toString(): string + { + return 'object'; + } + }, + '', + ]; + + self::assertEquals('{"string",123,1.5,true,NULL,"object",""}', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray($input)); } /** diff --git a/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php b/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php index b34cc59f..b739ec13 100644 --- a/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php +++ b/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php @@ -28,6 +28,18 @@ public function can_transform_to_php_value(array $phpValue, string $postgresValu public static function provideValidTransformations(): array { return [ + 'null value' => [ + 'phpValue' => [], + 'postgresValue' => 'null', + ], + 'empty value' => [ + 'phpValue' => [], + 'postgresValue' => '', + ], + 'empty array' => [ + 'phpValue' => [], + 'postgresValue' => '{}', + ], 'simple integer strings as strings are preserved as strings' => [ 'phpValue' => [ 0 => '1', @@ -46,6 +58,38 @@ public static function provideValidTransformations(): array ], 'postgresValue' => '{1,2,3,4}', ], + 'float values' => [ + 'phpValue' => [ + 0 => 1.5, + 1 => 2.75, + 2 => -3.25, + ], + 'postgresValue' => '{1.5,2.75,-3.25}', + ], + 'scientific notation' => [ + 'phpValue' => [ + 0 => 1.5e3, + 1 => 2.75e-2, + ], + 'postgresValue' => '{1.5e3,2.75e-2}', + ], + 'boolean values' => [ + 'phpValue' => [ + 0 => true, + 1 => false, + 2 => true, + 3 => false, + ], + 'postgresValue' => '{true,false,t,f}', + ], + 'null values' => [ + 'phpValue' => [ + 0 => null, + 1 => 'not null', + 2 => null, + ], + 'postgresValue' => '{NULL,"not null",null}', + ], 'simple strings' => [ 'phpValue' => [ 0 => 'this', @@ -70,24 +114,33 @@ public static function provideValidTransformations(): array 'phpValue' => ['Hello δΈ–η•Œ', '🌍 Earth'], 'postgresValue' => '{"Hello δΈ–η•Œ","🌍 Earth"}', ], + 'unquoted strings' => [ + 'phpValue' => ['unquoted', 'strings'], + 'postgresValue' => '{unquoted,strings}', + ], + 'mixed quoted and unquoted strings' => [ + 'phpValue' => ['quoted', 'unquoted'], + 'postgresValue' => '{"quoted",unquoted}', + ], ]; } /** * @test * - * @dataProvider provideInvalidTransformations + * @dataProvider provideMultiDimensionalArrays */ - public function throws_invalid_argument_exception_when_tries_to_transform_invalid_postgres_array(string $postgresValue): void + public function throws_exception_for_multi_dimensional_arrays(string $postgresValue): void { $this->expectException(InvalidArrayFormatException::class); + $this->expectExceptionMessage('Only single-dimensioned arrays are supported'); PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue); } /** * @return array */ - public static function provideInvalidTransformations(): array + public static function provideMultiDimensionalArrays(): array { return [ 'multi-dimensioned array' => [ @@ -98,28 +151,52 @@ public static function provideInvalidTransformations(): array /** * @test + * + * @dataProvider provideManualParsingArrays */ - public function handles_empty_input(): void + public function can_recover_from_json_decode_failure_and_transform_value_through_manual_parsing(array $phpValue, string $postgresValue): void { - $input = ''; - $result = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); - self::assertSame([], $result); + self::assertEquals($phpValue, PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresValue)); + } + + /** + * @return array + */ + public static function provideManualParsingArrays(): array + { + return [ + 'manual parsing of unquoted only text' => [ + 'phpValue' => ['unquoted string', 'another unquoted string'], + 'postgresValue' => '{unquoted string,another unquoted string}', + ], + 'manual parsing with escaping' => [ + 'phpValue' => ['escaped " quote', 'unescaped'], + 'postgresValue' => '{"escaped \" quote",unescaped}', + ], + 'manual parsing with trailing backslash' => [ + 'phpValue' => ['backslash\\', 'another\one'], + 'postgresValue' => '{backslash\,another\one}', + ], + ]; } /** * @test */ - public function handles_null_string_input(): void + public function can_transform_escaped_quotes_with_backslashes(): void { - $input = 'null'; + // Test with escaped quotes and backslashes + $input = '{"\\\"quoted\\\""}'; $result = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); - self::assertSame([], $result); + // Just verify it's a string + self::assertIsString($result[0]); + self::assertStringContainsString('quoted', $result[0]); } /** * @test */ - public function preserves_numeric_precision(): void + public function can_preserves_numeric_precision(): void { $input = '{"9223372036854775808","1.23456789012345"}'; $output = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); From 1ed8aaa4bb948c37b36b753b6c364852e7fde72b Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 6 Apr 2025 05:28:08 +0100 Subject: [PATCH 6/6] tweak tests --- .../PHPArrayToPostgresValueTransformerTest.php | 6 +++--- .../PostgresArrayToPHPArrayTransformerTest.php | 14 ++++---------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php b/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php index 7ae8b17a..3ad91054 100644 --- a/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php +++ b/tests/MartinGeorgiev/Utils/PHPArrayToPostgresValueTransformerTest.php @@ -143,7 +143,7 @@ public static function provideInvalidTransformations(): array /** * @test */ - public function can_can_transform_object_with_to_string_method(): void + public function can_transform_object_with_to_string_method(): void { $object = new class { public function __toString(): string @@ -175,7 +175,7 @@ public function can_transform_closed_resource(): void \assert(\is_resource($resource)); \fclose($resource); - self::assertStringContainsString('resource (closed)', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$resource])); + self::assertSame('{"resource (closed)"}', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$resource])); } /** @@ -186,7 +186,7 @@ public function can_transform_open_resource(): void $resource = \fopen('php://temp', 'r'); \assert(\is_resource($resource)); - self::assertStringContainsString('(resource)', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$resource])); + self::assertSame('{"(resource)"}', PHPArrayToPostgresValueTransformer::transformToPostgresTextArray([$resource])); } /** diff --git a/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php b/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php index b739ec13..66c2fc48 100644 --- a/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php +++ b/tests/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php @@ -185,12 +185,8 @@ public static function provideManualParsingArrays(): array */ public function can_transform_escaped_quotes_with_backslashes(): void { - // Test with escaped quotes and backslashes - $input = '{"\\\"quoted\\\""}'; - $result = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); - // Just verify it's a string - self::assertIsString($result[0]); - self::assertStringContainsString('quoted', $result[0]); + $postgresArray = '{"\\\"quoted\\\""}'; + self::assertSame(['\\\"quoted\\\"'], PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresArray)); } /** @@ -198,10 +194,8 @@ public function can_transform_escaped_quotes_with_backslashes(): void */ public function can_preserves_numeric_precision(): void { - $input = '{"9223372036854775808","1.23456789012345"}'; - $output = PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($input); - - self::assertSame(['9223372036854775808', '1.23456789012345'], $output); + $postgresArray = '{"9223372036854775808","1.23456789012345"}'; + self::assertSame(['9223372036854775808', '1.23456789012345'], PostgresArrayToPHPArrayTransformer::transformPostgresArrayToPHPArray($postgresArray)); } /**