• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

martin-georgiev / postgresql-for-doctrine / 17182619488

24 Aug 2025 01:46AM UTC coverage: 94.381% (-0.3%) from 94.65%
17182619488

Pull #421

github

web-flow
Merge d1b5e36bf into 4aafaebdc
Pull Request #421: feat(#305): add support for PostGIS's `GEOGRAPHY` and `GEOMETRY`

233 of 252 new or added lines in 14 files covered. (92.46%)

2066 of 2189 relevant lines covered (94.38%)

17.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.69
/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace MartinGeorgiev\Doctrine\DBAL\Types;
6

7
use Doctrine\DBAL\Types\ConversionException;
8
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DimensionalModifier;
9
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidWktSpatialDataException;
10
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\GeometryType;
11
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;
12

13
/**
14
 * Base class for PostgreSQL array types containing WKT/EWKT spatial data.
15
 *
16
 * This class provides specialized array parsing logic that understands the nested
17
 * structure of WKT geometries (parentheses, coordinate lists, etc.).
18
 * It splits array elements without breaking on commas inside coordinate groups.
19
 *
20
 * @since 3.5
21
 */
22
abstract class SpatialDataArray extends BaseArray
23
{
24
    /**
25
     * Get a regex pattern that matches all supported geometry types.
26
     *
27
     * This method dynamically builds the pattern from the GeometryType enum
28
     * to ensure consistency and eliminate duplication.
29
     */
30
    private function getGeometryTypesPattern(): string
57✔
31
    {
32
        $geometryTypes = \array_map(
57✔
33
            static fn (GeometryType $geometryType): string => $geometryType->value,
57✔
34
            GeometryType::cases()
57✔
35
        );
57✔
36

37
        return '('.\implode('|', $geometryTypes).')';
57✔
38
    }
39

40
    /**
41
     * Build dimensional modifier regex patterns for geometry type normalization.
42
     *
43
     * Uses the DimensionalModifier enum to ensure consistency and eliminate duplication.
44
     *
45
     * @return array<string, string> Array of regex pattern => replacement pairs
46
     */
47
    private function getDimensionalModifierPatterns(): array
57✔
48
    {
49
        $geometryTypesPattern = $this->getGeometryTypesPattern();
57✔
50
        $modifierValues = \array_map(
57✔
51
            static fn (DimensionalModifier $dimensionalModifier): string => $dimensionalModifier->value,
57✔
52
            DimensionalModifier::cases()
57✔
53
        );
57✔
54
        $modifiersPattern = '('.\implode('|', $modifierValues).')';
57✔
55

56
        return [
57✔
57
            // No-space variants: POINTZM/POINTZ/POINTM -> POINT ZM|Z|M (built from enum)
58
            \sprintf('/^%s%s\b/', $geometryTypesPattern, $modifiersPattern) => '$1 $2',
57✔
59
            // ST_AsText extra space format: POINT Z ( -> POINT Z(
60
            \sprintf('/^%s\s+%s\s+\(/', $geometryTypesPattern, $modifiersPattern) => '$1 $2(',
57✔
61
            // Multiple spaces: POINT  Z -> POINT Z
62
            \sprintf('/^%s\s+%s\b/', $geometryTypesPattern, $modifiersPattern) => '$1 $2',
57✔
63
        ];
57✔
64
    }
65

66
    protected function getValidatedArrayItem(mixed $item): WktSpatialData
23✔
67
    {
68
        if ($this->isValidArrayItemForDatabase($item)) {
23✔
69
            return $item; // @phpstan-ignore-line
23✔
70
        }
71

NEW
72
        throw $this->createInvalidTypeExceptionForPHP($item);
×
73
    }
74

75
    /**
76
     * Transforms a PostgreSQL array containing WKT/EWKT geometries to a PHP array.
77
     *
78
     * Examples:
79
     * - '{POINT(1 2),LINESTRING(0 0, 1 1)}' -> ['POINT(1 2)', 'LINESTRING(0 0, 1 1)']
80
     * - '{POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))}' -> ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))']
81
     * - '{}' -> []
82
     */
83
    protected function transformPostgresArrayToPHPArray(string $postgresArray): array
43✔
84
    {
85
        $trimmedArray = \trim($postgresArray);
43✔
86
        if ($trimmedArray === '{}' || $trimmedArray === '') {
43✔
87
            return [];
3✔
88
        }
89

90
        // Handle quoted array format: {"item1","item2","item3"}
91
        $isQuotedArray = \str_starts_with($trimmedArray, '{"') && \str_ends_with($trimmedArray, '"}');
40✔
92
        if ($isQuotedArray) {
40✔
93
            $arrayContentWithoutBraces = \substr($trimmedArray, 2, -2);
10✔
94
            if ($arrayContentWithoutBraces === '') {
10✔
NEW
95
                return [];
×
96
            }
97

98
            return $this->parseQuotedWktArray($arrayContentWithoutBraces);
10✔
99
        }
100

101
        // Handle unquoted array format: {item1,item2,item3} (fallback for backward compatibility)
102
        $arrayContentWithoutBraces = \substr($trimmedArray, 1, -1);
30✔
103
        if ($arrayContentWithoutBraces === '') {
30✔
NEW
104
            return [];
×
105
        }
106

107
        return $this->parseUnquotedWktArray($arrayContentWithoutBraces);
30✔
108
    }
109

110
    private function parseQuotedWktArray(string $content): array
10✔
111
    {
112
        $wktItems = [];
10✔
113
        $currentWktItem = '';
10✔
114
        $nestedBracketDepth = 0;
10✔
115
        $contentLength = \strlen($content);
10✔
116
        $charIndex = 0;
10✔
117

118
        while ($charIndex < $contentLength) {
10✔
119
            $currentChar = $content[$charIndex];
10✔
120

121
            // Track nested parentheses within the quoted WKT
122
            if ($currentChar === '(') {
10✔
123
                $nestedBracketDepth++;
10✔
124
                $currentWktItem .= $currentChar;
10✔
125
            } elseif ($currentChar === ')') {
10✔
126
                $nestedBracketDepth--;
10✔
127
                $currentWktItem .= $currentChar;
10✔
128
            } elseif ($currentChar === '"' && $nestedBracketDepth === 0) {
10✔
129
                // Found end quote at top level - this ends the current item
130
                if ($currentWktItem !== '') {
8✔
131
                    $wktItems[] = $currentWktItem;
8✔
132
                    $currentWktItem = '';
8✔
133
                }
134

135
                // Skip the quote and comma separator: ","
136
                $charIndex++; // Skip the quote
8✔
137
                if ($charIndex < $contentLength && $content[$charIndex] === ',') {
8✔
138
                    $charIndex++; // Skip the comma
8✔
139
                }
140

141
                if ($charIndex < $contentLength && $content[$charIndex] === '"') {
8✔
142
                    $charIndex++; // Skip the opening quote of next item
8✔
143
                }
144

145
                continue;
8✔
146
            } else {
147
                $currentWktItem .= $currentChar;
10✔
148
            }
149

150
            $charIndex++;
10✔
151
        }
152

153
        // Add the last item if there's content
154
        if ($currentWktItem !== '') {
10✔
155
            $wktItems[] = $currentWktItem;
9✔
156
        }
157

158
        return $wktItems;
10✔
159
    }
160

161
    private function parseUnquotedWktArray(string $content): array
30✔
162
    {
163
        $wktItems = [];
30✔
164
        $nestedBracketDepth = 0;
30✔
165
        $currentWktItem = '';
30✔
166
        $contentLength = \strlen($content);
30✔
167

168
        for ($charIndex = 0; $charIndex < $contentLength; $charIndex++) {
30✔
169
            $currentChar = $content[$charIndex];
30✔
170

171
            // Track opening brackets/parentheses to handle nested WKT structures
172
            if ($currentChar === '(' || $currentChar === '{') {
30✔
173
                $nestedBracketDepth++;
30✔
174
                $currentWktItem .= $currentChar;
30✔
175

176
                continue;
30✔
177
            }
178

179
            // Track closing brackets/parentheses
180
            if ($currentChar === ')' || $currentChar === '}') {
30✔
181
                $nestedBracketDepth--;
30✔
182
                $currentWktItem .= $currentChar;
30✔
183

184
                continue;
30✔
185
            }
186

187
            // Only split on commas at the top level (not inside WKT coordinate groups)
188
            if ($currentChar === ',' && $nestedBracketDepth === 0) {
30✔
189
                $wktItems[] = $currentWktItem;
13✔
190
                $currentWktItem = '';
13✔
191

192
                continue;
13✔
193
            }
194

195
            $currentWktItem .= $currentChar;
30✔
196
        }
197

198
        // Add the last WKT item if there's content
199
        if ($currentWktItem !== '') {
30✔
200
            $wktItems[] = $currentWktItem;
30✔
201
        }
202

203
        return \array_map('trim', $wktItems);
30✔
204
    }
205

206
    public function isValidArrayItemForDatabase(mixed $item): bool
32✔
207
    {
208
        return $item instanceof WktSpatialData;
32✔
209
    }
210

211
    public function transformArrayItemForPHP(mixed $item): ?WktSpatialData
58✔
212
    {
213
        if ($item === null) {
58✔
214
            return null;
1✔
215
        }
216

217
        if (!\is_string($item)) {
57✔
NEW
218
            throw $this->createInvalidTypeExceptionForPHP($item);
×
219
        }
220

221
        try {
222
            $normalizedWkt = $this->normalizePostgreSQLDimensionalModifiers($item);
57✔
223

224
            return WktSpatialData::fromWkt($normalizedWkt);
57✔
NEW
225
        } catch (InvalidWktSpatialDataException) {
×
NEW
226
            throw $this->createInvalidFormatExceptionForPHP($item);
×
227
        }
228
    }
229

230
    /**
231
     * Normalize PostgreSQL dimensional modifier format to standard WKT format.
232
     *
233
     * PostgreSQL can return dimensional modifiers in different formats:
234
     * - ST_AsEWKT(): POINTZ, POINTM, POINTZM (no spaces)
235
     * - ST_AsText(): POINT Z, POINT M, POINT ZM (with spaces)
236
     * - Hybrid approach: SRID=4326;POINT Z (1 2 3) (SRID + extra space)
237
     *
238
     * This method normalizes all formats to the standard WKT format using
239
     * patterns dynamically built from the GeometryType and DimensionalModifier enums.
240
     */
241
    private function normalizePostgreSQLDimensionalModifiers(string $wkt): string
57✔
242
    {
243
        // Handle SRID prefix if present
244
        $sridPrefix = '';
57✔
245
        $hasSrid = \str_starts_with($wkt, 'SRID=');
57✔
246
        if ($hasSrid) {
57✔
247
            $sridSeparatorPosition = \strpos($wkt, ';');
11✔
248
            if ($sridSeparatorPosition === false) {
11✔
NEW
249
                throw InvalidWktSpatialDataException::forMissingSemicolonInEwkt();
×
250
            }
251

252
            $sridPrefix = \substr($wkt, 0, $sridSeparatorPosition + 1);
11✔
253
            $wkt = \substr($wkt, $sridSeparatorPosition + 1);
11✔
254
        }
255

256
        // Normalize dimensional modifiers using patterns built from WktGeometryType enum
257
        foreach ($this->getDimensionalModifierPatterns() as $pattern => $replacement) {
57✔
258
            $wkt = \preg_replace($pattern, $replacement, (string) $wkt);
57✔
259
        }
260

261
        return $sridPrefix.$wkt;
57✔
262
    }
263

264
    /**
265
     * Creates an exception for invalid type during PHP conversion.
266
     * Subclasses should override this to provide specific exception types.
267
     */
268
    abstract protected function createInvalidTypeExceptionForPHP(mixed $item): ConversionException;
269

270
    /**
271
     * Creates an exception for invalid format during PHP conversion.
272
     * Subclasses should override this to provide specific exception types.
273
     */
274
    abstract protected function createInvalidFormatExceptionForPHP(mixed $item): ConversionException;
275
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc