Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This table describes the capabilities of the repository's current implementation

| Class. | Output | SRID in output | Dimensions written | Usage |
| --------------------- | ------------------------------- | ----------------- | ------------------ | ---------------------------------------------------------------- |
| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint, MultiLineString, Polygon and MultiPolygon encoding according to RFC 7946 |
| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint, MultiLineString, Polygon, MultiPolygon and GeometryCollection encoding according to RFC 7946 |
| `WktTextStrategy` | WKT Well Known Text | No | XY, XYZ, XYM, XYZM | Display, text exchanges, functions accepting WKT |
| `EwktTextStrategy` | EWKT Extended Well Known Text. | Yes, when nonzero | XY, XYZ, XYM, XYZM | Text exchanges that include the spatial reference |
| `WkbBinaryStrategy` | ISO WKB Well Known Binary. | No | XY, XYZ, XYM, XYZM | Exchanges with a standard WKB consumer |
Expand Down Expand Up @@ -235,9 +235,9 @@ and the [testing guide](../tests/README.md). To add a format, implement

## GeoJSON — `GeoJsonStrategy`

This increment supports Geometry and Geography Points, LineStrings, MultiPoints,
MultiLineStrings, Polygons and MultiPolygons in XY and XYZ, including EMPTY. Other geometry
types are not yet supported. Feature and FeatureCollection composition belongs
This strategy supports Geometry and Geography Points, LineStrings, MultiPoints,
MultiLineStrings, Polygons, MultiPolygons and collections in XY and XYZ, including
EMPTY. Types outside the seven RFC 7946 geometry types are rejected. Feature and FeatureCollection composition belongs
to the consuming application.

