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
32 changes: 29 additions & 3 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 and MultiPoint encoding according to RFC 7946 |
| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint and MultiLineString 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,7 +235,7 @@ and the [testing guide](../tests/README.md). To add a format, implement

## GeoJSON — `GeoJsonStrategy`

This increment supports Geometry and Geography Points, LineStrings and MultiPoints in XY and
This increment supports Geometry and Geography Points, LineStrings, MultiPoints and MultiLineStrings in XY and
XYZ, including EMPTY. Other geometry types are not yet supported. Feature and FeatureCollection
composition belongs to the consuming application.

Expand Down Expand Up @@ -294,7 +294,7 @@ antimeridian, such as `[[170,45],[-170,45]]`, are encoded unchanged, without
cutting or detection for rejection. This deliberately does not apply the SHOULD
in RFC 7946 section 3.1.9: consumers may interpret or display an uncut line as
crossing the long way around the globe. Callers must prepare any cutting required
by their consumers before encoding; this increment does not support MultiLineString.
by their consumers before encoding.

MultiPoints preserve the number and order of their positions, including duplicates
and Z. A singleton remains a MultiPoint rather than becoming a Point:
Expand All @@ -317,6 +317,31 @@ of EMPTY members. No member is omitted or replaced. The required spatial-types
them through its public structural exception. An empty aggregate with no members
remains supported.

MultiLineStrings preserve each supplied line, its position order and Z, and the
order of the members. A single member remains wrapped as a MultiLineString:

```php
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString([
[[1, 2], [3, 4]],
[[5, 6], [7, 8]],
]);
echo $writer->convert($multiLine);
// {"type":"MultiLineString","coordinates":[[[1,2],[3,4]],[[5,6],[7,8]]]}
```

An XY or XYZ MultiLineString with no members produces
`{"type":"MultiLineString","coordinates":[]}`. Any EMPTY LineString member,
including an aggregate containing only EMPTY members, throws
`UnsupportedGeometryStructureException`. A member with only one position also
throws that exception, as a GeoJSON line requires at least two positions.
The model permits these line members; the writer rejects them without omitting,
flattening or repairing them, and never returns partial output or nested empty
coordinate arrays. XYM and XYZM are rejected even with no members.

Antimeridian-crossing members are preserved unchanged under the deliberate
non-application of RFC 7946 section 3.1.9 described above. The same caller
responsibilities, reference omission and JSON failure contract apply.

The approved contracts for later geometry contributions preserve nested,
singleton and homogeneous GeometryCollections. These deliberately do not apply
the SHOULD guidance of RFC 7946 section 3.1.8. Consumers may have limited support
Expand All @@ -326,5 +351,6 @@ this increment.

References: [RFC 7946 geometry objects and positions](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1),
[MultiPoint positions](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.3),
[MultiLineString members](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.5),
[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).
25 changes: 25 additions & 0 deletions lib/LongitudeOne/SpatialWriter/Strategy/GeoJsonStrategy.php
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\LineStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\MultiLineStringInterface;
use LongitudeOne\SpatialTypes\Interfaces\MultiPointInterface;
use LongitudeOne\SpatialTypes\Interfaces\PointInterface;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;
Expand Down Expand Up @@ -53,6 +54,7 @@ public function executeStrategy(SpatialInterface $spatial): string
GeometryTypeEnum::POINT => $this->encodePoint($spatial),
GeometryTypeEnum::LINESTRING => $this->encodeLineString($spatial),
GeometryTypeEnum::MULTIPOINT => $this->encodeMultiPoint($spatial),
GeometryTypeEnum::MULTILINESTRING => $this->encodeMultiLineString($spatial),
default => throw new UnsupportedSpatialTypeException('This GeoJSON strategy does not support '.$spatial->getType()->name.'.'),
};

Expand Down Expand Up @@ -84,6 +86,29 @@ private function encodeLineString(SpatialInterface $spatial): array
return ['type' => 'LineString', 'coordinates' => $coordinates];
}

