PostgresArrayToPHPArrayTransformer   C
last analyzed

Complexity

Total Complexity 54

Size/Duplication

Total Lines 244
Duplicated Lines 0 %

Test Coverage

Coverage 98.18%

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 105
c 1
b 1
f 0
dl 0
loc 244
ccs 108
cts 110
cp 0.9818
rs 6.4799
wmc 54

11 Methods

Rating   Name   Duplication   Size   Complexity  
A isNullValue() 0 3 2
A processBooleanValue() 0 3 2
A isNumericValue() 0 3 1
A isQuotedString() 0 3 3
A isBooleanValue() 0 3 1
A processNumericValue() 0 7 3
A unescapeString() 0 36 6
A processQuotedString() 0 5 1
B parsePostgresArrayManually() 0 48 10
D transformPostgresArrayToPHPArray() 0 62 18
B processPostgresValue() 0 30 7

How to fix   Complexity   

Complex Class

Complex classes like PostgresArrayToPHPArrayTransformer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use PostgresArrayToPHPArrayTransformer, and based on these observations, apply Extract Interface, too.

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