parsePostgresArrayManually()   B
last analyzed

Complexity

Conditions 10
Paths 13

Size

Total Lines 48
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 2
dl 0
loc 48
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-dimensional text arrays and
25
     * relies on the default escaping strategy in PostgreSQL (double quotes).
26
     *
27
     * @param bool $preserveStringTypes When true, all unquoted values are preserved as strings without type inference.
28
     *                                  This is useful for text arrays where PostgreSQL may omit quotes for values that look numeric.
29
     *
30
     * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has an invalid format
31
     */
32 66
    public static function transformPostgresArrayToPHPArray(string $postgresArray, bool $preserveStringTypes = false): array
33
    {
34 66
        $trimmed = \trim($postgresArray);
35
36 66
        if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
37 3
            return [];
38
        }
39
40 63
        if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) {
41 1
            throw InvalidArrayFormatException::multiDimensionalArrayNotSupported();
42
        }
43
44 62
        if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) {
45 3
            return [];
46
        }
47
48
        // Check for malformed nesting - this is a more specific check than the one above
49
        // But we need to exclude cases where curly braces are part of quoted strings
50 59
        $content = \trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY);
51 59
        $inQuotes = false;
52 59
        $escaping = false;
53
54 59
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
55 59
            $char = $content[$i];
56
57 59
            if ($escaping) {
58 17
                $escaping = false;
59
60 17
                continue;
61
            }
62
63 59
            if ($char === '\\' && $inQuotes) {
64 17
                $escaping = true;
65
66 17
                continue;
67
            }
68
69 59
            if ($char === '"') {
70 36
                $inQuotes = !$inQuotes;
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
71 57
            } elseif (($char === '{' || $char === '}') && !$inQuotes) {
72 1
                throw InvalidArrayFormatException::invalidFormat('Malformed array nesting detected');
73
            }
74
        }
75
76 58
        if ($inQuotes) {
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
77 2
            throw InvalidArrayFormatException::invalidFormat('Unclosed quotes in array');
78
        }
79
80 56
        if ($preserveStringTypes) {
81 25
            return self::parsePostgresArrayManually($content, true);
82
        }
83
84 31
        $jsonArray = '['.\trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY).']';
85
86
        /** @var array<int, mixed>|null $decoded */
87 31
        $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
88 31
        $jsonDecodingFailed = $decoded === null && \json_last_error() !== JSON_ERROR_NONE;
89 31
        if ($jsonDecodingFailed) {
90 14
            return self::parsePostgresArrayManually($content, false);
91
        }
92
93 17
        return (array) $decoded;
94
    }
95
96 39
    private static function parsePostgresArrayManually(string $content, bool $preserveStringTypes): array
97
    {
98 39
        if ($content === '') {
99
            return [];
100
        }
101
102 39
        $result = [];
103 39
        $inQuotes = false;
104 39
        $currentValue = '';
105 39
        $escaping = false;
106
107 39
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
108 39
            $char = $content[$i];
109
110
            // Handle escaping within quotes
111 39
            if ($escaping) {
112 11
                $currentValue .= $char;
113 11
                $escaping = false;
114
115 11
                continue;
116
            }
117
118 39
            if ($char === '\\' && $inQuotes) {
119 11
                $escaping = true;
120 11
                $currentValue .= $char;
121
122 11
                continue;
123
            }
124
125 39
            if ($char === '"') {
126 20
                $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 20
                $currentValue .= $char;
129 39
            } elseif ($char === ',' && !$inQuotes) {
130
                // End of value
131 30
                $result[] = self::processPostgresValue($currentValue, $preserveStringTypes);
132 30
                $currentValue = '';
133
            } else {
134 39
                $currentValue .= $char;
135
            }
136
        }
137
138
        // Add the last value
139 39
        if ($currentValue !== '') {
140 38
            $result[] = self::processPostgresValue($currentValue, $preserveStringTypes);
141
        }
142
143 39
        return $result;
144
    }
145
146
    /**
147
     * Process a single value from a PostgreSQL array.
148
     *
149
     * @param bool $preserveStringTypes When true, skip type inference for unquoted values
150
     */
151 39
    private static function processPostgresValue(string $value, bool $preserveStringTypes): mixed
152
    {
153 39
        $value = \trim($value);
154
155 39
        if ($preserveStringTypes) {
156 25
            if (self::isQuotedString($value)) {
157 13
                return self::processQuotedString($value);
158
            }
159
160 17
            return $value;
161
        }
162
163 14
        if (self::isNullValue($value)) {
164 1
            return null;
165
        }
166
167 14
        if (self::isBooleanValue($value)) {
168 2
            return self::processBooleanValue($value);
169
        }
170
171 13
        if (self::isQuotedString($value)) {
172 7
            return self::processQuotedString($value);
173
        }
174
175 9
        if (self::isNumericValue($value)) {
176 1
            return self::processNumericValue($value);
177
        }
178
179
        // For unquoted strings, return as is
180 9
        return $value;
181
    }
182
183 14
    private static function isNullValue(string $value): bool
184
    {
185 14
        return $value === 'NULL' || $value === 'null';
186
    }
187
188 14
    private static function isBooleanValue(string $value): bool
189
    {
190 14
        return \in_array($value, ['true', 't', 'false', 'f'], true);
191
    }
192
193 2
    private static function processBooleanValue(string $value): bool
194
    {
195 2
        return $value === 'true' || $value === 't';
196
    }
197
198 38
    private static function isQuotedString(string $value): bool
199
    {
200 38
        return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"';
201
    }
202
203 20
    private static function processQuotedString(string $value): string
204
    {
205 20
        $unquoted = \substr($value, 1, -1);
206
207 20
        return self::unescapeString($unquoted);
208
    }
209
210 9
    private static function isNumericValue(string $value): bool
211
    {
212 9
        return \is_numeric($value);
213
    }
214
215 1
    private static function processNumericValue(string $value): float|int
216
    {
217 1
        if (\str_contains($value, '.') || \stripos($value, 'e') !== false) {
218
            return (float) $value;
219
        }
220
221 1
        return (int) $value;
222
    }
223
224 20
    private static function unescapeString(string $value): string
225
    {
226
        /**
227
         * PostgreSQL array escaping rules:
228
         * \\ -> \ (escaped backslash becomes literal backslash)
229
         * \" -> " (escaped quote becomes literal quote)
230
         * Everything else remains as-is
231
         */
232 20
        $result = '';
233 20
        $length = \strlen($value);
234 20
        $position = 0;
235
236 20
        while ($position < $length) {
237 19
            if ($value[$position] === '\\' && $position + 1 < $length) {
238 11
                $nextChar = $value[$position + 1];
239
240 11
                if ($nextChar === '\\') {
241
                    // \\ -> \
242 6
                    $result .= '\\';
243 6
                    $position += 2;
244 5
                } elseif ($nextChar === '"') {
245
                    // \" -> "
246 4
                    $result .= '"';
247 4
                    $position += 2;
248
                } else {
249
                    // \ followed by anything else - keep the backslash
250 3
                    $result .= '\\';
251 3
                    $position++;
252
                }
253
            } else {
254 19
                $result .= $value[$position];
255 19
                $position++;
256
            }
257
        }
258
259 20
        return $result;
260
    }
261
}
262