Passed
Push — main ( 339e98...90c0a6 )
by Martin
40:38 queued 25:40
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 32
    public static function transformPostgresArrayToPHPArray(string $postgresArray): array
30
    {
31 32
        $trimmed = \trim($postgresArray);
32
33 32
        if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
34 2
            return [];
35
        }
36
37 30
        if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) {
38 1
            throw InvalidArrayFormatException::multiDimensionalArrayNotSupported();
39
        }
40
41 29
        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 28
        $content = \trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY);
48 28
        $inQuotes = false;
49 28
        $escaping = false;
50
51 28
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
52 28
            $char = $content[$i];
53
54 28
            if ($escaping) {
55 10
                $escaping = false;
56
57 10
                continue;
58
            }
59
60 28
            if ($char === '\\' && $inQuotes) {
61 10
                $escaping = true;
62
63 10
                continue;
64
            }
65
66 28
            if ($char === '"') {
67 19
                $inQuotes = !$inQuotes;
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
68 28
            } elseif (($char === '{' || $char === '}') && !$inQuotes) {
69 1
                throw InvalidArrayFormatException::invalidFormat('Malformed array nesting detected');
70
            }
71
        }
72
73
        // Check for unclosed quotes
74 27
        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 25
        $jsonArray = '['.\trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY).']';
80
81
        /** @var array<int, mixed>|null $decoded */
82 25
        $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
83
84
        // If json_decode fails, try manual parsing for unquoted strings
85 25
        if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) {
86 9
            return self::parsePostgresArrayManually($content);
87
        }
88
89 16
        return \array_map(
90 16
            static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value,
91 16
            (array) $decoded
92 16
        );
93
    }
94
95 9
    private static function parsePostgresArrayManually(string $content): array
96
    {
97 9
        if ($content === '') {
98
            return [];
99
        }
100
101
        // Parse the array manually, handling quoted and unquoted values
102 9
        $result = [];
103 9
        $inQuotes = false;
104 9
        $currentValue = '';
105 9
        $escaping = false;
106
107 9
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
108 9
            $char = $content[$i];
109
110
            // Handle escaping within quotes
111 9
            if ($escaping) {
112 2
                $currentValue .= $char;
113 2
                $escaping = false;
114
115 2
                continue;
116
            }
117
118 9
            if ($char === '\\' && $inQuotes) {
119 2
                $escaping = true;
120 2
                $currentValue .= $char;
121
122 2
                continue;
123
            }
124
125 9
            if ($char === '"') {
126 4
                $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 4
                $currentValue .= $char;
129 9
            } elseif ($char === ',' && !$inQuotes) {
130
                // End of value
131 8
                $result[] = self::processPostgresValue($currentValue);
132 8
                $currentValue = '';
133
            } else {
134 9
                $currentValue .= $char;
135
            }
136
        }
137
138
        // Add the last value
139 9
        if ($currentValue !== '') {
140 9
            $result[] = self::processPostgresValue($currentValue);
141
        }
142
143 9
        return $result;
144
    }
145
146
    /**
147
     * Process a single value from a PostgreSQL array.
148
     */
149 9
    private static function processPostgresValue(string $value): mixed
150
    {
151 9
        $value = \trim($value);
152
153 9
        if (self::isNullValue($value)) {
154 1
            return null;
155
        }
156
157 9
        if (self::isBooleanValue($value)) {
158 1
            return self::processBooleanValue($value);
159
        }
160
161 8
        if (self::isQuotedString($value)) {
162 4
            return self::processQuotedString($value);
163
        }
164
165 6
        if (self::isNumericValue($value)) {
166
            return self::processNumericValue($value);
167
        }
168
169
        // For unquoted strings, return as is
170 6
        return $value;
171
    }
172
173 9
    private static function isNullValue(string $value): bool
174
    {
175 9
        return $value === 'NULL' || $value === 'null';
176
    }
177
178 9
    private static function isBooleanValue(string $value): bool
179
    {
180 9
        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 8
    private static function isQuotedString(string $value): bool
189
    {
190 8
        return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"';
191
    }
192
193 4
    private static function processQuotedString(string $value): string
194
    {
195
        // Remove the quotes and unescape the string
196 4
        $unquoted = \substr($value, 1, -1);
197
198 4
        return self::unescapeString($unquoted);
199
    }
200
201 6
    private static function isNumericValue(string $value): bool
202
    {
203 6
        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 17
    private static function unescapeString(string $value): string
217
    {
218 17
        $result = '';
219 17
        $len = \strlen($value);
220 17
        $i = 0;
221 17
        $backslashCount = 0;
222
223 17
        while ($i < $len) {
224 17
            if ($value[$i] === '\\') {
225 9
                $backslashCount++;
226 9
                $i++;
227
228 9
                continue;
229
            }
230
231 17
            if ($backslashCount > 0) {
232 7
                if ($value[$i] === '"') {
233
                    // This is an escaped quote
234 2
                    $result .= \str_repeat('\\', (int) ($backslashCount / 2));
235 2
                    if ($backslashCount % 2 === 1) {
236 1
                        $result .= '"';
237
                    } else {
238 1
                        $result .= '\"';
239
                    }
240
                } else {
241
                    // These are literal backslashes
242 5
                    $result .= \str_repeat('\\', $backslashCount);
243 5
                    $result .= $value[$i];
244
                }
245
246 7
                $backslashCount = 0;
247
            } else {
248 17
                $result .= $value[$i];
249
            }
250
251 17
            $i++;
252
        }
253
254
        // Handle any trailing backslashes
255 17
        if ($backslashCount > 0) {
256 4
            $result .= \str_repeat('\\', $backslashCount);
257
        }
258
259 17
        return $result;
260
    }
261
}
262