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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/MartinGeorgiev/Doctrine/DBAL/Types/BaseIntegerArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down
179 changes: 129 additions & 50 deletions src/MartinGeorgiev/Utils/DataStructure.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,78 +13,157 @@
*/
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]);

break;
}

$isInteger = \is_numeric($text) && ''.(int) $text === $text;
if ($isInteger) {
$phpArray[$i] = (int) $text;
$trimmed = \trim($postgresArray);

continue;
}
if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
return [];
}

$isFloat = \is_numeric($text) && ''.(float) $text === $text;
if ($isFloat) {
$phpArray[$i] = (float) $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 [];
}

$phpArray[$i] = \stripslashes(\str_replace('\"', '"', $text));
}
$jsonArray = '['.\trim($trimmed, '{}').']';

return $phpArray;
};
/** @var array<int, mixed>|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 $transform($postgresArray);
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).
*
* @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;
if ($phpArray === []) {
return self::POSTGRESQL_EMPTY_ARRAY;
}

if (\array_filter($phpArray, 'is_array')) {
throw new \InvalidArgumentException('Only single-dimensioned arrays are supported');
}

/** @var array<int|string, string> */
$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);

// Handle remaining single backslashes
$value = \str_replace('\\', '\\', $value);

return '{'.\implode(',', $result).'}';
};
// Restore double backslashes
$value = \str_replace('___DBLBACK___', '\\\\', $value);

return $transform($phpArray);
// Finally restore quotes
return \str_replace('___QUOTE___', '"', $value);
}
}
4 changes: 3 additions & 1 deletion tests/MartinGeorgiev/Doctrine/DBAL/Types/TextArrayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ public static function provideValidTransformations(): array
'phpValue' => [
1,
'2',
3.4,
'5.6',
'text',
'some text here',
'and some here',
Expand All @@ -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,
],
];
Expand Down
67 changes: 56 additions & 11 deletions tests/MartinGeorgiev/Utils/DataStructureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,35 +34,48 @@ 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<array{
* phpValue: array,
* postgresValue: string
* }>
* @return array<string, array{phpValue: array, postgresValue: string}>
*/
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',
Expand All @@ -72,17 +85,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"}',
],
];
}

Expand Down