Passed
Push — main ( 05a0af...c5f732 )
by Martin
13:23
created

parsePostgresArrayManually()   B

Complexity

Conditions 10
Paths 13

Size

Total Lines 49
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 10.005

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 10
eloc 27
c 1
b 1
f 0
nc 13
nop 1
dl 0
loc 49
ccs 26
cts 27
cp 0.963
crap 10.005
rs 7.6666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace MartinGeorgiev\Utils;
6
7
use MartinGeorgiev\Utils\Exception\InvalidArrayFormatException;
8
9
/**
10
 * Handles transformation from PostgreSQL text arrays to PHP values.
11
 *
12
 * @since 3.0
13
 *
14
 * @author Martin Georgiev <[email protected]>
15
 */
16
class PostgresArrayToPHPArrayTransformer
17
{
18
    private const POSTGRESQL_EMPTY_ARRAY = '{}';
19
20
    private const POSTGRESQL_NULL_VALUE = 'null';
21
22
    /**
23
     * Transforms a PostgreSQL text array to a PHP array.
24
     * This method supports only single-dimensioned text arrays and
25
     * relays on the default escaping strategy in PostgreSQL (double quotes).
26
     *
27
     * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has invalid format
28
     */
29 42
    public static function transformPostgresArrayToPHPArray(string $postgresArray): array
30
    {
31 42
        $trimmed = \trim($postgresArray);
32
33 42
        if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
34 3
            return [];
35
        }
36
37 39
        if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) {
38 1
            throw InvalidArrayFormatException::multiDimensionalArrayNotSupported();
39
        }
40
41 38
        if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) {
42 1
            return [];
43
        }
44
45
        // Check for malformed nesting - this is a more specific check than the one above
46
        // But we need to exclude cases where curly braces are part of quoted strings
47 37
        $content = \trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY);
48 37
        $inQuotes = false;
49 37
        $escaping = false;
50
51 37
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
52 37
            $char = $content[$i];
53
54 37
            if ($escaping) {
55 14
                $escaping = false;
56
57 14
                continue;
58
            }
59
60 37
            if ($char === '\\' && $inQuotes) {
61 14
                $escaping = true;
62
63 14
                continue;
64
            }
65
66 37
            if ($char === '"') {
67 26
                $inQuotes = !$inQuotes;
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
68 36
            } elseif (($char === '{' || $char === '}') && !$inQuotes) {
69 1
                throw InvalidArrayFormatException::invalidFormat('Malformed array nesting detected');
70
            }
71
        }
72
73
        // Check for unclosed quotes
74 36
        if ($inQuotes) {
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
75 2
            throw InvalidArrayFormatException::invalidFormat('Unclosed quotes in array');
76
        }
77
78
        // First try with json_decode for properly quoted values
79 34
        $jsonArray = '['.\trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY).']';
80
81
        /** @var array<int, mixed>|null $decoded */
82 34
        $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
83
84
        // If json_decode fails, try manual parsing for unquoted strings
85 34
        if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) {
86 13
            return self::parsePostgresArrayManually($content);
87
        }
88
89 21
        return \array_map(
90 21
            static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value,
91 21
            (array) $decoded
92 21
        );
93
    }
94
95 13
    private static function parsePostgresArrayManually(string $content): array
96
    {
97 13
        if ($content === '') {
98
            return [];
99
        }
100
101
        // Parse the array manually, handling quoted and unquoted values
102 13
        $result = [];
103 13
        $inQuotes = false;
104 13
        $currentValue = '';
105 13
        $escaping = false;
106
107 13
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
108 13
            $char = $content[$i];
109
110
            // Handle escaping within quotes
111 13
            if ($escaping) {
112 4
                $currentValue .= $char;
113 4
                $escaping = false;
114
115 4
                continue;
116
            }
117
118 13
            if ($char === '\\' && $inQuotes) {
119 4
                $escaping = true;
120 4
                $currentValue .= $char;
121
122 4
                continue;
123
            }
124
125 13
            if ($char === '"') {
126 6
                $inQuotes = !$inQuotes;
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
127
                // For quoted values, we include the quotes for later processing
128 6
                $currentValue .= $char;
129 13
            } elseif ($char === ',' && !$inQuotes) {
130
                // End of value
131 9
                $result[] = self::processPostgresValue($currentValue);
132 9
                $currentValue = '';
133
            } else {
134 13
                $currentValue .= $char;
135
            }
136
        }
137
138
        // Add the last value
139 13
        if ($currentValue !== '') {
140 12
            $result[] = self::processPostgresValue($currentValue);
141
        }
142
143 13
        return $result;
144
    }
145
146
    /**
147
     * Process a single value from a PostgreSQL array.
148
     */
149 13
    private static function processPostgresValue(string $value): mixed
150
    {
151 13
        $value = \trim($value);
152
153 13
        if (self::isNullValue($value)) {
154 1
            return null;
155
        }
156
157 13
        if (self::isBooleanValue($value)) {
158 1
            return self::processBooleanValue($value);
159
        }
160
161 12
        if (self::isQuotedString($value)) {
162 6
            return self::processQuotedString($value);
163
        }
164
165 8
        if (self::isNumericValue($value)) {
166
            return self::processNumericValue($value);
167
        }
168
169
        // For unquoted strings, return as is
170 8
        return $value;
171
    }
172
173 13
    private static function isNullValue(string $value): bool
174
    {
175 13
        return $value === 'NULL' || $value === 'null';
176
    }
177
178 13
    private static function isBooleanValue(string $value): bool
179
    {
180 13
        return \in_array($value, ['true', 't', 'false', 'f'], true);
181
    }
182
183 1
    private static function processBooleanValue(string $value): bool
184
    {
185 1
        return $value === 'true' || $value === 't';
186
    }
187
188 12
    private static function isQuotedString(string $value): bool
189
    {
190 12
        return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"';
191
    }
192
193 6
    private static function processQuotedString(string $value): string
194
    {
195
        // Remove the quotes and unescape the string
196 6
        $unquoted = \substr($value, 1, -1);
197
198 6
        return self::unescapeString($unquoted);
199
    }
200
201 8
    private static function isNumericValue(string $value): bool
202
    {
203 8
        return \is_numeric($value);
204
    }
205
206
    private static function processNumericValue(string $value): float|int
207
    {
208
        // Convert to int or float as appropriate
209
        if (\str_contains($value, '.') || \stripos($value, 'e') !== false) {
210
            return (float) $value;
211
        }
212
213
        return (int) $value;
214
    }
215
216 24
    private static function unescapeString(string $value): string
217
    {
218 24
        $result = '';
219 24
        $len = \strlen($value);
220 24
        $i = 0;
221 24
        while ($i < $len) {
222 23
            if ($value[$i] === '\\') {
223 12
                $start = $i;
224 12
                while ($i < $len && $value[$i] === '\\') {
225 12
                    $i++;
226
                }
227
228 12
                $slashCount = $i - $start;
229 12
                $nextChar = $i < $len ? $value[$i] : '';
230 12
                if ($nextChar === '"' || $nextChar === '\\') {
231 3
                    $result .= \str_repeat('\\', \intdiv($slashCount, 2));
232 3
                    if ($slashCount % 2 === 1) {
233 2
                        $result .= $nextChar;
234 2
                        $i++;
235
                    }
236
                } else {
237 10
                    $result .= \str_repeat('\\', $slashCount);
238
                }
239
240 12
                continue;
241
            }
242
243 23
            $result .= $value[$i];
244 23
            $i++;
245
        }
246
247 24
        return $result;
248
    }
249
}
250