Passed
Push — main ( 339e98...90c0a6 )
by Martin
40:38 queued 25:40
created

isNumericValue()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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