diff --git a/README.md b/README.md index 282925d..c756a5c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/lib/Exception/NonInstantiableGeometryTypeException.php b/lib/Exception/NonInstantiableGeometryTypeException.php new file mode 100644 index 0000000..0af9d1e --- /dev/null +++ b/lib/Exception/NonInstantiableGeometryTypeException.php @@ -0,0 +1,45 @@ + 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; + } +} diff --git a/lib/Strategy/Common/Lexer.php b/lib/Strategy/Common/Lexer.php index 2400999..d4e063d 100644 --- a/lib/Strategy/Common/Lexer.php +++ b/lib/Strategy/Common/Lexer.php @@ -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; @@ -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)) { diff --git a/tests/Unit/EwktGeometryCoverageTest.php b/tests/Unit/EwktGeometryCoverageTest.php index 6888000..92df8d5 100644 --- a/tests/Unit/EwktGeometryCoverageTest.php +++ b/tests/Unit/EwktGeometryCoverageTest.php @@ -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 */ diff --git a/tests/Unit/NonInstantiableGeometryTypeTest.php b/tests/Unit/NonInstantiableGeometryTypeTest.php new file mode 100644 index 0000000..1d08186 --- /dev/null +++ b/tests/Unit/NonInstantiableGeometryTypeTest.php @@ -0,0 +1,85 @@ + 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 */ + 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); + } + } + } + } +} diff --git a/tests/Unit/SpatialTypeImplementationTest.php b/tests/Unit/SpatialTypeImplementationTest.php index 40842ba..fb466d9 100644 --- a/tests/Unit/SpatialTypeImplementationTest.php +++ b/tests/Unit/SpatialTypeImplementationTest.php @@ -35,8 +35,7 @@ class SpatialTypeImplementationTest extends TestCase /** @return iterable */ 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]; }