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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ The shared WKT/EWKT lexer uses `SpatialTypeImplementationStatus` from
`longitude-one/spatial-types` **0.0.1-alpha.4** to classify recognized geometry
keywords. `CIRCULARSTRING`, `COMPOUNDCURVE`, `CURVEPOLYGON`, `MULTICURVE`,
`MULTISURFACE`, and `TIN` raise `NotYetImplementedException`. Non-instantiable
enum types raise the decoder's `InvalidArgumentException`. Implemented types
enum types raise `NonInstantiableGeometryTypeException`. Implemented types
continue to the representation-specific parsers. EWKT supports `POINT`,
`LINESTRING`, `POLYGON`, `TRIANGLE`, `POLYHEDRALSURFACE`, `MULTIPOINT`,
`MULTILINESTRING`, `MULTIPOLYGON`, and `GEOMETRYCOLLECTION`, including nested
Expand All @@ -96,6 +96,23 @@ The exception reports an implementation limitation; it does not certify that
the complete input is valid. Unknown keywords and malformed input without an
unimplemented keyword retain the existing invalid-input handling.

## Non-instantiable geometry types

WKT and EWKT reject every type for which `GeometryTypeEnum::isInstantiable()`
returns `false` (`GEOMETRY`, `CURVE`, `SURFACE`, and `SOLID`) with
`LongitudeOne\SpatialDecoder\Exception\NonInstantiableGeometryTypeException`.
Its message identifies the recognized type and states that it is not instantiable;
`getSpatialType()` returns the corresponding enum value. The exception implements
`DecoderExceptionInterface` and extends PHP's `\InvalidArgumentException`.

This classification happens in the shared lexer, before spatial object creation
or body validation, including for nested collection members. It is distinct from
`NotYetImplementedException` for recognized instantiable types awaiting implementation
and the decoder's `InvalidArgumentException` for unknown geometry names.
Consumers previously catching the decoder's `InvalidArgumentException` for
non-instantiable types should now catch the dedicated exception or the common
`DecoderExceptionInterface`.

