Passed
Push — main ( 979a58...5541f7 )
by Martin
26:26 queued 12:01
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-dimensional text arrays and
25
     * relies on the default escaping strategy in PostgreSQL (double quotes).
26
     *
27
     * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has an invalid format
28
     */
29 43
    public static function transformPostgresArrayToPHPArray(string $postgresArray): array
30
    {
31 43
        $trimmed = \trim($postgresArray);
32
33 43
        if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
34 3
            return [];
35
        }
36
37 40
        if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) {
38 1
            throw InvalidArrayFormatException::multiDimensionalArrayNotSupported();
39
        }
40
41 39
        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 38
        $content = \trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY);
48 38
        $inQuotes = false;
49 38
        $escaping = false;
50
51 38
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
52 38
            $char = $content[$i];
53
54 38
            if ($escaping) {
55 16
                $escaping = false;
56
57 16
                continue;
58
            }
59
60 38
            if ($char === '\\' && $inQuotes) {
61 16
                $escaping = true;
62
63 16
                continue;
64
            }
65
66 38
            if ($char === '"') {
67 28
                $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 37
        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 35
        $jsonArray = '['.\trim($trimmed, self::POSTGRESQL_EMPTY_ARRAY).']';
80
81
        /** @var array<int, mixed>|null $decoded */
82 35
        $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
83
84
        // If json_decode fails, try manual parsing for unquoted strings
85 35
        if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) {
86 13
            return self::parsePostgresArrayManually($content);
87
        }
88
89 22
        return (array) $decoded;
90
    }
91
92 13
    private static function parsePostgresArrayManually(string $content): array
93
    {
94 13
        if ($content === '') {
95
            return [];
96
        }
97
98
        // Parse the array manually, handling quoted and unquoted values
99 13
        $result = [];
100 13
        $inQuotes = false;
101 13
        $currentValue = '';
102 13
        $escaping = false;
103
104 13
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
105 13
            $char = $content[$i];
106
107
            // Handle escaping within quotes
108 13
            if ($escaping) {
109 5
                $currentValue .= $char;
110 5
                $escaping = false;
111
112 5
                continue;
113
            }
114
115 13
            if ($char === '\\' && $inQuotes) {
116 5
                $escaping = true;
117 5
                $currentValue .= $char;
118
119 5
                continue;
120
            }
121
122 13
            if ($char === '"') {
123 7
                $inQuotes = !$inQuotes;
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
124
                // For quoted values, we include the quotes for later processing
125 7
                $currentValue .= $char;
126 13
            } elseif ($char === ',' && !$inQuotes) {
127
                // End of value
128 10
                $result[] = self::processPostgresValue($currentValue);
129 10
                $currentValue = '';
130
            } else {
131 13
                $currentValue .= $char;
132
            }
133
        }
134
135
        // Add the last value
136 13
        if ($currentValue !== '') {
137 12
            $result[] = self::processPostgresValue($currentValue);
138
        }
139
140 13
        return $result;
141
    }
142
143
    /**
144
     * Process a single value from a PostgreSQL array.
145
     */
146 13
    private static function processPostgresValue(string $value): mixed
147
    {
148 13
        $value = \trim($value);
149
150 13
        if (self::isNullValue($value)) {
151 1
            return null;
152
        }
153
154 13
        if (self::isBooleanValue($value)) {
155 1
            return self::processBooleanValue($value);
156
        }
157
158 12
        if (self::isQuotedString($value)) {
159 7
            return self::processQuotedString($value);
160
        }
161
162 8
        if (self::isNumericValue($value)) {
163
            return self::processNumericValue($value);
164
        }
165
166
        // For unquoted strings, return as is
167 8
        return $value;
168
    }
169
170 13
    private static function isNullValue(string $value): bool
171
    {
172 13
        return $value === 'NULL' || $value === 'null';
173
    }
174
175 13
    private static function isBooleanValue(string $value): bool
176
    {
177 13
        return \in_array($value, ['true', 't', 'false', 'f'], true);
178
    }
179
180 1
    private static function processBooleanValue(string $value): bool
181
    {
182 1
        return $value === 'true' || $value === 't';
183
    }
184
185 12
    private static function isQuotedString(string $value): bool
186
    {
187 12
        return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"';
188
    }
189
190 7
    private static function processQuotedString(string $value): string
191
    {
192
        // Remove the quotes and unescape the string
193 7
        $unquoted = \substr($value, 1, -1);
194
195 7
        return self::unescapeString($unquoted);
196
    }
197
198 8
    private static function isNumericValue(string $value): bool
199
    {
200 8
        return \is_numeric($value);
201
    }
202
203
    private static function processNumericValue(string $value): float|int
204
    {
205
        // Convert to int or float as appropriate
206
        if (\str_contains($value, '.') || \stripos($value, 'e') !== false) {
207
            return (float) $value;
208
        }
209
210
        return (int) $value;
211
    }
212
213 7
    private static function unescapeString(string $value): string
214
    {
215
        /**
216
         * PostgreSQL array escaping rules:
217
         * \\ -> \ (escaped backslash becomes literal backslash)
218
         * \" -> " (escaped quote becomes literal quote)
219
         * Everything else remains as-is
220
         */
221 7
        $result = '';
222 7
        $length = \strlen($value);
223 7
        $position = 0;
224
225 7
        while ($position < $length) {
226 7
            if ($value[$position] === '\\' && $position + 1 < $length) {
227 5
                $nextChar = $value[$position + 1];
228
229 5
                if ($nextChar === '\\') {
230
                    // \\ -> \
231 1
                    $result .= '\\';
232 1
                    $position += 2;
233 4
                } elseif ($nextChar === '"') {
234
                    // \" -> "
235 3
                    $result .= '"';
236 3
                    $position += 2;
237
                } else {
238
                    // \ followed by anything else - keep the backslash
239 3
                    $result .= '\\';
240 3
                    $position++;
241
                }
242
            } else {
243 7
                $result .= $value[$position];
244 7
                $position++;
245
            }
246
        }
247
248 7
        return $result;
249
    }
250
}
251