/**
* Preserve line membership and order, rejecting EMPTY and singleton members.
*
* @param SpatialInterface $spatial the multi-line string to encode
*
* @return array{type: string, coordinates: (float|int)[][][]}
*/
private function encodeMultiLineString(SpatialInterface $spatial): array
{
if (!$spatial instanceof MultiLineStringInterface) {
throw new UnsupportedSpatialInterfaceException($spatial::class);
}

$coordinates = $spatial->toArray();
foreach ($coordinates as $line) {
if (count($line) < 2) {
throw new UnsupportedGeometryStructureException('Every GeoJSON MultiLineString member requires at least two positions.');
}
}

return ['type' => 'MultiLineString', 'coordinates' => $coordinates];
}

/**
* Preserve all positions, rejecting EMPTY members without omitting them.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use LongitudeOne\Core\Enum\GeometryTypeEnum;
use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPoint;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point;
use LongitudeOne\SpatialWriter\Exception\ExceptionInterface;
Expand Down Expand Up @@ -90,6 +91,20 @@ public function testLineStringEncodingFailure(): void
}
}

/** Non-finite line members use the shared JSON failure contract. */
public function testMultiLineStringEncodingFailure(): void
{
$multiLine = new MultiLineString([[[1, 2], [3, 4]], [[5, 6], [INF, 8]]]);

try {
(new Writer(new GeoJsonStrategy()))->convert($multiLine);
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());
}
}

/** Non-finite MultiPoint members use the shared JSON failure contract. */
public function testMultiPointEncodingFailure(): void
{
Expand Down Expand Up @@ -175,6 +190,17 @@ public function testUnsupportedLineStringInterfaceThroughWriter(): void
(new Writer(new GeoJsonStrategy()))->convert($spatial);
}

/** Reject a declared MultiLineString without its corresponding interface. */
public function testUnsupportedMultiLineStringInterfaceThroughWriter(): void
{
$spatial = static::createStub(SpatialInterface::class);
$spatial->method('getType')->willReturn(GeometryTypeEnum::MULTILINESTRING);
$spatial->method('hasM')->willReturn(false);

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

/** Reject a declared MultiPoint without its corresponding interface. */
public function testUnsupportedMultiPointInterfaceThroughWriter(): void
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?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\LineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiLineString;
use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException;
use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException;
use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy;
use LongitudeOne\SpatialWriter\Writer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

/**
* Explicit Geography MultiLineString GeoJSON examples.
*
* @internal
*/
#[CoversClass(GeoJsonStrategy::class)]
#[CoversClass(Writer::class)]
class GeographyMultiLineStringTest extends TestCase
{
/** Preserve line membership, coordinates and order without reference metadata. */
public function testAntimeridian(): void
{
$multiLine = new MultiLineString([[[170, 45], [-170, 45]], [[1, 2], [3, 4]]], 4326);

static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[170,45],[-170,45]],[[1,2],[3,4]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiLine));
static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[170,45],[-170,45]],[[1,2],[3,4]]]}', (new GeoJsonStrategy())->executeStrategy($multiLine));
}

/** Preserve line membership, coordinates and order without reference metadata. */
public function testDeclaredReference(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiLineString([[[1, 2, 3], [4, 5, 6]]], new SpatialReference(4326, 'EPSG'));

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

/** Preserve line membership, coordinates and order without reference metadata. */
public function testEmptyXy(): void
{
$multiLine = new MultiLineString([]);

static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($multiLine));
static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[]}', (new GeoJsonStrategy())->executeStrategy($multiLine));
}

/** Preserve line membership, coordinates and order without reference metadata. */
public function testEmptyXyz(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiLineString([]);

static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[]}', (new Writer(new GeoJsonStrategy()))->convert($multiLine));
static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[]}', (new GeoJsonStrategy())->executeStrategy($multiLine));
}

