Test Failed
Push — issue-329 ( 5fdddd )
by Martin
11:26
created

ArrayDataTransformer::processNumericValue()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 3
nc 2
nop 1
dl 0
loc 8
ccs 0
cts 4
cp 0
crap 12
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace MartinGeorgiev\Utils;
6
7
use MartinGeorgiev\Utils\Exception\InvalidArrayFormatException;
8
9
/**
10
 * @since 3.0
11
 *
12
 * @author Martin Georgiev <[email protected]>
13
 */
14
class ArrayDataTransformer
15
{
16
    private const POSTGRESQL_EMPTY_ARRAY = '{}';
17
18
    private const POSTGRESQL_NULL_VALUE = 'null';
19
20
    /**
21
     * This method supports only single-dimensioned text arrays and
22
     * relays on the default escaping strategy in PostgreSQL (double quotes).
23
     *
24
     * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has invalid format
25
     */
26 30
    public static function transformPostgresTextArrayToPHPArray(string $postgresArray): array
27
    {
28 30
        $trimmed = \trim($postgresArray);
29
30 30
        if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
31 2
            return [];
32
        }
33
34 28
        if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) {
35 1
            throw InvalidArrayFormatException::multiDimensionalArrayNotSupported();
36
        }
37
38 27
        if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) {
39 1
            return [];
40
        }
41
42
        // Check for malformed nesting - this is a more specific check than the one above
43
        // But we need to exclude cases where curly braces are part of quoted strings
44 26
        $content = \trim($trimmed, '{}');
45 26
        $inQuotes = false;
46 26
        $escaping = false;
47
48 26
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
49 26
            $char = $content[$i];
50
51 26
            if ($escaping) {
52 6
                $escaping = false;
53
54 6
                continue;
55
            }
56
57 26
            if ($char === '\\' && $inQuotes) {
58 6
                $escaping = true;
59
60 6
                continue;
61
            }
62
63 26
            if ($char === '"') {
64 21
                $inQuotes = !$inQuotes;
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
65 26
            } elseif (($char === '{' || $char === '}') && !$inQuotes) {
66 1
                throw InvalidArrayFormatException::invalidFormat('Malformed array nesting detected');
67
            }
68
        }
69
70
        // Check for unclosed quotes
71 25
        if ($inQuotes) {
0 ignored issues
show
introduced by
The condition $inQuotes is always false.
Loading history...
72 2
            throw InvalidArrayFormatException::invalidFormat('Unclosed quotes in array');
73
        }
74
75
        // First try with json_decode for properly quoted values
76 23
        $jsonArray = '['.\trim($trimmed, '{}').']';
77
78
        /** @var array<int, mixed>|null $decoded */
79 23
        $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
80
81
        // If json_decode fails, try manual parsing for unquoted strings
82 23
        if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) {
83 1
            return self::parsePostgresArrayManually($content);
84
        }
85
86 22
        return \array_map(
87 22
            static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value,
88 22
            (array) $decoded
89 22
        );
90
    }
91
92
    /**
93
     * Manually parse a PostgreSQL array content string.
94
     */
95 1
    private static function parsePostgresArrayManually(string $content): array
96
    {
97 1
        if ($content === '') {
98
            return [];
99
        }
100
101
        // Parse the array manually, handling quoted and unquoted values
102 1
        $result = [];
103 1
        $inQuotes = false;
104 1
        $currentValue = '';
105 1
        $escaping = false;
106
107 1
        for ($i = 0, $len = \strlen($content); $i < $len; $i++) {
108 1
            $char = $content[$i];
109
110
            // Handle escaping within quotes
111 1
            if ($escaping) {
112
                $currentValue .= $char;
113
                $escaping = false;
114
115
                continue;
116
            }
117
118 1
            if ($char === '\\' && $inQuotes) {
119
                $escaping = true;
120
                $currentValue .= $char;
121
122
                continue;
123
            }
124
125 1
            if ($char === '"') {
126
                $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
                $currentValue .= $char;
129 1
            } elseif ($char === ',' && !$inQuotes) {
130
                // End of value
131 1
                $result[] = self::processPostgresValue($currentValue);
132 1
                $currentValue = '';
133
            } else {
134 1
                $currentValue .= $char;
135
            }
136
        }
137
138
        // Add the last value
139 1
        if ($currentValue !== '') {
140 1
            $result[] = self::processPostgresValue($currentValue);
141
        }
142
143 1
        return $result;
144
    }
145
146
    /**
147
     * Process a single value from a PostgreSQL array.
148
     */
149 1
    private static function processPostgresValue(string $value): mixed
150
    {
151 1
        $value = \trim($value);
152
153 1
        if (self::isNullValue($value)) {
154
            return null;
155
        }
156
157 1
        if (self::isBooleanValue($value)) {
158
            return self::processBooleanValue($value);
159
        }
160
161 1
        if (self::isQuotedString($value)) {
162
            return self::processQuotedString($value);
163
        }
164
165 1
        if (self::isNumericValue($value)) {
166
            return self::processNumericValue($value);
167
        }
168
169
        // For unquoted strings, return as is
170 1
        return $value;
171
    }
