Passed
Pull Request — main (#391)
by Martin
35:22 queued 20:23
created

processPostgresValue()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5.0187

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 5
eloc 10
c 1
b 1
f 0
nc 5
nop 1
dl 0
loc 22
ccs 10
cts 11
cp 0.9091
crap 5.0187
rs 9.6111
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