Passed
Pull Request — main (#333)
by Martin
27:53 queued 12:49
created

parsePostgresArrayManually()   B

Complexity

Conditions 10
Paths 13

Size

Total Lines 49
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 13.7025

Importance

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