172
173
    /**
174
     * Check if the value is a NULL value.
175
     */
176 1
    private static function isNullValue(string $value): bool
177
    {
178 1
        return $value === 'NULL' || $value === 'null';
179
    }
180
181
    /**
182
     * Check if the value is a boolean value.
183
     */
184 1
    private static function isBooleanValue(string $value): bool
185
    {
186 1
        return \in_array($value, ['true', 't', 'false', 'f'], true);
187
    }
188
189
    /**
190
     * Process a boolean value.
191
     */
192
    private static function processBooleanValue(string $value): bool
193
    {
194
        return $value === 'true' || $value === 't';
195
    }
196
197
    /**
198
     * Check if the value is a quoted string.
199
     */
200 1
    private static function isQuotedString(string $value): bool
201
    {
202 1
        return \strlen($value) >= 2 && $value[0] === '"' && $value[\strlen($value) - 1] === '"';
203
    }
204
205
    /**
206
     * Process a quoted string.
207
     */
208
    private static function processQuotedString(string $value): string
209
    {
210
        // Remove the quotes and unescape the string
211
        $unquoted = \substr($value, 1, -1);
212
213
        return self::unescapeString($unquoted);
214
    }
215
216
    /**
217
     * Check if the value is a numeric value.
218
     */
219 1
    private static function isNumericValue(string $value): bool
220
    {
221 1
        return \is_numeric($value);
222
    }
223
224
    /**
225
     * Process a numeric value.
226
     */
227
    private static function processNumericValue(string $value): float|int
228
    {
229
        // Convert to int or float as appropriate
230
        if (\str_contains($value, '.') || \stripos($value, 'e') !== false) {
231
            return (float) $value;
232
        }
233
234
        return (int) $value;
235
    }
236
237
    /**
238
     * This method supports only single-dimensioned PHP arrays.
239
     * This method relays on the default escaping strategy in PostgreSQL (double quotes).
240
     *
241
     * @throws InvalidArrayFormatException when the input is a multi-dimensional array or has invalid format
242
     */
243 28
    public static function transformPHPArrayToPostgresTextArray(array $phpArray): string
244
    {
245 28
        if ($phpArray === []) {
246 1
            return self::POSTGRESQL_EMPTY_ARRAY;
247
        }
248
249 27
        if (\array_filter($phpArray, 'is_array')) {
250 5
            throw InvalidArrayFormatException::multiDimensionalArrayNotSupported();
251
        }
252
253
        /** @var array<int|string, string> */
254 22
        $processed = \array_map(
255 22
            static fn (mixed $value): string => self::formatValue($value),
256 22
            $phpArray
257 22
        );
258
259 22
        return '{'.\implode(',', $processed).'}';
260
    }
261
262
    /**
263
     * Formats a single value for PostgreSQL array.
264
     */
265 22
    private static function formatValue(mixed $value): string
266
    {
267
        // Handle null
268 22
        if ($value === null) {
269
            return 'NULL';
270
        }
271
272
        // Handle actual numbers
273 22
        if (\is_int($value) || \is_float($value)) {
274 6
            return (string) $value;
275
        }
276
277
        // Handle booleans
278 20
        if (\is_bool($value)) {
279 1
            return $value ? 'true' : 'false';
280
        }
281
282 19
        if (\is_object($value)) {
283 1
            if (\method_exists($value, '__toString')) {
284 1
                $stringValue = $value->__toString();
285
            } else {
286
                // For objects without __toString, use a default representation
287
                $stringValue = $value::class;
288
            }
289 18
        } elseif (\is_resource($value)) {
290 1
            $stringValue = '(resource)';
291
        } else {
292 17
            $valueType = \get_debug_type($value);
293
294 17
            if ($valueType === 'string') {
295 17
                $stringValue = $value;
296
            } elseif (\in_array($valueType, ['int', 'float', 'bool'], true)) {
297
                /** @var bool|float|int $value */
298
                $stringValue = (string) $value;
299
            } else {
300
                $stringValue = $valueType;
301
            }
302
        }
303
304 19
        \assert(\is_string($stringValue));
305
306
        // Handle empty string
307 19
        if ($stringValue === '') {
308 1
            return '""';
309
        }
310
311
        // Always quote strings to match the test expectations
312
        // Double the backslashes and escape quotes
313 18
        $escaped = \str_replace(
314 18
            ['\\', '"'],
315 18
            ['\\\\', '\"'],
316 18
            $stringValue
317 18
        );
318
319 18
        return '"'.$escaped.'"';
320
    }
321
322 19
    private static function unescapeString(string $value): string
323
    {
324
        // First handle escaped quotes
325 19
        $value = \str_replace('\"', '___QUOTE___', $value);
326
327
        // Handle double backslashes
328 19
        $value = \str_replace('\\\\', '___DBLBACK___', $value);
329
330
        // Restore double backslashes
331 19
        $value = \str_replace('___DBLBACK___', '\\', $value);
332
333
        // Finally restore quotes
334 19
        return \str_replace('___QUOTE___', '"', $value);
335
    }
336
}
337