```php
Expand Down Expand Up @@ -420,3 +420,37 @@ References: [RFC 7946 geometry objects and positions](https://www.rfc-editor.org
[MultiPolygon members](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.7),
[coordinate reference system](https://www.rfc-editor.org/rfc/rfc7946.html#section-4),
[non-extensible types](https://www.rfc-editor.org/rfc/rfc7946.html#section-7).

GeometryCollections recursively preserve each member's type, coordinates, order
and nesting. GeographyCollection uses the same GeoJSON `GeometryCollection` type:

```php
$collection = new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\GeometryCollection(4326, [
new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point(1, 2, 4326),
new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString([[3, 4], [5, 6]], 4326),
]);
echo $writer->convert($collection);
// {"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]}
```

A collection with no members produces
`{"type":"GeometryCollection","geometries":[]}`. EMPTY geometry members retain
their own empty `coordinates` arrays; nested collections retain their
`geometries` arrays. No `coordinates` member is added to a collection. XY and
XYZ are supported, preserving Z; XYM and XYZM are rejected even when EMPTY.
Reference metadata is omitted throughout, under the caller responsibilities above.

The approved policy deliberately preserves nested, singleton and homogeneous
collections instead of flattening them or converting them to another type.
This does not apply the SHOULD guidance in
[RFC 7946 section 3.1.8](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.8).
Consumers that expect simplified forms may not handle or display these collections
consistently; callers must prepare any simplification needed by their consumers.
Antimeridian crossings also remain unchanged under section 3.1.9 as described above.

All six other supported types use their normal encoders when nested in a
collection. A non-RFC member throws `UnsupportedSpatialTypeException`; an
incompatible member structure throws `UnsupportedGeometryStructureException`.
Failures propagate through every collection level: no member is omitted and no
partial result is returned. JSON failures retain the shared `JsonEncodingException`
contract and original exception.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
namespace LongitudeOne\SpatialWriter\Strategy\GeoJson;

use LongitudeOne\Core\Enum\GeometryTypeEnum;
use LongitudeOne\SpatialTypes\Interfaces\CollectionInterface;
use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\MultiLineStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\MultiPointInterface;
Expand All @@ -40,11 +41,12 @@ final class GeometryEncoder
*
* @param SpatialInterface $spatial the geometry to encode
*
* @return array{type: string, coordinates: array<mixed>}
* @return array{type: string, coordinates?: array<mixed>, geometries?: list<array<string, mixed>>}
*/
public function encode(SpatialInterface $spatial): array
{
return match ($spatial->getType()) {
GeometryTypeEnum::GEOMETRYCOLLECTION => $this->encodeCollection($spatial),
GeometryTypeEnum::POINT => $this->encodePoint($spatial),
GeometryTypeEnum::LINESTRING => $this->encodeLineString($spatial),
GeometryTypeEnum::MULTIPOINT => $this->encodeMultiPoint($spatial),
Expand All @@ -55,6 +57,27 @@ public function encode(SpatialInterface $spatial): array
};
}

/**
* Recursively retain every member, including nested and EMPTY geometries.
*
* @param SpatialInterface $spatial the collection to encode
*
* @return array{type: string, geometries: list<array<string, mixed>>}
*/
private function encodeCollection(SpatialInterface $spatial): array
{
if (!$spatial instanceof CollectionInterface) {
throw new UnsupportedSpatialInterfaceException($spatial::class);
}

$geometries = [];
foreach ($spatial->getElements() as $element) {
$geometries[] = $this->encode($element);
}

return ['type' => 'GeometryCollection', 'geometries' => $geometries];
}

/**
* Preserve a line's positions, allowing EMPTY but rejecting singleton lines.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

use LongitudeOne\Core\Enum\GeometryTypeEnum;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\GeometryCollection;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPoint;
Expand Down Expand Up @@ -51,6 +52,20 @@
#[CoversClass(Writer::class)]
class ExceptionContractTest extends TestCase
{
/** A nested real member retains the original JSON exception. */
public function testCollectionEncodingFailure(): void
{
$collection = new GeometryCollection(0, [new Point(1, 2), new GeometryCollection(0, [new Point(INF, 3)])]);

try {
(new Writer(new GeoJsonStrategy()))->convert($collection);
static::fail('JSON encoding must fail.');
} catch (JsonEncodingException $exception) {
static::assertInstanceOf(\JsonException::class, $exception->getPrevious());
static::assertSame(JSON_ERROR_INF_OR_NAN, $exception->getPrevious()->getCode());
}
}

/** The installed model accepts this float through its ordinary constructor. */
public function testInfinityEncodingFailure(): void
{
Expand Down Expand Up @@ -181,6 +196,17 @@ public function testPolygonEncodingFailure(): void
}
}

/** Preserve the internal exception for a missing collection interface. */
public function testUnsupportedCollectionInterfaceThroughWriter(): void
{
$spatial = static::createStub(SpatialInterface::class);
$spatial->method('getType')->willReturn(GeometryTypeEnum::GEOMETRYCOLLECTION);
$spatial->method('hasM')->willReturn(false);

$this->expectException(UnsupportedSpatialInterfaceException::class);
(new Writer(new GeoJsonStrategy()))->convert($spatial);
}

/** Preserve the approved public exception inheritance and constructor contract. */
public function testUnsupportedDimensionExceptionContract(): void
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php
/**
* This file is part of the binary-writer project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 2024-2026
* Copyright Longitude One 2024-2026
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
*/

declare(strict_types=1);

namespace LongitudeOne\SpatialWriter\Tests\Unit\Strategy\GeoJson;

use LongitudeOne\SpatialTypes\Reference\SpatialReference;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\GeographyCollection;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\LineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiLineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiPoint;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiPolygon;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Point;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Polygon;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Triangle;
use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException;
use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException;
use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException;
use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder;
use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy;
use LongitudeOne\SpatialWriter\Writer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

/**
* Explicit real Geography collection composition examples.
*
* @internal
*/
#[CoversClass(GeoJsonStrategy::class)]
#[CoversClass(GeometryEncoder::class)]
#[CoversClass(Writer::class)]
class GeographyCollectionTest extends TestCase
{
/** Preserve the complete collection structure and values. */
public function testAllMemberTypes(): void
{
$collection = new GeographyCollection(0, [new Point(1, 2), new LineString([[3, 4], [5, 6]]), new MultiPoint([[7, 8], [9, 10]]), new MultiLineString([[[11, 12], [13, 14]]]), new Polygon([[[0, 0], [2, 0], [0, 2], [0, 0]]]), new MultiPolygon([[[[3, 0], [5, 0], [3, 2], [3, 0]]]])]);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]},{"type":"MultiPoint","coordinates":[[7,8],[9,10]]},{"type":"MultiLineString","coordinates":[[[11,12],[13,14]]]},{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]},{"type":"MultiPolygon","coordinates":[[[[3,0],[5,0],[3,2],[3,0]]]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]},{"type":"MultiPoint","coordinates":[[7,8],[9,10]]},{"type":"MultiLineString","coordinates":[[[11,12],[13,14]]]},{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]},{"type":"MultiPolygon","coordinates":[[[[3,0],[5,0],[3,2],[3,0]]]]}]}', (new GeoJsonStrategy())->executeStrategy($collection));
}

/** Preserve the complete collection structure and values. */
public function testAntimeridian(): void
{
$collection = new GeographyCollection(0, [new LineString([[170, 45], [-170, 45]])]);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[170,45],[-170,45]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[170,45],[-170,45]]}]}', (new GeoJsonStrategy())->executeStrategy($collection));
}

/** Preserve the complete collection structure and values. */
public function testEmptyXy(): void
{
$collection = new GeographyCollection(0, []);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new GeoJsonStrategy())->executeStrategy($collection));
}

/** Preserve the complete collection structure and values. */
public function testEmptyXyz(): void
{
$collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\GeographyCollection(0, []);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new GeoJsonStrategy())->executeStrategy($collection));
}

/** Preserve the complete collection structure and values. */
public function testHomogeneous(): void
{
$collection = new GeographyCollection(0, [new Point(3, 4), new Point(1, 2), new Point(3, 4)]);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[3,4]},{"type":"Point","coordinates":[1,2]},{"type":"Point","coordinates":[3,4]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[3,4]},{"type":"Point","coordinates":[1,2]},{"type":"Point","coordinates":[3,4]}]}', (new GeoJsonStrategy())->executeStrategy($collection));
}

/** Preserve the complete collection structure and values. */
public function testNestedEmpty(): void
{
$collection = new GeographyCollection(0, [new GeographyCollection(0, [new Point()]), new GeographyCollection(0, [])]);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[]}]},{"type":"GeometryCollection","geometries":[]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[]}]},{"type":"GeometryCollection","geometries":[]}]}', (new GeoJsonStrategy())->executeStrategy($collection));
}

/** Propagate failure without returning a partial collection. */
public function testRejectClockwisePolygon(): void
{
$collection = new GeographyCollection(0, [new Point(1, 2), new Polygon([[[0, 0], [0, 2], [2, 0], [0, 0]]])]);

$this->expectException(UnsupportedGeometryStructureException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Propagate failure without returning a partial collection. */
public function testRejectDimension3m(): void
{
$collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\GeographyCollection(0, [new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\Point(1, 2, 3)]);

$this->expectException(UnsupportedDimensionException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Propagate failure without returning a partial collection. */
public function testRejectDimension4zm(): void
{
$collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\GeographyCollection(0, [new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\Point(1, 2, 3, 4)]);

$this->expectException(UnsupportedDimensionException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Propagate failure without returning a partial collection. */
public function testRejectEmptyDimension3m(): void
{
$collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\GeographyCollection(0, []);

$this->expectException(UnsupportedDimensionException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Propagate failure without returning a partial collection. */
public function testRejectEmptyDimension4zm(): void
{
$collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\GeographyCollection(0, []);

$this->expectException(UnsupportedDimensionException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Propagate failure without returning a partial collection. */
public function testRejectEmptyMultiPointMember(): void
{
$collection = new GeographyCollection(0, [new Point(1, 2), new MultiPoint([new Point()])]);

$this->expectException(UnsupportedGeometryStructureException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Propagate failure without returning a partial collection. */
public function testRejectNestedSingletonLine(): void
{
$collection = new GeographyCollection(0, [new Point(1, 2), new GeographyCollection(0, [new LineString([[3, 4]])])]);

$this->expectException(UnsupportedGeometryStructureException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Propagate failure without returning a partial collection. */
public function testRejectNestedTriangle(): void
{
$collection = new GeographyCollection(0, [new Point(1, 2), new GeographyCollection(0, [new Triangle([[[0, 0], [2, 0], [0, 2], [0, 0]]])])]);

$this->expectException(UnsupportedSpatialTypeException::class);
(new Writer(new GeoJsonStrategy()))->convert($collection);
}

/** Preserve the complete collection structure and values. */
public function testXy(): void
{
$collection = new GeographyCollection(4326, [new Point(1, 2, 4326), new LineString([[3, 4], [5, 6]], 4326)]);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]}', (new GeoJsonStrategy())->executeStrategy($collection));
}

/** Preserve the complete collection structure and values. */
public function testXyzSingleton(): void
{
$collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\GeographyCollection(new SpatialReference(999999, 'CUSTOM'), [new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\Point(1, 2, 3, new SpatialReference(999999, 'CUSTOM'))]);

static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2,3]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection));
static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2,3]}]}', (new GeoJsonStrategy())->executeStrategy($collection));
}
}
Loading
Loading