Skip to content

Commit 9916c25

Browse files
committed
Introduce Geometry value objects to eliminate leaky abstractions
Refactor GeometryType and GeographyType to replace direct GeoJSON string handling with dedicated value objects, preventing exposure of database-specific formats to application code. This commit introduces a Geometry value object that encapsulates a supporting GeoJSON value object responsible for validating and wrapping GeoJSON representations.
1 parent 0066970 commit 9916c25

File tree

10 files changed

+569
-23
lines changed

10 files changed

+569
-23
lines changed

src/Types/GeoJSON.php

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\DBAL\Types;
6+
7+
use InvalidArgumentException;
8+
use JsonException;
9+
use Stringable;
10+
11+
use function is_array;
12+
use function is_string;
13+
use function json_decode;
14+
use function json_encode;
15+
use function preg_match;
16+
use function sprintf;
17+
18+
use const JSON_THROW_ON_ERROR;
19+
20+
/**
21+
* Value object representing GeoJSON geometry data with optional SRID metadata.
22+
*
23+
* This class validates and wraps GeoJSON geometry data according to RFC 7946 specification.
24+
* It provides a clean abstraction for transporting pure geometry data with SRID across
25+
* different database platforms. Feature and FeatureCollection types are intentionally
26+
* excluded as they are not suitable for database column storage.
27+
*
28+
* Supported geometry types:
29+
* - Point, LineString, Polygon
30+
* - MultiPoint, MultiLineString, MultiPolygon
31+
* - GeometryCollection
32+
*
33+
* @see https://datatracker.ietf.org/doc/html/rfc7946
34+
*/
35+
final class GeoJSON implements Stringable
36+
{
37+
/**
38+
* Valid GeoJSON geometry types according to RFC 7946.
39+
*
40+
* Note: Feature and FeatureCollection are not included as they are not supported
41+
* for database storage. This class focuses on pure geometry transport with SRID.
42+
*/
43+
private const VALID_TYPES = [
44+
'Point' => true,
45+
'LineString' => true,
46+
'Polygon' => true,
47+
'MultiPoint' => true,
48+
'MultiLineString' => true,
49+
'MultiPolygon' => true,
50+
'GeometryCollection' => true,
51+
];
52+
53+
/** @param array<string, mixed> $data Parsed GeoJSON data as associative array */
54+
private function __construct(private readonly array $data)
55+
{
56+
}
57+
58+
/**
59+
* Creates a GeoJSON value object from a JSON string.
60+
*
61+
* Validates the JSON structure and ensures it conforms to RFC 7946 geometry types.
62+
*
63+
* @param string $json GeoJSON string conforming to RFC 7946
64+
*
65+
* @throws InvalidArgumentException If the JSON is invalid or doesn't conform to GeoJSON spec.
66+
*/
67+
public static function fromString(string $json): self
68+
{
69+
try {
70+
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
71+
} catch (JsonException $e) {
72+
throw new InvalidArgumentException(
73+
sprintf('Invalid JSON string: %s', $e->getMessage()),
74+
0,
75+
$e,
76+
);
77+
}
78+
79+
if (! is_array($data)) {
80+
throw new InvalidArgumentException('GeoJSON must be a JSON object, not a scalar or array');
81+
}
82+
83+
self::validate($data);
84+
85+
return new self($data);
86+
}
87+
88+
/**
89+
* Validates GeoJSON data structure according to RFC 7946.
90+
*
91+
* @param array<string, mixed> $data
92+
*
93+
* @throws InvalidArgumentException If validation fails.
94+
*/
95+
private static function validate(array $data): void
96+
{
97+
if (! isset($data['type'])) {
98+
throw new InvalidArgumentException('GeoJSON object must have a "type" property');
99+
}
100+
101+
if (! is_string($data['type'])) {
102+
throw new InvalidArgumentException('GeoJSON "type" must be a string');
103+
}
104+
105+
if (! isset(self::VALID_TYPES[$data['type']])) {
106+
throw new InvalidArgumentException(
107+
sprintf('Invalid GeoJSON type "%s"', $data['type']),
108+
);
109+
}
110+
111+
$type = $data['type'];
112+
113+
// Validate geometry objects (only pure geometries, no Features/FeatureCollections)
114+
if ($type === 'GeometryCollection') {
115+
if (! isset($data['geometries'])) {
116+
throw new InvalidArgumentException('GeoJSON GeometryCollection must have a "geometries" property');
117+
}
118+
119+
if (! is_array($data['geometries'])) {
120+
throw new InvalidArgumentException('GeoJSON "geometries" must be an array');
121+
}
122+
} else {
123+
// All other geometry types must have coordinates
124+
if (! isset($data['coordinates'])) {
125+
throw new InvalidArgumentException(
126+
sprintf('GeoJSON %s must have a "coordinates" property', $type),
127+
);
128+
}
129+
130+
if (! is_array($data['coordinates'])) {
131+
throw new InvalidArgumentException(
132+
sprintf('GeoJSON %s "coordinates" must be an array', $type),
133+
);
134+
}
135+
}
136+
137+
// Validate CRS if present (for SRID support)
138+
if (isset($data['crs'])) {
139+
self::validateCrs($data['crs']);
140+
}
141+
142+
// Validate bbox if present
143+
if (isset($data['bbox']) && ! is_array($data['bbox'])) {
144+
throw new InvalidArgumentException('GeoJSON "bbox" must be an array');
145+
}
146+
}
147+
148+
/**
149+
* Validates the CRS (Coordinate Reference System) property.
150+
*
151+
* @throws InvalidArgumentException If CRS is invalid.
152+
*/
153+
private static function validateCrs(mixed $crs): void
154+
{
155+
if (! is_array($crs)) {
156+
throw new InvalidArgumentException('GeoJSON "crs" must be an object');
157+
}
158+
159+
if (! isset($crs['type']) || $crs['type'] !== 'name') {
160+
throw new InvalidArgumentException('GeoJSON "crs" must have type "name"');
161+
}
162+
163+
if (! isset($crs['properties']) || ! is_array($crs['properties'])) {
164+
throw new InvalidArgumentException('GeoJSON "crs" must have a "properties" object');
165+
}
166+
167+
if (! isset($crs['properties']['name']) || ! is_string($crs['properties']['name'])) {
168+
throw new InvalidArgumentException('GeoJSON "crs.properties" must have a "name" string');
169+
}
170+
}
171+
172+
/**
173+
* Returns the SRID (Spatial Reference System Identifier) if specified in CRS.
174+
*
175+
* Extracts SRID from CRS property in formats:
176+
* - "EPSG:4326"
177+
* - "urn:ogc:def:crs:EPSG::4326"
178+
* - "http://www.opengis.net/def/crs/EPSG/0/4326"
179+
*/
180+
public function getSrid(): int|null
181+
{
182+
if (! isset($this->data['crs']['properties']['name'])) {
183+
return null;
184+
}
185+
186+
$crsName = $this->data['crs']['properties']['name'];
187+
188+
// Match EPSG:4326 format
189+
if (preg_match('/EPSG[:\\/](\d+)$/i', $crsName, $matches)) {
190+
return (int) $matches[1];
191+
}
192+
193+
// Match urn:ogc:def:crs:EPSG::4326 format
194+
if (preg_match('/urn:ogc:def:crs:EPSG::(\d+)$/i', $crsName, $matches)) {
195+
return (int) $matches[1];
196+
}
197+
198+
return null;
199+
}
200+
201+
/**
202+
* Returns the GeoJSON data as a JSON string.
203+
*/
204+
public function toString(): string
205+
{
206+
try {
207+
return json_encode($this->data, JSON_THROW_ON_ERROR);
208+
} catch (JsonException $e) {
209+
throw new InvalidArgumentException(
210+
sprintf('Failed to encode GeoJSON: %s', $e->getMessage()),
211+
0,
212+
$e,
213+
);
214+
}
215+
}
216+
217+
/**
218+
* Returns the GeoJSON data as a JSON string.
219+
*/
220+
public function __toString(): string
221+
{
222+
return $this->toString();
223+
}
224+
}

