Passed
Pull Request — main (#391)
by Martin
35:22 queued 20: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-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 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 15
                $escaping = false;
56
57 15
                continue;
58
            }
59
60 37
            if ($char === '\\' && $inQuotes) {
61 15
                $escaping = true;
62
63 15
                continue;
64
            }
65
66 37
            if ($char === '"') {
67 27
                $inQuotes = !$inQuotes;
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
68 35
            } 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 11
            return self::parsePostgresArrayManually($content);
87
        }
88
89 23
        return (array) $decoded;
90
    }
91
92 11
    private static function parsePostgresArrayManually(string $content): array
93
    {
94 11
        if ($content === '') {
95
            return [];
96
        }
97
98
        // Parse the array manually, handling quoted and unquoted values
99 11
        $result = [];
100 11
        $inQuotes = false;
101 11
        $currentValue = '';
102 11
        $escaping = false;
103
104 11
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
105 11
            $char = $content[$i];
106
107
            // Handle escaping within quotes
108 11
            if ($escaping) {
109 3
                $currentValue .= $char;
110 3
                $escaping = false;
111
112 3
                continue;
113
            }
114
115 11
            if ($char === '\\' && $inQuotes) {
116 3
                $escaping = true;
117 3
                $currentValue .= $char;
118
119 3
                continue;
120
            }
121
122 11
            if ($char === '"') {
123 5
                $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 5
                $currentValue .= $char;
126 11
            } elseif ($char === ',' && !$inQuotes) {
127
                // End of value
128 9
                $result[] = self::processPostgresValue($currentValue);
129 9
                $currentValue = '';
130
            } else {
131 11
                $currentValue .= $char;
132
            }
133
        }
134
135
        // Add the last value
136 11
        if ($currentValue !== '') {
137 10
            $result[] = self::processPostgresValue($currentValue);
138
        }
139
140 11
        return $result;
141
    }
142
143
    /**
144
     * Process a single value from a PostgreSQL array.
145
     */
146 11
    private static function processPostgresValue(string $value): mixed
147
    {
148 11
        $value = \trim($value);
149
150 11
        if (self::isNullValue($value)) {
151 1
            return null;
152
        }
153
154 11
        if (self::isBooleanValue($value)) {
155 1
            return self::processBooleanValue($value);
156
        }
157
158 10
        if (self::isQuotedString($value)) {
159 5
            return self::processQuotedString($value);
160
        }
161
162 7
        if (self::isNumericValue($value)) {
163
            return self::processNumericValue($value);
164
        }
165
166
        // For unquoted strings, return as is
167 7
        return $value;
168
    }
169
170 11
    private static function isNullValue(string $value): bool
171
    {
172 11
        return $value === 'NULL' || $value === 'null';
173
    }
174
175 11
    private static function isBooleanValue(string $value): bool
176
    {
177 11
        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 10
    private static function isQuotedString(string $value): bool
186
    {
187 10
        return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"';
188
    }
189
190 5
    private static function processQuotedString(string $value): string
191
    {
192
        // Remove the quotes and unescape the string
193 5
        $unquoted = \substr($value, 1, -1);
194
195 5
        return self::unescapeString($unquoted);
196
    }
197
198 7
    private static function isNumericValue(string $value): bool
199
    {
200 7
        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 5
    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 5
        $result = '';
222 5
        $length = \strlen($value);
223 5
        $position = 0;
224
225 5
        while ($position < $length) {
226 5
            if ($value[$position] === '\\' && $position + 1 < $length) {
227 3
                $nextChar = $value[$position + 1];
228
229 3
                if ($nextChar === '\\') {
230
                    // \\ -> \
231 1
                    $result .= '\\';
232 1
                    $position += 2;
233 2
                } elseif ($nextChar === '"') {
234
                    // \" -> "
235 1
                    $result .= '"';
236 1
                    $position += 2;
237
                } else {
238
                    // \ followed by anything else - keep the backslash
239 1
                    $result .= '\\';
240 1
                    $position++;
241
                }
242
            } else {
243 5
                $result .= $value[$position];
244 5
                $position++;
245
            }
246
        }
247
248 5
        return $result;
249
    }
250
}
251