## Current status
![longitude-one/spatial--decoder](https://img.shields.io/badge/longitude--one-spatial--decoder-blue)
![Stable release](https://img.shields.io/github/v/release/longitude-one/spatial-decoder)
Expand Down
45 changes: 45 additions & 0 deletions lib/Exception/NonInstantiableGeometryTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
/**
* This file is part of the spatial-decoder project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 2026
* Copyright Longitude One 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\SpatialDecoder\Exception;

use LongitudeOne\Core\Enum\GeometryTypeEnum;

/**
* A recognized abstract spatial type cannot be instantiated.
*
* The type classification is supplied by GeometryTypeEnum::isInstantiable().
*/
final class NonInstantiableGeometryTypeException extends \InvalidArgumentException implements DecoderExceptionInterface
{
/**
* Retain the recognized type without imposing format-specific validation.
*
* @param GeometryTypeEnum $spatialType the recognized spatial type
*/
public function __construct(private readonly GeometryTypeEnum $spatialType)
{
parent::__construct(\sprintf('Geometry type "%s" is recognized but is not instantiable.', $spatialType->value));
}

/**
* Return the recognized spatial type as supplied by the decoding strategy.
*/
public function getSpatialType(): GeometryTypeEnum
{
return $this->spatialType;
}
}
4 changes: 2 additions & 2 deletions lib/Strategy/Common/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

use Doctrine\Common\Lexer\AbstractLexer;
use LongitudeOne\Core\Enum\GeometryTypeEnum;
use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException;
use LongitudeOne\SpatialDecoder\Exception\NonInstantiableGeometryTypeException;
use LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException;
use LongitudeOne\SpatialTypes\Implementation\SpatialTypeImplementationStatus;

Expand Down Expand Up @@ -186,7 +186,7 @@ private function getWordType(string $value): int
}

if (!$geometryType->isInstantiable()) {
throw new InvalidArgumentException(\sprintf('The spatial type "%s" is not instantiable.', $geometryType->value));
throw new NonInstantiableGeometryTypeException($geometryType);
}

if (!SpatialTypeImplementationStatus::isFullyImplemented($geometryType)) {
Expand Down
4 changes: 0 additions & 4 deletions tests/Unit/EwktGeometryCoverageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,6 @@ public static function invalidGeometries(): iterable
yield 'surface missing parenthesis' => ['POLYHEDRALSURFACE Z ((0 0 0,4 0 0,0 4 0,0 0 0))'];
yield 'nested independent SRID' => ['GEOMETRYCOLLECTION Z (TRIANGLE EMPTY,SRID=3857;POLYHEDRALSURFACE EMPTY)'];
yield 'unknown' => ['UNKNOWN EMPTY'];
foreach (['GEOMETRY', 'CURVE', 'SURFACE', 'SOLID'] as $type) {
yield $type => [$type.' EMPTY'];
yield 'nested '.$type => ['GEOMETRYCOLLECTION ('.$type.' EMPTY)'];
}
}

/** @return iterable<string, array{GeometryTypeEnum}> */
Expand Down
85 changes: 85 additions & 0 deletions tests/Unit/NonInstantiableGeometryTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php
/**
* This file is part of the spatial-decoder project.
*
* PHP 8.4 | 8.5
*
* Copyright Alexandre Tranchant <alexandre.tranchant@gmail.com> 2026
* Copyright Longitude One 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\SpatialDecoder\Tests\Unit;

use LongitudeOne\Core\Enum\GeometryTypeEnum;
use LongitudeOne\SpatialDecoder\Decoder;
use LongitudeOne\SpatialDecoder\Exception\DecoderExceptionInterface;
use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException;
use LongitudeOne\SpatialDecoder\Exception\NonInstantiableGeometryTypeException;
use LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException;
use LongitudeOne\SpatialDecoder\Strategy\EwktDecoderStrategy;
use LongitudeOne\SpatialDecoder\Strategy\WktDecoderStrategy;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* @internal
*
* @covers \LongitudeOne\SpatialDecoder\Exception\NonInstantiableGeometryTypeException
* @covers \LongitudeOne\SpatialDecoder\Strategy\Common\Lexer
*/
class NonInstantiableGeometryTypeTest extends TestCase
{
/** @return iterable<string, array{GeometryTypeEnum}> */
public static function abstractTypes(): iterable
{
foreach (GeometryTypeEnum::cases() as $type) {
if (!$type->isInstantiable()) {
yield $type->name => [$type];
}
}
}

/** @param GeometryTypeEnum $type recognized non-instantiable type */
#[DataProvider('abstractTypes')]
public function testStrategiesRejectAbstractTypesBeforeBodyValidation(GeometryTypeEnum $type): void
{
foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) {
$prefixes = $strategy instanceof EwktDecoderStrategy ? ['', 'SRID=4326;'] : [''];
foreach ($prefixes as $prefix) {
foreach ([' EMPTY', ' Z EMPTY', ' M EMPTY', ' ZM EMPTY', 'Z EMPTY', 'M EMPTY', 'ZM EMPTY', ' (garbage)'] as $body) {
foreach ([strtolower($type->name).$body, 'GEOMETRYCOLLECTION ('.$type->name.$body.')'] as $geometry) {
try {
(new Decoder($strategy))->decode($prefix.$geometry);
self::fail('A non-instantiable geometry must be rejected.');
} catch (NonInstantiableGeometryTypeException $exception) {
self::assertContains(DecoderExceptionInterface::class, class_implements($exception));
self::assertSame($type, $exception->getSpatialType());
self::assertSame(\sprintf('Geometry type "%s" is recognized but is not instantiable.', $type->value), $exception->getMessage());
}
}
}
}
}
}

/** Verify lexical boundaries and the three distinct public error contracts. */
public function testUnknownAndUnimplementedTypesRemainDistinct(): void
{
foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) {
foreach (['UNKNOWN', 'CURVEUNKNOWN', 'GEOMETRYUNKNOWN', 'SURFACEUNKNOWN', 'SOLIDUNKNOWN', 'CIRCULARSTRING'] as $keyword) {
try {
$strategy->decode($keyword.' EMPTY');
self::fail('An unknown or unimplemented type must be rejected.');
} catch (DecoderExceptionInterface $exception) {
self::assertSame('CIRCULARSTRING' === $keyword ? NotYetImplementedException::class : InvalidArgumentException::class, $exception::class);
}
}
}
}
}
3 changes: 1 addition & 2 deletions tests/Unit/SpatialTypeImplementationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ class SpatialTypeImplementationTest extends TestCase
/** @return iterable<string, array{string}> */
public static function invalidInputs(): iterable
{
foreach (['GEOMETRY EMPTY', 'CURVE EMPTY', 'SURFACE EMPTY', 'SOLID EMPTY',
'UNKNOWN EMPTY', 'CIRCULARSTRINGUNKNOWN EMPTY', 'POINT (1)', 'POINT (1 @ 2)',
foreach (['UNKNOWN EMPTY', 'CIRCULARSTRINGUNKNOWN EMPTY', 'POINT (1)', 'POINT (1 @ 2)',
'TRIANGLE ((0 0,1 0,0 0))', 'POLYHEDRALSURFACE EMPTY'] as $input) {
yield $input => [$input];
}
Expand Down
Loading