src/Types/GeographyType.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use function is_string;
1212

1313
/**
14-
* Type that maps a database GEOGRAPHY column to a PHP string containing GeoJSON data.
14+
* Type that maps a database GEOGRAPHY column to a PHP Geometry value object.
1515
*
1616
* Geography types handle spherical coordinate systems and are typically used for
1717
* earth-based geographic data with latitude/longitude coordinates.
@@ -38,21 +38,21 @@ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform)
3838
return null;
3939
}
4040

41-
if (is_string($value)) {
42-
return $value;
41+
if ($value instanceof Geometry) {
42+
return $value->toGeoJSON();
4343
}
4444

4545
throw ValueNotConvertible::new($value, Types::GEOGRAPHY);
4646
}
4747

48-
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?string
48+
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): Geometry|null
4949
{
5050
if ($value === null) {
5151
return null;
5252
}
5353

5454
if (is_string($value)) {
55-
return $value;
55+
return Geometry::fromGeoJSON($value);
5656
}
5757

5858
throw ValueNotConvertible::new($value, Types::GEOGRAPHY);

src/Types/Geometry.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\DBAL\Types;
6+
7+
use InvalidArgumentException;
8+
use Stringable;
9+
10+
/**
11+
* Value object representing spatial geometry data using GeoJSON format.
12+
*
13+
* This class provides a clean abstraction for spatial data that works across different
14+
* database platforms. GeoJSON is the standardized format that includes SRID information
15+
* within the format itself via the CRS property.
16+
*/
17+
final class Geometry implements Stringable
18+
{
19+
private function __construct(
20+
private readonly GeoJSON $geoJson,
21+
private readonly int|null $srid,
22+
) {
23+
}
24+
25+
/**
26+
* Creates a Geometry from GeoJSON string.
27+
*
28+
* GeoJSON format includes SRID information within the CRS property according to RFC 7946.
29+
* This provides a standardized, platform-agnostic representation.
30+
*
31+
* @param string $json GeoJSON string representation of the geometry
32+
*
33+
* @throws InvalidArgumentException If the GeoJSON format is invalid.
34+
*/
35+
public static function fromGeoJSON(string $json): self
36+
{
37+
$geoJson = GeoJSON::fromString($json);
38+
$srid = $geoJson->getSrid();
39+
40+
return new self($geoJson, $srid);
41+
}
42+
43+
/**
44+
* Returns the GeoJSON representation.
45+
*/
46+
public function getGeoJSON(): GeoJSON
47+
{
48+
return $this->geoJson;
49+
}
50+
51+
/**
52+
* Returns the Spatial Reference System Identifier, if set.
53+
*
54+
* This is extracted from the CRS property in the GeoJSON.
55+
*/
56+
public function getSrid(): int|null
57+
{
58+
return $this->srid;
59+
}
60+
61+
/**
62+
* Returns GeoJSON string representation.
63+
*/
64+
public function toGeoJSON(): string
65+
{
66+
return $this->geoJson->toString();
67+
}
68+
69+
/**
70+
* Returns string representation of the geometry in GeoJSON format.
71+
*/
72+
public function __toString(): string
73+
{
74+
return $this->geoJson->toString();
75+
}
76+
}

src/Types/GeometryType.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
use function is_string;
1212

1313
/**
14-
* Type that maps a database GEOMETRY column to a PHP string containing GeoJSON data.
14+
* Type that maps a database GEOMETRY column to a PHP Geometry value object.
1515
*
1616
* This type handles geometric data stored in various database formats and converts
17-
* it to/from GeoJSON format for PHP processing.
17+
* it to/from a Geometry value object that encapsulates GeoJSON.
1818
*/
1919
class GeometryType extends Type
2020
{
@@ -37,21 +37,21 @@ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform)
3737
return null;
3838
}
3939

40-
if (is_string($value)) {
41-
return $value;
40+
if ($value instanceof Geometry) {
41+
return $value->toGeoJSON();
4242
}
4343

4444
throw ValueNotConvertible::new($value, Types::GEOMETRY);
4545
}
4646

47-
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?string
47+
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): Geometry|null
4848
{
4949
if ($value === null) {
5050
return null;
5151
}
5252

5353
if (is_string($value)) {
54-
return $value;
54+
return Geometry::fromGeoJSON($value);
5555
}
5656

5757
throw ValueNotConvertible::new($value, Types::GEOMETRY);

0 commit comments

Comments
 (0)