Passed
Push — main ( 979a58...5541f7 )
by Martin
26:26 queued 12:01
created

unescapeString()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 36
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 6

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 6
eloc 19
c 1
b 1
f 0
nc 5
nop 1
dl 0
loc 36
ccs 18
cts 18
cp 1
crap 6
rs 9.0111
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