/** Preserve line membership, coordinates and order without reference metadata. */
public function testMemberAndPositionOrder(): void
{
$multiLine = new MultiLineString([[[7.5, 8], [5, 6], [-2, 1]], [[3, 4], [1, 2]]]);

static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[7.5,8],[5,6],[-2,1]],[[3,4],[1,2]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiLine));
static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[7.5,8],[5,6],[-2,1]],[[3,4],[1,2]]]}', (new GeoJsonStrategy())->executeStrategy($multiLine));
}

/** Preserve line membership, coordinates and order without reference metadata. */
public function testOtherSrid(): void
{
$multiLine = new MultiLineString([new LineString([[1, 2], [3, 4]], 3857)], 3857);

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

/** Reject an incompatible line member instead of returning partial output. */
public function testRejectAllEmpty(): void
{
$multiLine = new MultiLineString([new LineString([]), new LineString([])]);

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

/** Reject an incompatible line member instead of returning partial output. */
public function testRejectAllEmptyXyz(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiLineString([[], []]);

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

/** Reject an incompatible line member instead of returning partial output. */
public function testRejectEmptyFirst(): void
{
$multiLine = new MultiLineString([new LineString([]), new LineString([[1, 2], [3, 4]])]);

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

/** Reject an incompatible line member instead of returning partial output. */
public function testRejectEmptyLast(): void
{
$multiLine = new MultiLineString([new LineString([[1, 2], [3, 4]]), new LineString([])]);

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

/** Reject measured dimensions even when no line members are present. */
public function testRejectEmptyXym(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\MultiLineString([]);

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

/** Reject measured dimensions even when no line members are present. */
public function testRejectEmptyXyzm(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\MultiLineString([]);

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

/** Reject an incompatible line member instead of returning partial output. */
public function testRejectEmptyXyzMember(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiLineString([[[1, 2, 3], [4, 5, 6]], []]);

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

/** Reject an incompatible line member instead of returning partial output. */
public function testRejectSingletonMember(): void
{
$multiLine = new MultiLineString([[[1, 2], [3, 4]], [[5, 6]]]);

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

/** Reject an incompatible line member instead of returning partial output. */
public function testRejectSingletonXyzMember(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiLineString([[[1, 2, 3]]]);

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

/** Reject measured dimensions even when no line members are present. */
public function testRejectXym(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\MultiLineString([[[1, 2, 3], [4, 5, 6]]]);

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

/** Reject measured dimensions even when no line members are present. */
public function testRejectXyzm(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\MultiLineString([[[1, 2, 3, 4], [5, 6, 7, 8]]]);

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

/** Preserve line membership, coordinates and order without reference metadata. */
public function testSingleMember(): void
{
$multiLine = new MultiLineString([[[3, 4], [1, 2]]]);

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

/** Preserve line membership, coordinates and order without reference metadata. */
public function testUnknownReference(): void
{
$multiLine = new MultiLineString([[[1, 2], [3, 4]]], new SpatialReference(999999, 'CUSTOM'));

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

/** Preserve line membership, coordinates and order without reference metadata. */
public function testXy(): void
{
$multiLine = new MultiLineString([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]);

static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[1,2],[3,4]],[[5,6],[7,8]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiLine));
static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[1,2],[3,4]],[[5,6],[7,8]]]}', (new GeoJsonStrategy())->executeStrategy($multiLine));
}

/** Preserve line membership, coordinates and order without reference metadata. */
public function testXyz(): void
{
$multiLine = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\MultiLineString([[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]]);

static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[1,2,3],[4,5,6]],[[7,8,9],[10,11,12]]]}', (new Writer(new GeoJsonStrategy()))->convert($multiLine));
static::assertJsonStringEqualsJsonString('{"type":"MultiLineString","coordinates":[[[1,2,3],[4,5,6]],[[7,8,9],[10,11,12]]]}', (new GeoJsonStrategy())->executeStrategy($multiLine));
}
}
Loading
Loading