Passed
Push — float-arrays ( 02f420...1b8dd5 )
by Martin
26:39 queued 11:41
created

transformPostgresTextArrayToPHPArray()   B

Complexity

Conditions 9
Paths 5

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 9.0239

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 9
eloc 14
c 1
b 0
f 0
nc 5
nop 1
dl 0
loc 27
ccs 14
cts 15
cp 0.9333
crap 9.0239
rs 8.0555
1
<?php
2
3
declare(strict_types=1);
4
5
namespace MartinGeorgiev\Utils;
6
7
/**
8
 * Util class with helpers for working with PostgreSQL data structures.
9
 *
10
 * @since 0.9
11
 *
12
 * @author Martin Georgiev <[email protected]>
13
 */
14
class DataStructure
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 20
    public static function transformPostgresTextArrayToPHPArray(string $postgresArray): array
25
    {
26 20
        $trimmed = \trim($postgresArray);
27
28 20
        if ($trimmed === '' || \strtolower($trimmed) === self::POSTGRESQL_NULL_VALUE) {
29
            return [];
30
        }
31
32 20
        if (\str_contains($trimmed, '},{') || \str_starts_with($trimmed, '{{')) {
33 1
            throw new \InvalidArgumentException('Only single-dimensioned arrays are supported');
34
        }
35
36 19
        if ($trimmed === self::POSTGRESQL_EMPTY_ARRAY) {
37 1
            return [];
38
        }
39
40 18
        $jsonArray = '['.\trim($trimmed, '{}').']';
41
42
        /** @var array<int, mixed>|null $decoded */
43 18
        $decoded = \json_decode($jsonArray, true, 512, JSON_BIGINT_AS_STRING);
44 18
        if ($decoded === null && \json_last_error() !== JSON_ERROR_NONE) {
45 1
            throw new \InvalidArgumentException('Invalid array format: '.\json_last_error_msg());
46
        }
47
48 17
        return \array_map(
49 17
            static fn (mixed $value): mixed => \is_string($value) ? self::unescapeString($value) : $value,
50 17
            (array) $decoded
51 17
        );
52
    }
53
54
    /**
55
     * This method supports only single-dimensioned PHP arrays.
56
     * This method relays on the default escaping strategy in PostgreSQL (double quotes).
57
     */
58 20
    public static function transformPHPArrayToPostgresTextArray(array $phpArray): string
59
    {
60 20
        if ($phpArray === []) {
61 1
            return self::POSTGRESQL_EMPTY_ARRAY;
62
        }
63
64 19
        if (\array_filter($phpArray, 'is_array')) {
65 1
            throw new \InvalidArgumentException('Only single-dimensioned arrays are supported');
66
        }
67
68
        /** @var array<int|string, string> */
69 18
        $processed = \array_map(
70 18
            static fn (mixed $value): string => self::formatValue($value),
71 18
            $phpArray
72 18
        );
73
74 18
        return '{'.\implode(',', $processed).'}';
75
    }
76
77
    /**
78
     * Formats a single value for PostgreSQL array.
79
     */
80 18
    private static function formatValue(mixed $value): string
81
    {
82
        // Handle null
83 18
        if ($value === null) {
84
            return 'NULL';
85
        }
86
87
        // Handle actual numbers
88 18
        if (\is_int($value) || \is_float($value)) {
89 6
            return (string) $value;
90
        }
91
92
        // Handle booleans
93 16
        if (\is_bool($value)) {
94 1
            return $value ? 'true' : 'false';
95
        }
96
97
        // Handle objects that implement __toString()
98 15
        if (\is_object($value)) {
99 1
            if (\method_exists($value, '__toString')) {
100 1
                $stringValue = $value->__toString();
101
            } else {
102
                // For objects without __toString, use a default representation
103
                $stringValue = $value::class;
104
            }
105
        } else {
106
            // For all other types, force string conversion
107
            // This covers strings, resources, and other types
108 14
            $stringValue = match (true) {
109 14
                \is_resource($value) => '(resource)',
110 13
                default => (string) $value // @phpstan-ignore-line
111 14
            };
112
        }
113
114 15
        \assert(\is_string($stringValue));
115
116
        // Handle empty string
117 15
        if ($stringValue === '') {
118 1
            return '""';
119
        }
120
121 14
        if (self::isNumericSimple($stringValue)) {
122 8
            return '"'.$stringValue.'"';
123
        }
124
125
        // Double the backslashes and escape quotes
126 7
        $escaped = \str_replace(
127 7
            ['\\', '"'],
128 7
            ['\\\\', '\"'],
129 7
            $stringValue
130 7
        );
131
132 7
        return '"'.$escaped.'"';
133
    }
134
135 14
    private static function isNumericSimple(string $value): bool
136
    {
137
        // Fast path for obvious numeric strings
138 14
        if ($value === '' || $value[0] === '"') {
139 1
            return false;
140
        }
141
142
        // Handle scientific notation
143 14
        $lower = \strtolower($value);
144 14
        if (\str_contains($lower, 'e')) {
145 8
            $value = \str_replace('e', '', $lower);
146
        }
147
148
        // Use built-in numeric check
149 14
        return \is_numeric($value);
150
    }
151
152 14
    private static function unescapeString(string $value): string
153
    {
154
        // First handle escaped quotes
155 14
        $value = \str_replace('\"', '___QUOTE___', $value);
156
157
        // Handle double backslashes
158 14
        $value = \str_replace('\\\\', '___DBLBACK___', $value);
159
160
        // Handle remaining single backslashes
161 14
        $value = \str_replace('\\', '\\', $value);
162
163
        // Restore double backslashes
164 14
        $value = \str_replace('___DBLBACK___', '\\\\', $value);
165
166
        // Finally restore quotes
167 14
        return \str_replace('___QUOTE___', '"', $value);
168
    }
169
}
170