Passed
Pull Request — main (#304)
by Martin
12:03
created

transformPostgresTextArrayToPHPArray()   B

Complexity

Conditions 9
Paths 5

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 9.0239

Importance

Changes 0
Metric Value
cc 9
eloc 14
nc 5
nop 1
dl 0
loc 27
ccs 14
cts 15
cp 0.9333
crap 9.0239
rs 8.0555
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace MartinGeorgiev\Utils;
6
7
/**
8
 * @since 3.0
9
 *
10
 * @author Martin Georgiev <[email protected]>
11
 */
12
class ArrayDataTransformer
13
{
14
    private const POSTGRESQL_EMPTY_ARRAY = '{}';
15
16
    private const POSTGRESQL_NULL_VALUE = 'null';
17
18
    /**
19
     * This method supports only single-dimensioned text arrays and
20
     * relays on the default escaping strategy in PostgreSQL (double quotes).
21
     */
22 20
    public static function transformPostgresTextArrayToPHPArray(string $postgresArray): array
23
    {
24 20
        $trimmed = \trim($postgresArray);
25
26 20
        if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
27
            return [];
28
        }
29
30 20
        if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) {
31 1
            throw new \InvalidArgumentException('Only single-dimensioned arrays are supported');
32
        }
33
34 19
        if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) {
35 1
            return [];
36
        }
37
38 18
        $jsonArray = '['.\trim($trimmed, '{}').']';
39
40
        /** @var array<int, mixed>|null $decoded */
41 18
        $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
42 18
        if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) {
43 1
            throw new \InvalidArgumentException('Invalid array format: '.\json_last_error_msg());
44
        }
45
46 17
        return \array_map(
47 17
            static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value,
48 17
            (array) $decoded
49 17
        );
50
    }
51
52
    /**
53
     * This method supports only single-dimensioned PHP arrays.
54
     * This method relays on the default escaping strategy in PostgreSQL (double quotes).
55
     */
56 20
    public static function transformPHPArrayToPostgresTextArray(array $phpArray): string
57
    {
58 20
        if ($phpArray === []) {
59 1
            return self::POSTGRESQL_EMPTY_ARRAY;
60
        }
61
62 19
        if (\array_filter($phpArray, 'is_array')) {
63 1
            throw new \InvalidArgumentException('Only single-dimensioned arrays are supported');
64
        }
65
66
        /** @var array<int|string, string> */
67 18
        $processed = \array_map(
68 18
            static fn (mixed $value): string => self::formatValue($value),
69 18
            $phpArray
70 18
        );
71
72 18
        return '{'.\implode(',', $processed).'}';
73
    }
74
75
    /**
76
     * Formats a single value for PostgreSQL array.
77
     */
78 18
    private static function formatValue(mixed $value): string
79
    {
80
        // Handle null
81 18
        if ($value === null) {
82
            return 'NULL';
83
        }
84
85
        // Handle actual numbers
86 18
        if (\is_int($value) || \is_float($value)) {
87 6
            return (string) $value;
88
        }
89
90
        // Handle booleans
91 16
        if (\is_bool($value)) {
92 1
            return $value ? 'true' : 'false';
93
        }
94
95
        // Handle objects that implement __toString()
96 15
        if (\is_object($value)) {
97 1
            if (\method_exists($value, '__toString')) {
98 1
                $stringValue = $value->__toString();
99
            } else {
100
                // For objects without __toString, use a default representation
101
                $stringValue = $value::class;
102
            }
103
        } else {
104
            // For all other types, force string conversion
105
            // This covers strings, resources, and other types
106 14
            $stringValue = match (true) {
107 14
                \is_resource($value) => '(resource)',
108 13
                default => (string) $value // @phpstan-ignore-line
109 14
            };
110
        }
111
112 15
        \assert(\is_string($stringValue));
113
114
        // Handle empty string
115 15
        if ($stringValue === '') {
116 1
            return '""';
117
        }
118
119 14
        if (self::isNumericSimple($stringValue)) {
120 8
            return '"'.$stringValue.'"';
121
        }
122
123
        // Double the backslashes and escape quotes
124 7
        $escaped = \str_replace(
125 7
            ['\\', '"'],
126 7
            ['\\\\', '\"'],
127 7
            $stringValue
128 7
        );
129
130 7
        return '"'.$escaped.'"';
131
    }
132
133 14
    private static function isNumericSimple(string $value): bool
134
    {
135
        // Fast path for obvious numeric strings
136 14
        if ($value === '' || $value[0] === '"') {
137 1
            return false;
138
        }
139
140
        // Handle scientific notation
141 14
        $lower = \strtolower($value);
142 14
        if (\str_contains($lower, 'e')) {
143 8
            $value = \str_replace('e', '', $lower);
144
        }
145
146
        // Use built-in numeric check
147 14
        return \is_numeric($value);
148
    }
149
150 14
    private static function unescapeString(string $value): string
151
    {
152
        // First handle escaped quotes
153 14
        $value = \str_replace('\"', '___QUOTE___', $value);
154
155
        // Handle double backslashes
156 14
        $value = \str_replace('\\\\', '___DBLBACK___', $value);
157
158
        // Restore double backslashes
159 14
        $value = \str_replace('___DBLBACK___', '\\\\', $value);
160
161
        // Finally restore quotes
162 14
        return \str_replace('___QUOTE___', '"', $value);
163
    }
164
}
165