From a9aabf3c48ef90fde6a7bcd6ab8f15c147f64dfe Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 19 Aug 2025 15:45:29 +0300 Subject: [PATCH 01/26] feat(#305): add support for PostGIS's `GEOGRAPHY` and `GEOMETRY` --- .github/workflows/integration-tests.yml | 8 +++++-- README.md | 8 ++++--- devenv.nix | 12 ++++++++++ docker-compose.yml | 3 ++- docs/AVAILABLE-TYPES.md | 20 ++++++++++++----- docs/CONTRIBUTING.md | 2 +- docs/USE-CASES-AND-EXAMPLES.md | 29 +++++++++++++++++++++++++ tests/Integration/README.md | 14 ++++++------ 8 files changed, 76 insertions(+), 20 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3df3efaf..be1cf7c7 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -34,17 +34,18 @@ jobs: needs: should-run if: needs.should-run.outputs.run == 'true' runs-on: ubuntu-latest - name: "PostgreSQL ${{ matrix.postgres }} + PHP ${{ matrix.php }}" + name: "PostgreSQL ${{ matrix.postgres }} + PostGIS ${{ matrix.postgis }} + PHP ${{ matrix.php }}" strategy: fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4'] postgres: ['16', '17'] + postgis: ['3.4', '3.5'] services: postgres: - image: postgres:${{ matrix.postgres }} + image: postgis/postgis:${{ matrix.postgres }}-${{ matrix.postgis }} env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres @@ -106,6 +107,9 @@ jobs: echo "\nListing available PostgreSQL extensions:" PGPASSWORD=postgres psql -h localhost -U postgres -d postgres_doctrine_test -c "SELECT * FROM pg_available_extensions;" + echo "\nVerifying PostGIS installation:" + PGPASSWORD=postgres psql -h localhost -U postgres -d postgres_doctrine_test -c "SELECT PostGIS_Version();" + - name: Run integration test suite run: composer run-integration-tests env: diff --git a/README.md b/README.md index d880f82c..94480e84 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ This package provides comprehensive Doctrine support for PostgreSQL features: - MAC addresses (`macaddr`, `macaddr[]`) - **Geometric Types** - Point (`point`, `point[]`) + - PostGIS Geometry (`geometry`) + - PostGIS Geography (`geography`) - **Range Types** - Date and time ranges (`daterange`, `tsrange`, `tstzrange`) - Numeric ranges (`numrange`, `int4range`, `int8range`) @@ -122,10 +124,10 @@ composer run-unit-tests ``` ### PostgreSQL Integration Tests -We also provide integration tests that run against a real PostgreSQL database to ensure compatibility: +We also provide integration tests that run against a real PostgreSQL database with PostGIS to ensure compatibility: ```bash -# Start PostgreSQL using Docker Compose +# Start PostgreSQL with PostGIS using Docker Compose docker-compose up -d # Run integration tests @@ -135,7 +137,7 @@ composer run-integration-tests docker-compose down -v ``` -See [tests-integration/README.md](tests-integration/README.md) for more details. +See [tests/Integration/README.md](tests/Integration/README.md) for more details. ## ⭐ Support the Project diff --git a/devenv.nix b/devenv.nix index b05176ca..b336842d 100644 --- a/devenv.nix +++ b/devenv.nix @@ -60,14 +60,26 @@ in services.postgres = { enable = true; + # Use PostgreSQL 17 to match Docker Compose and CI + package = pkgs.postgresql_17; + listen_addresses = "127.0.0.1"; port = config.env.POSTGRES_PORT; initialDatabases = [ { name = config.env.POSTGRES_DB; } ]; + # Enable PostGIS extension + extensions = extensions: [ + extensions.postgis + ]; + initialScript = '' CREATE ROLE "${config.env.POSTGRES_USER}" WITH SUPERUSER LOGIN PASSWORD '${config.env.POSTGRES_PASSWORD}'; + + -- Enable PostGIS extension in the database + \c ${config.env.POSTGRES_DB} + CREATE EXTENSION IF NOT EXISTS postgis; ''; }; diff --git a/docker-compose.yml b/docker-compose.yml index 430bec19..ec6cb967 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,11 @@ services: postgres: - image: postgres:${POSTGRES_VERSION:-17} + image: postgis/postgis:${POSTGRES_VERSION:-17}-3.4 environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-postgres_doctrine_test} + TZ: UTC ports: - "${POSTGRES_PORT:-5432}:5432" volumes: diff --git a/docs/AVAILABLE-TYPES.md b/docs/AVAILABLE-TYPES.md index 02ef6551..91e22acd 100644 --- a/docs/AVAILABLE-TYPES.md +++ b/docs/AVAILABLE-TYPES.md @@ -8,20 +8,28 @@ | bigint[] | _int8 | `MartinGeorgiev\Doctrine\DBAL\Types\BigIntArray` | | real[] | _float4 | `MartinGeorgiev\Doctrine\DBAL\Types\RealArray` | | double precision[] | _float8 | `MartinGeorgiev\Doctrine\DBAL\Types\DoublePrecisionArray` | -| text[] | _text | `MartinGeorgiev\Doctrine\DBAL\Types\TextArray` | -| jsonb[] | _jsonb | `MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray` | +|---|---|---| | jsonb | jsonb | `MartinGeorgiev\Doctrine\DBAL\Types\Jsonb` | -| inet | inet | `MartinGeorgiev\Doctrine\DBAL\Types\Inet` | -| inet[] | _inet | `MartinGeorgiev\Doctrine\DBAL\Types\InetArray` | +| jsonb[] | _jsonb | `MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray` | +| text[] | _text | `MartinGeorgiev\Doctrine\DBAL\Types\TextArray` | +|---|---|---| | cidr | cidr | `MartinGeorgiev\Doctrine\DBAL\Types\Cidr` | | cidr[] | _cidr | `MartinGeorgiev\Doctrine\DBAL\Types\CidrArray` | +| inet | inet | `MartinGeorgiev\Doctrine\DBAL\Types\Inet` | +| inet[] | _inet | `MartinGeorgiev\Doctrine\DBAL\Types\InetArray` | | macaddr | macaddr | `MartinGeorgiev\Doctrine\DBAL\Types\Macaddr` | | macaddr[] | _macaddr | `MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray` | -| point | point | `MartinGeorgiev\Doctrine\DBAL\Types\Point` | -| point[] | _point | `MartinGeorgiev\Doctrine\DBAL\Types\PointArray` | +|---|---|---| | daterange | daterange | `MartinGeorgiev\Doctrine\DBAL\Types\DateRange` | | int4range | int4range | `MartinGeorgiev\Doctrine\DBAL\Types\Int4Range` | | int8range | int8range | `MartinGeorgiev\Doctrine\DBAL\Types\Int8Range` | | numrange | numrange | `MartinGeorgiev\Doctrine\DBAL\Types\NumRange` | | tsrange | tsrange | `MartinGeorgiev\Doctrine\DBAL\Types\TsRange` | | tstzrange | tstzrange | `MartinGeorgiev\Doctrine\DBAL\Types\TstzRange` | +|---|---|---| +| geography | geography | `MartinGeorgiev\Doctrine\DBAL\Types\Geography` | +| geography[] | geography[] | `MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray` | +| geometry | geometry | `MartinGeorgiev\Doctrine\DBAL\Types\Geometry` | +| geometry[] | geometry[] | `MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray` | +| point | point | `MartinGeorgiev\Doctrine\DBAL\Types\Point` | +| point[] | _point | `MartinGeorgiev\Doctrine\DBAL\Types\PointArray` | diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0e602b2a..39b3f9be 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -117,7 +117,7 @@ The provided environment includes: - PHP 8.1, which is the oldest PHPβ€―version supported by this project. - Composer -- PostgreSQL 17, started by `devenv up`. +- PostgreSQL 17 with PostGIS 3.4, started by `devenv up`. - Pre-commit hooks (PHP-CS-Fixer, PHPStan, Rector, deptrac, ...). ### Local development diff --git a/docs/USE-CASES-AND-EXAMPLES.md b/docs/USE-CASES-AND-EXAMPLES.md index bfd62451..a41c473d 100644 --- a/docs/USE-CASES-AND-EXAMPLES.md +++ b/docs/USE-CASES-AND-EXAMPLES.md @@ -139,3 +139,32 @@ SELECT p FROM Product p WHERE CONTAINS(p.availabilityPeriod, DATERANGE('2024-06- -- Find products with prices in a specific range SELECT p FROM Product p WHERE p.priceRange @> 25.0 ``` + + +Using PostGIS Types +--- + +The library provides DBAL type support for PostGIS `geometry` and `geography` columns. Example usage: + +```sql +CREATE TABLE places ( + id SERIAL PRIMARY KEY, + location GEOMETRY, + boundary GEOGRAPHY +); +``` + +```php +use Doctrine\DBAL\Types\Type; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Geometry as GeometryValueObject; + +Type::addType('geography', MartinGeorgiev\Doctrine\DBAL\Types\Geography::class); +Type::addType('geometry', MartinGeorgiev\Doctrine\DBAL\Types\Geometry::class); + +$location = GeometryValueObject::fromWKT('SRID=4326;POINT(-122.4194 37.7749)'); +$entity->setLocation($location); +``` + +Notes: +- Values round-trip as EWKT/WKT strings at the database boundary. +- Integration tests automatically enable the `postgis` extension; ensure PostGIS is available in your environment. diff --git a/tests/Integration/README.md b/tests/Integration/README.md index 96784087..09574736 100644 --- a/tests/Integration/README.md +++ b/tests/Integration/README.md @@ -1,27 +1,27 @@ # PostgreSQL Integration Tests -This directory contains integration tests that run against a real PostgreSQL database. These tests validate that our custom DBAL types work correctly with an actual PostgreSQL instance. +This directory contains integration tests that run against a real PostgreSQL database with PostGIS extensions. These tests validate that our custom DBAL types work correctly with an actual PostgreSQL instance, including PostGIS geometry and geography types. ## Running Tests Locally ### Prerequisites -- Docker (for running PostgreSQL) +- Docker (for running PostgreSQL with PostGIS) - PHP 8.1+ with the `pdo_pgsql` extension -### Start PostgreSQL +### Start PostgreSQL with PostGIS -You can use Docker Compose to start PostgreSQL: +You can use Docker Compose to start PostgreSQL with PostGIS: ```bash -# Start PostgreSQL using Docker Compose +# Start PostgreSQL with PostGIS using Docker Compose docker-compose up -d ``` Or use a plain Docker command: ```bash -docker run --name postgres-doctrine-test -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=postgres_doctrine_test -p 5432:5432 -d postgres:17 +docker run --name postgres-doctrine-test -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=postgres_doctrine_test -p 5432:5432 -d postgis/postgis:17-3.4 ``` ### Run the Tests @@ -66,6 +66,6 @@ docker rm postgres-doctrine-test ## CI Integration -These tests are automatically run in GitHub Actions against PostgreSQL 16 and 17 for all supported PHP versions. +These tests are automatically run in GitHub Actions against PostgreSQL 16 and 17 with PostGIS 3.4 for all supported PHP versions. The workflow is defined in `.github/workflows/integration-tests.yml`. From 3801e55ae8de9ab5ebfc8fc8d159bccf3f85f453 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 19 Aug 2025 17:43:19 +0300 Subject: [PATCH 02/26] excpetions skeleton --- docs/AVAILABLE-TYPES.md | 4 +- .../Doctrine/DBAL/Types/Point.php | 3 +- .../Exceptions/InvalidGeometryException.php | 48 +++++++++++++++++++ .../Exceptions/InvalidPointException.php | 36 ++++++++++++++ .../Doctrine/DBAL/Types/ValueObject/Point.php | 10 ++-- 5 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php diff --git a/docs/AVAILABLE-TYPES.md b/docs/AVAILABLE-TYPES.md index 91e22acd..ad58bc7e 100644 --- a/docs/AVAILABLE-TYPES.md +++ b/docs/AVAILABLE-TYPES.md @@ -28,8 +28,8 @@ | tstzrange | tstzrange | `MartinGeorgiev\Doctrine\DBAL\Types\TstzRange` | |---|---|---| | geography | geography | `MartinGeorgiev\Doctrine\DBAL\Types\Geography` | -| geography[] | geography[] | `MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray` | +| geography[] | _geography | `MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray` | | geometry | geometry | `MartinGeorgiev\Doctrine\DBAL\Types\Geometry` | -| geometry[] | geometry[] | `MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray` | +| geometry[] | _geometry | `MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray` | | point | point | `MartinGeorgiev\Doctrine\DBAL\Types\Point` | | point[] | _point | `MartinGeorgiev\Doctrine\DBAL\Types\PointArray` | diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php index 49f6285b..edad8f7f 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointForDatabaseException; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointForPHPException; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidPointException; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point as PointValueObject; /** @@ -46,7 +47,7 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?PointVal try { return PointValueObject::fromString($value); - } catch (\InvalidArgumentException) { + } catch (InvalidPointException) { throw InvalidPointForDatabaseException::forInvalidFormat($value); } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php new file mode 100644 index 00000000..37698a3e --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php @@ -0,0 +1,48 @@ + + */ +final class InvalidGeometryException extends \InvalidArgumentException +{ + public static function forEmptyWkt(): self + { + return new self('Empty Wkt string provided'); + } + + public static function forMissingSemicolonInEwkt(): self + { + return new self('Invalid Ewkt: missing semicolon after Srid prefix'); + } + + public static function forInvalidSridValue(mixed $sridValue): self + { + return new self(\sprintf('Invalid Srid value in Ewkt: %s', \var_export($sridValue, true))); + } + + public static function forInvalidWktFormat(string $wkt): self + { + return new self(\sprintf('Invalid Wkt format: %s', \var_export($wkt, true))); + } + + public static function forEmptyCoordinateSection(): self + { + return new self('Invalid Wkt: empty coordinate/body section'); + } + + public static function forUnsupportedGeometryType(string $type): self + { + return new self(\sprintf('Unsupported Wkt geometry type: %s', \var_export($type, true))); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php new file mode 100644 index 00000000..99f15a5b --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php @@ -0,0 +1,36 @@ + + */ +final class InvalidPointException extends \InvalidArgumentException +{ + public static function forInvalidPointFormat(string $pointString, string $expectedPattern): self + { + return new self(\sprintf( + 'Invalid point format. Expected format matching %s, got: %s', + \var_export($expectedPattern, true), + \var_export($pointString, true) + )); + } + + public static function forInvalidCoordinate(string $coordinateName, string $value): self + { + return new self(\sprintf( + 'Invalid %s coordinate format: %s', + \var_export($coordinateName, true), + \var_export($value, true) + )); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php index 91739bfa..4b6057e6 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php @@ -4,6 +4,8 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidPointException; + /** * @since 3.1 * @@ -41,9 +43,7 @@ public function getY(): float public static function fromString(string $pointString): self { if (!\preg_match(self::POINT_REGEX, $pointString, $matches)) { - throw new \InvalidArgumentException( - \sprintf('Invalid point format. Expected format matching %s, got: %s', self::POINT_REGEX, $pointString) - ); + throw InvalidPointException::forInvalidPointFormat($pointString, self::POINT_REGEX); } return new self((float) $matches[1], (float) $matches[2]); @@ -55,9 +55,7 @@ private function validateCoordinate(float $value, string $name): void $floatRegex = '/^'.self::COORDINATE_PATTERN.'$/'; if (!\preg_match($floatRegex, $stringValue)) { - throw new \InvalidArgumentException( - \sprintf('Invalid %s coordinate format: %s', $name, $stringValue) - ); + throw InvalidPointException::forInvalidCoordinate($name, $stringValue); } } } From 9fa3471c3ccd7299ee3ee263abd82fa887cfb66b Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 19 Aug 2025 18:38:57 +0300 Subject: [PATCH 03/26] add geometry vo --- .../Exceptions/InvalidGeometryException.php | 10 +- .../DBAL/Types/ValueObject/Geometry.php | 103 ++++++++++++++++++ .../Types/ValueObject/WKTGeometryType.php | 40 +++++++ .../DBAL/Types/ValueObject/GeometryTest.php | 64 +++++++++++ 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php index 37698a3e..d78ceb73 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php @@ -4,6 +4,8 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktGeometryType; + /** * Exception thrown when creating or manipulating Geometry value objects with invalid data. * @@ -43,6 +45,12 @@ public static function forEmptyCoordinateSection(): self public static function forUnsupportedGeometryType(string $type): self { - return new self(\sprintf('Unsupported Wkt geometry type: %s', \var_export($type, true))); + $supportedTypes = \array_map(fn(WktGeometryType $case) => $case->value, WktGeometryType::cases()); + + return new self(\sprintf( + 'Unsupported Wkt geometry type: %s. Supported types: %s', + \var_export($type, true), + \implode(', ', $supportedTypes) + )); } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php new file mode 100644 index 00000000..dd0ff4a1 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php @@ -0,0 +1,103 @@ + + */ +final class Geometry implements \Stringable +{ + private ?int $srid; + + private WktGeometryType $wktType; + + private string $wktBody; + + private function __construct(?int $srid, WktGeometryType $wktType, string $wktBody) + { + $this->srid = $srid; + $this->wktType = $wktType; + $this->wktBody = $wktBody; + } + + public static function fromWkt(string $wkt): self + { + $wkt = trim($wkt); + if ($wkt === '') { + throw InvalidGeometryException::forEmptyWkt(); + } + + $srid = null; + $expectSrid = str_starts_with($wkt, 'SRID='); + if ($expectSrid) { + $sridSeparatorPosition = strpos($wkt, ';'); + if ($sridSeparatorPosition === false) { + throw InvalidGeometryException::forMissingSemicolonInEwkt(); + } + $sridRawValue = substr($wkt, 5, $sridSeparatorPosition - 5); + if ($sridRawValue === '' || !ctype_digit($sridRawValue)) { + throw InvalidGeometryException::forInvalidSridValue($sridRawValue); + } + $srid = (int) $sridRawValue; + $wkt = substr($wkt, $sridSeparatorPosition + 1); + } + + $wktTypeWithOptionalModifiersPattern = '/^([A-Z][A-Z0-9_]*)(?:\s+(?:ZM|Z|M))?\s*\((.*)\)$/s'; + if (!preg_match($wktTypeWithOptionalModifiersPattern, $wkt, $matches)) { + throw InvalidGeometryException::forInvalidWktFormat($wkt); + } + + $typeString = $matches[1]; + $body = $matches[2]; + if ($body === '') { + throw InvalidGeometryException::forEmptyCoordinateSection(); + } + + $geometryType = WktGeometryType::tryFrom($typeString); + if ($geometryType === null) { + throw InvalidGeometryException::forUnsupportedGeometryType($typeString); + } + + return new self($srid, $geometryType, $body); + } + + public function __toString(): string + { + $typeAndBody = $this->wktType->value.'('.$this->wktBody.')'; + if ($this->srid === null) { + return $typeAndBody; + } + + return 'SRID='.$this->srid.';'.$typeAndBody; + } + + public function getSrid(): ?int + { + return $this->srid; + } + + public function getGeometryType(): WktGeometryType + { + return $this->wktType; + } + + public function getWkt(): string + { + return (string) $this; + } +} + diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php new file mode 100644 index 00000000..cdcb3d23 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php @@ -0,0 +1,40 @@ + + */ +enum WktGeometryType: string +{ + // Basic geometry types + case POINT = 'POINT'; + case LINESTRING = 'LINESTRING'; + case POLYGON = 'POLYGON'; + + // Multi-geometry types + case MULTIPOINT = 'MULTIPOINT'; + case MULTILINESTRING = 'MULTILINESTRING'; + case MULTIPOLYGON = 'MULTIPOLYGON'; + + // Collection types + case GEOMETRYCOLLECTION = 'GEOMETRYCOLLECTION'; + + // Circular geometry types (PostGIS extensions) + case CIRCULARSTRING = 'CIRCULARSTRING'; + case COMPOUNDCURVE = 'COMPOUNDCURVE'; + case CURVEPOLYGON = 'CURVEPOLYGON'; + case MULTICURVE = 'MULTICURVE'; + case MULTISURFACE = 'MULTISURFACE'; + + // Triangle and TIN types + case TRIANGLE = 'TRIANGLE'; + case TIN = 'TIN'; + case POLYHEDRALSURFACE = 'POLYHEDRALSURFACE'; +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php new file mode 100644 index 00000000..1234727e --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php @@ -0,0 +1,64 @@ +getGeometryType()); + self::assertSame($expectedSrid, $geometry->getSrid()); + self::assertSame($wkt, $geometry->getWkt()); + self::assertSame($wkt, (string) $geometry); + } + + /** + * @return array + */ + public static function provideValidWkt(): array + { + return [ + 'point' => ['POINT(1 2)', 'POINT', null], + 'point with srid' => ['SRID=4326;POINT(-122.4194 37.7749)', 'POINT', 4326], + 'linestring' => ['LINESTRING(0 0, 1 1, 2 2)', 'LINESTRING', null], + 'polygon' => ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 'POLYGON', null], + ]; + } + + #[DataProvider('provideInvalidWkt')] + #[Test] + public function throws_exception_for_invalid_wkt(string $invalidWkt): void + { + $this->expectException(InvalidGeometryException::class); + Geometry::fromWkt($invalidWkt); + } + + /** + * @return array + */ + public static function provideInvalidWkt(): array + { + return [ + 'empty' => [''], + 'missing semicolon after srid' => ['SRID=4326POINT(1 2)'], + 'invalid srid' => ['SRID=abc;POINT(1 2)'], + 'invalid body' => ['POINT()'], + 'invalid format' => ['INVALID_WKT'], + 'unsupported geometry type' => ['UNSUPPORTED(1 2)'], + ]; + } +} + From 1b9d623e93e164a428be5069801066f00375fe5f Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 20 Aug 2025 00:28:06 +0300 Subject: [PATCH 04/26] add Geometry and Geography types --- .../InvalidGeographyForDatabaseException.php | 30 ++++++ .../InvalidGeographyForPHPException.php | 30 ++++++ .../InvalidGeometryForDatabaseException.php | 30 ++++++ .../InvalidGeometryForPHPException.php | 30 ++++++ .../Doctrine/DBAL/Types/Geography.php | 54 +++++++++++ .../Doctrine/DBAL/Types/Geometry.php | 54 +++++++++++ .../Exceptions/InvalidPointException.php | 2 +- .../InvalidWktSpatialDataException.php | 56 +++++++++++ .../Types/ValueObject/WKTGeometryType.php | 40 -------- .../DBAL/Types/ValueObject/WktSpatialData.php | 93 +++++++++++++++++++ .../Types/ValueObject/WktGeometryTypeTest.php | 71 ++++++++++++++ .../Types/ValueObject/WktSpatialDataTest.php | 63 +++++++++++++ 12 files changed, 512 insertions(+), 41 deletions(-) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForDatabaseException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForPHPException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForDatabaseException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForPHPException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php delete mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForDatabaseException.php new file mode 100644 index 00000000..d3b9eec4 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForDatabaseException.php @@ -0,0 +1,30 @@ + + */ +final class InvalidGeographyForDatabaseException extends ConversionException +{ + private static function create(string $message, mixed $value): self + { + return new self(\sprintf($message, \var_export($value, true))); + } + + public static function forInvalidType(mixed $value): self + { + return self::create('Database value must be a string, %s given', $value); + } + + public static function forInvalidFormat(mixed $value): self + { + return self::create('Invalid geography format in database: %s', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForPHPException.php new file mode 100644 index 00000000..395bb529 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeographyForPHPException.php @@ -0,0 +1,30 @@ + + */ +final class InvalidGeographyForPHPException extends ConversionException +{ + private static function create(string $message, mixed $value): self + { + return new self(\sprintf($message, \var_export($value, true))); + } + + public static function forInvalidType(mixed $value): self + { + return self::create('Value must be a Geography value object, %s given', $value); + } + + public static function forInvalidFormat(mixed $value): self + { + return self::create('Invalid Geography value object format: %s', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForDatabaseException.php new file mode 100644 index 00000000..0c417fc6 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForDatabaseException.php @@ -0,0 +1,30 @@ + + */ +final class InvalidGeometryForDatabaseException extends ConversionException +{ + private static function create(string $message, mixed $value): self + { + return new self(\sprintf($message, \var_export($value, true))); + } + + public static function forInvalidType(mixed $value): self + { + return self::create('Database value must be a string, %s given', $value); + } + + public static function forInvalidFormat(mixed $value): self + { + return self::create('Invalid geometry format in database: %s', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForPHPException.php new file mode 100644 index 00000000..eb81dbdc --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidGeometryForPHPException.php @@ -0,0 +1,30 @@ + + */ +final class InvalidGeometryForPHPException extends ConversionException +{ + private static function create(string $message, mixed $value): self + { + return new self(\sprintf($message, \var_export($value, true))); + } + + public static function forInvalidType(mixed $value): self + { + return self::create('Value must be a Geometry value object, %s given', $value); + } + + public static function forInvalidFormat(mixed $value): self + { + return self::create('Invalid Geometry value object format: %s', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php new file mode 100644 index 00000000..0506371a --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php @@ -0,0 +1,54 @@ + + */ +final class Geography extends BaseType +{ + protected const TYPE_NAME = 'geography'; + + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if (!$value instanceof WktSpatialData) { + throw InvalidGeographyForPHPException::forInvalidType($value); + } + + return (string) $value; + } + + public function convertToPHPValue($value, AbstractPlatform $platform): ?WktSpatialData + { + if ($value === null) { + return null; + } + + if (!\is_string($value)) { + throw InvalidGeographyForDatabaseException::forInvalidType($value); + } + + try { + return WktSpatialData::fromWkt($value); + } catch (InvalidWktSpatialDataException) { + throw InvalidGeographyForDatabaseException::forInvalidFormat($value); + } + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php new file mode 100644 index 00000000..9ba7b69b --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php @@ -0,0 +1,54 @@ + + */ +final class Geometry extends BaseType +{ + protected const TYPE_NAME = 'geometry'; + + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if (!$value instanceof WktSpatialData) { + throw InvalidGeometryForPHPException::forInvalidType($value); + } + + return (string) $value; + } + + public function convertToPHPValue($value, AbstractPlatform $platform): ?WktSpatialData + { + if ($value === null) { + return null; + } + + if (!\is_string($value)) { + throw InvalidGeometryForDatabaseException::forInvalidType($value); + } + + try { + return WktSpatialData::fromWkt($value); + } catch (InvalidWktSpatialDataException) { + throw InvalidGeometryForDatabaseException::forInvalidFormat($value); + } + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php index 99f15a5b..3a6b2d9c 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidPointException.php @@ -6,7 +6,7 @@ /** * Exception thrown when creating or manipulating Point value objects with invalid data. - * + * * This exception is specifically for validation errors within the Point value object itself, * separate from DBAL conversion exceptions. * diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php new file mode 100644 index 00000000..45816068 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php @@ -0,0 +1,56 @@ + + */ +final class InvalidWktSpatialDataException extends \InvalidArgumentException +{ + public static function forEmptyWkt(): self + { + return new self('Empty Wkt string provided'); + } + + public static function forMissingSemicolonInEwkt(): self + { + return new self('Invalid Ewkt: missing semicolon after Srid prefix'); + } + + public static function forInvalidSridValue(mixed $sridValue): self + { + return new self(\sprintf('Invalid Srid value in Ewkt: %s', \var_export($sridValue, true))); + } + + public static function forInvalidWktFormat(string $wkt): self + { + return new self(\sprintf('Invalid Wkt format: %s', \var_export($wkt, true))); + } + + public static function forEmptyCoordinateSection(): self + { + return new self('Invalid Wkt: empty coordinate/body section'); + } + + public static function forUnsupportedGeometryType(string $type): self + { + $supportedTypes = \array_map(static fn (WktGeometryType $wktGeometryType) => $wktGeometryType->value, WktGeometryType::cases()); + + return new self(\sprintf( + 'Unsupported Wkt geometry type: %s. Supported types: %s', + \var_export($type, true), + \implode(', ', $supportedTypes) + )); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php deleted file mode 100644 index cdcb3d23..00000000 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WKTGeometryType.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ -enum WktGeometryType: string -{ - // Basic geometry types - case POINT = 'POINT'; - case LINESTRING = 'LINESTRING'; - case POLYGON = 'POLYGON'; - - // Multi-geometry types - case MULTIPOINT = 'MULTIPOINT'; - case MULTILINESTRING = 'MULTILINESTRING'; - case MULTIPOLYGON = 'MULTIPOLYGON'; - - // Collection types - case GEOMETRYCOLLECTION = 'GEOMETRYCOLLECTION'; - - // Circular geometry types (PostGIS extensions) - case CIRCULARSTRING = 'CIRCULARSTRING'; - case COMPOUNDCURVE = 'COMPOUNDCURVE'; - case CURVEPOLYGON = 'CURVEPOLYGON'; - case MULTICURVE = 'MULTICURVE'; - case MULTISURFACE = 'MULTISURFACE'; - - // Triangle and TIN types - case TRIANGLE = 'TRIANGLE'; - case TIN = 'TIN'; - case POLYHEDRALSURFACE = 'POLYHEDRALSURFACE'; -} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php new file mode 100644 index 00000000..ad8f0088 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php @@ -0,0 +1,93 @@ + + */ +final class WktSpatialData implements \Stringable +{ + private function __construct(private readonly ?int $srid, private readonly WktGeometryType $wktType, private readonly string $wktBody) {} + + public function __toString(): string + { + $typeAndBody = $this->wktType->value.'('.$this->wktBody.')'; + if ($this->srid === null) { + return $typeAndBody; + } + + return 'SRID='.$this->srid.';'.$typeAndBody; + } + + public static function fromWkt(string $wkt): self + { + $wkt = \trim($wkt); + if ($wkt === '') { + throw InvalidWktSpatialDataException::forEmptyWkt(); + } + + $srid = null; + $expectSrid = \str_starts_with($wkt, 'SRID='); + if ($expectSrid) { + $sridSeparatorPosition = \strpos($wkt, ';'); + if ($sridSeparatorPosition === false) { + throw InvalidWktSpatialDataException::forMissingSemicolonInEwkt(); + } + + $sridRawValue = \substr($wkt, 5, $sridSeparatorPosition - 5); + if ($sridRawValue === '' || !\ctype_digit($sridRawValue)) { + throw InvalidWktSpatialDataException::forInvalidSridValue($sridRawValue); + } + + $srid = (int) $sridRawValue; + $wkt = \substr($wkt, $sridSeparatorPosition + 1); + } + + $wktTypeWithOptionalModifiersPattern = '/^([A-Z][A-Z0-9_]*)(?:\s+(?:ZM|Z|M))?\s*\((.*)\)$/s'; + if (!\preg_match($wktTypeWithOptionalModifiersPattern, $wkt, $matches)) { + throw InvalidWktSpatialDataException::forInvalidWktFormat($wkt); + } + + $typeString = $matches[1]; + $body = $matches[2]; + if ($body === '') { + throw InvalidWktSpatialDataException::forEmptyCoordinateSection(); + } + + $geometryType = WktGeometryType::tryFrom($typeString); + if ($geometryType === null) { + throw InvalidWktSpatialDataException::forUnsupportedGeometryType($typeString); + } + + return new self($srid, $geometryType, $body); + } + + public function getSrid(): ?int + { + return $this->srid; + } + + public function getGeometryType(): WktGeometryType + { + return $this->wktType; + } + + public function getWkt(): string + { + return (string) $this; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php new file mode 100644 index 00000000..4dd8ef4f --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php @@ -0,0 +1,71 @@ +value); + } + + #[Test] + public function throws_exception_for_invalid_type(): void + { + $this->expectException(\ValueError::class); + + WktGeometryType::from('INVALID'); + } + + #[DataProvider('provideValidGeometryTypes')] + #[Test] + public function returns_enum_for_valid_types(string $typeString, WktGeometryType $wktGeometryType): void + { + $result = WktGeometryType::tryFrom($typeString); + + self::assertSame($wktGeometryType, $result); + } + + /** + * @return array + */ + public static function provideValidGeometryTypes(): array + { + return [ + 'point' => ['POINT', WktGeometryType::POINT], + 'linestring' => ['LINESTRING', WktGeometryType::LINESTRING], + 'polygon' => ['POLYGON', WktGeometryType::POLYGON], + 'multipoint' => ['MULTIPOINT', WktGeometryType::MULTIPOINT], + 'multilinestring' => ['MULTILINESTRING', WktGeometryType::MULTILINESTRING], + 'multipolygon' => ['MULTIPOLYGON', WktGeometryType::MULTIPOLYGON], + 'geometrycollection' => ['GEOMETRYCOLLECTION', WktGeometryType::GEOMETRYCOLLECTION], + 'circularstring' => ['CIRCULARSTRING', WktGeometryType::CIRCULARSTRING], + 'compoundcurve' => ['COMPOUNDCURVE', WktGeometryType::COMPOUNDCURVE], + 'curvepolygon' => ['CURVEPOLYGON', WktGeometryType::CURVEPOLYGON], + 'multicurve' => ['MULTICURVE', WktGeometryType::MULTICURVE], + 'multisurface' => ['MULTISURFACE', WktGeometryType::MULTISURFACE], + 'triangle' => ['TRIANGLE', WktGeometryType::TRIANGLE], + 'tin' => ['TIN', WktGeometryType::TIN], + 'polyhedralsurface' => ['POLYHEDRALSURFACE', WktGeometryType::POLYHEDRALSURFACE], + ]; + } + + #[Test] + public function returns_null_for_invalid_types(): void + { + self::assertNull(WktGeometryType::tryFrom('INVALID_TYPE')); + self::assertNull(WktGeometryType::tryFrom('')); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php new file mode 100644 index 00000000..0ae99150 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php @@ -0,0 +1,63 @@ +getGeometryType()); + self::assertSame($expectedSrid, $wktSpatialData->getSrid()); + self::assertSame($wkt, $wktSpatialData->getWkt()); + self::assertSame($wkt, (string) $wktSpatialData); + } + + /** + * @return array + */ + public static function provideValidWkt(): array + { + return [ + 'point' => ['POINT(1 2)', 'POINT', null], + 'point with srid' => ['SRID=4326;POINT(-122.4194 37.7749)', 'POINT', 4326], + 'linestring' => ['LINESTRING(0 0, 1 1, 2 2)', 'LINESTRING', null], + 'polygon' => ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 'POLYGON', null], + ]; + } + + #[DataProvider('provideInvalidWkt')] + #[Test] + public function throws_exception_for_invalid_wkt(string $invalidWkt): void + { + $this->expectException(InvalidWktSpatialDataException::class); + WktSpatialData::fromWkt($invalidWkt); + } + + /** + * @return array + */ + public static function provideInvalidWkt(): array + { + return [ + 'empty' => [''], + 'missing semicolon after srid' => ['SRID=4326POINT(1 2)'], + 'invalid srid' => ['SRID=abc;POINT(1 2)'], + 'invalid body' => ['POINT()'], + 'invalid format' => ['INVALID_WKT'], + 'unsupported geometry type' => ['UNSUPPORTED(1 2)'], + ]; + } +} From d0fb182baddf145a0e572d0fbb295d2673390ff3 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 20 Aug 2025 00:40:17 +0300 Subject: [PATCH 05/26] add tests and minor names cleanup --- .../Exceptions/InvalidGeometryException.php | 56 -------- .../DBAL/Types/ValueObject/Geometry.php | 103 -------------- .../Types/ValueObject/WktGeometryType.php | 40 ++++++ .../Doctrine/DBAL/Types/GeographyTypeTest.php | 48 +++++++ .../Doctrine/DBAL/Types/GeometryTypeTest.php | 49 +++++++ .../Doctrine/DBAL/Types/TestCase.php | 12 +- .../Doctrine/DBAL/Types/GeographyTest.php | 119 ++++++++++++++++ .../Doctrine/DBAL/Types/GeometryTest.php | 131 ++++++++++++++++++ .../DBAL/Types/ValueObject/GeometryTest.php | 64 --------- 9 files changed, 398 insertions(+), 224 deletions(-) delete mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php delete mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php delete mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php deleted file mode 100644 index d78ceb73..00000000 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidGeometryException.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ -final class InvalidGeometryException extends \InvalidArgumentException -{ - public static function forEmptyWkt(): self - { - return new self('Empty Wkt string provided'); - } - - public static function forMissingSemicolonInEwkt(): self - { - return new self('Invalid Ewkt: missing semicolon after Srid prefix'); - } - - public static function forInvalidSridValue(mixed $sridValue): self - { - return new self(\sprintf('Invalid Srid value in Ewkt: %s', \var_export($sridValue, true))); - } - - public static function forInvalidWktFormat(string $wkt): self - { - return new self(\sprintf('Invalid Wkt format: %s', \var_export($wkt, true))); - } - - public static function forEmptyCoordinateSection(): self - { - return new self('Invalid Wkt: empty coordinate/body section'); - } - - public static function forUnsupportedGeometryType(string $type): self - { - $supportedTypes = \array_map(fn(WktGeometryType $case) => $case->value, WktGeometryType::cases()); - - return new self(\sprintf( - 'Unsupported Wkt geometry type: %s. Supported types: %s', - \var_export($type, true), - \implode(', ', $supportedTypes) - )); - } -} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php deleted file mode 100644 index dd0ff4a1..00000000 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Geometry.php +++ /dev/null @@ -1,103 +0,0 @@ - - */ -final class Geometry implements \Stringable -{ - private ?int $srid; - - private WktGeometryType $wktType; - - private string $wktBody; - - private function __construct(?int $srid, WktGeometryType $wktType, string $wktBody) - { - $this->srid = $srid; - $this->wktType = $wktType; - $this->wktBody = $wktBody; - } - - public static function fromWkt(string $wkt): self - { - $wkt = trim($wkt); - if ($wkt === '') { - throw InvalidGeometryException::forEmptyWkt(); - } - - $srid = null; - $expectSrid = str_starts_with($wkt, 'SRID='); - if ($expectSrid) { - $sridSeparatorPosition = strpos($wkt, ';'); - if ($sridSeparatorPosition === false) { - throw InvalidGeometryException::forMissingSemicolonInEwkt(); - } - $sridRawValue = substr($wkt, 5, $sridSeparatorPosition - 5); - if ($sridRawValue === '' || !ctype_digit($sridRawValue)) { - throw InvalidGeometryException::forInvalidSridValue($sridRawValue); - } - $srid = (int) $sridRawValue; - $wkt = substr($wkt, $sridSeparatorPosition + 1); - } - - $wktTypeWithOptionalModifiersPattern = '/^([A-Z][A-Z0-9_]*)(?:\s+(?:ZM|Z|M))?\s*\((.*)\)$/s'; - if (!preg_match($wktTypeWithOptionalModifiersPattern, $wkt, $matches)) { - throw InvalidGeometryException::forInvalidWktFormat($wkt); - } - - $typeString = $matches[1]; - $body = $matches[2]; - if ($body === '') { - throw InvalidGeometryException::forEmptyCoordinateSection(); - } - - $geometryType = WktGeometryType::tryFrom($typeString); - if ($geometryType === null) { - throw InvalidGeometryException::forUnsupportedGeometryType($typeString); - } - - return new self($srid, $geometryType, $body); - } - - public function __toString(): string - { - $typeAndBody = $this->wktType->value.'('.$this->wktBody.')'; - if ($this->srid === null) { - return $typeAndBody; - } - - return 'SRID='.$this->srid.';'.$typeAndBody; - } - - public function getSrid(): ?int - { - return $this->srid; - } - - public function getGeometryType(): WktGeometryType - { - return $this->wktType; - } - - public function getWkt(): string - { - return (string) $this; - } -} - diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php new file mode 100644 index 00000000..cf40444b --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php @@ -0,0 +1,40 @@ + + */ +enum WktGeometryType: string +{ + // Basic geometry types + case POINT = 'POINT'; + case LINESTRING = 'LINESTRING'; + case POLYGON = 'POLYGON'; + + // Multi-geometry types + case MULTIPOINT = 'MULTIPOINT'; + case MULTILINESTRING = 'MULTILINESTRING'; + case MULTIPOLYGON = 'MULTIPOLYGON'; + + // Collection types + case GEOMETRYCOLLECTION = 'GEOMETRYCOLLECTION'; + + // Circular geometry types (PostGIS extensions) + case CIRCULARSTRING = 'CIRCULARSTRING'; + case COMPOUNDCURVE = 'COMPOUNDCURVE'; + case CURVEPOLYGON = 'CURVEPOLYGON'; + case MULTICURVE = 'MULTICURVE'; + case MULTISURFACE = 'MULTISURFACE'; + + // Triangle and TIN types + case TRIANGLE = 'TRIANGLE'; + case TIN = 'TIN'; + case POLYHEDRALSURFACE = 'POLYHEDRALSURFACE'; +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php new file mode 100644 index 00000000..00d7544e --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -0,0 +1,48 @@ +runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_handle_geography_values(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + 'point' => ['point', WktSpatialData::fromWkt('POINT(1 2)')], + 'linestring' => ['linestring', WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)')], + 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')], + 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))')], + ]; + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php new file mode 100644 index 00000000..cc7741a1 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -0,0 +1,49 @@ +runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_handle_geometry_values(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + 'point' => ['point', WktSpatialData::fromWkt('POINT(1 2)')], + 'linestring' => ['linestring', WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)')], + 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')], + 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))')], + 'point with srid' => ['point with srid', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)')], + ]; + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php index 158dd006..5fc822f2 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php @@ -48,7 +48,7 @@ protected function runTypeTest(string $typeName, string $columnType, mixed $test // Query the value back $queryBuilder = $this->connection->createQueryBuilder(); $queryBuilder - ->select($columnName) + ->select($this->getSelectExpressionForType($typeName, $columnName)) ->from(self::DATABASE_SCHEMA.'.'.$tableName) ->where('id = 1'); @@ -96,4 +96,14 @@ public function type_will_be_registered(): void Type::getType($typeName); } + + private function getSelectExpressionForType(string $typeName, string $columnName): string + { + // Ensure we get a text representation for PostGIS types that DBAL might map to resource/stream + return match ($typeName) { + 'geometry' => \sprintf('ST_AsEWKT("%s") AS "%s"', $columnName, $columnName), + 'geography' => \sprintf('ST_AsEWKT("%s"::geometry) AS "%s"', $columnName, $columnName), + default => $columnName, + }; + } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php new file mode 100644 index 00000000..2c887bc1 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php @@ -0,0 +1,119 @@ +platform = $this->createMock(AbstractPlatform::class); + $this->fixture = new Geography(); + } + + #[Test] + public function has_name(): void + { + self::assertEquals('geography', $this->fixture->getName()); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_from_php_value(?WktSpatialData $wktSpatialData, ?string $postgresValue): void + { + self::assertEquals($postgresValue, $this->fixture->convertToDatabaseValue($wktSpatialData, $this->platform)); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_to_php_value(?WktSpatialData $wktSpatialData, ?string $postgresValue): void + { + $result = $this->fixture->convertToPHPValue($postgresValue, $this->platform); + if (!$wktSpatialData instanceof WktSpatialData) { + self::assertNull($result); + + return; + } + + self::assertInstanceOf(WktSpatialData::class, $result); + self::assertEquals((string) $wktSpatialData, (string) $result); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + 'null' => [ + 'valueObject' => null, + 'postgresValue' => null, + ], + 'point' => [ + 'valueObject' => WktSpatialData::fromWkt('POINT(1 2)'), + 'postgresValue' => 'POINT(1 2)', + ], + ]; + } + + #[DataProvider('provideInvalidDatabaseValueInputs')] + #[Test] + public function throws_exception_for_invalid_database_value_inputs(mixed $phpValue): void + { + $this->expectException(InvalidGeographyForPHPException::class); + $this->fixture->convertToDatabaseValue($phpValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidDatabaseValueInputs(): array + { + return [ + 'random string' => ['foo'], + 'integer input' => [123], + 'array input' => [['not', 'geography']], + 'boolean input' => [true], + 'object input' => [new \stdClass()], + ]; + } + + #[DataProvider('provideInvalidPHPValueInputs')] + #[Test] + public function throws_exception_for_invalid_php_value_inputs(mixed $postgresValue): void + { + $this->expectException(InvalidGeographyForDatabaseException::class); + $this->fixture->convertToPHPValue($postgresValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidPHPValueInputs(): array + { + return [ + 'empty string' => [''], + 'invalid wkt' => ['INVALID_WKT'], + 'missing coordinates' => ['POINT()'], + 'not a string' => [123], + ]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php new file mode 100644 index 00000000..4e672923 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php @@ -0,0 +1,131 @@ +platform = $this->createMock(AbstractPlatform::class); + $this->fixture = new Geometry(); + } + + #[Test] + public function has_name(): void + { + self::assertEquals('geometry', $this->fixture->getName()); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_from_php_value(?WktSpatialData $wktSpatialData, ?string $postgresValue): void + { + self::assertEquals($postgresValue, $this->fixture->convertToDatabaseValue($wktSpatialData, $this->platform)); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_to_php_value(?WktSpatialData $wktSpatialData, ?string $postgresValue): void + { + $result = $this->fixture->convertToPHPValue($postgresValue, $this->platform); + if (!$wktSpatialData instanceof WktSpatialData) { + self::assertNull($result); + + return; + } + + self::assertInstanceOf(WktSpatialData::class, $result); + self::assertEquals((string) $wktSpatialData, (string) $result); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + 'null' => [ + 'valueObject' => null, + 'postgresValue' => null, + ], + 'point' => [ + 'valueObject' => WktSpatialData::fromWkt('POINT(1 2)'), + 'postgresValue' => 'POINT(1 2)', + ], + 'point with srid' => [ + 'valueObject' => WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + 'postgresValue' => 'SRID=4326;POINT(-122.4194 37.7749)', + ], + 'linestring' => [ + 'valueObject' => WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)'), + 'postgresValue' => 'LINESTRING(0 0, 1 1, 2 2)', + ], + 'polygon' => [ + 'valueObject' => WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), + 'postgresValue' => 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', + ], + ]; + } + + #[DataProvider('provideInvalidDatabaseValueInputs')] + #[Test] + public function throws_exception_for_invalid_database_value_inputs(mixed $phpValue): void + { + $this->expectException(InvalidGeometryForPHPException::class); + $this->fixture->convertToDatabaseValue($phpValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidDatabaseValueInputs(): array + { + return [ + 'random string' => ['foo'], + 'integer input' => [123], + 'array input' => [['not', 'geometry']], + 'boolean input' => [true], + 'object input' => [new \stdClass()], + ]; + } + + #[DataProvider('provideInvalidPHPValueInputs')] + #[Test] + public function throws_exception_for_invalid_php_value_inputs(mixed $postgresValue): void + { + $this->expectException(InvalidGeometryForDatabaseException::class); + $this->fixture->convertToPHPValue($postgresValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidPHPValueInputs(): array + { + return [ + 'empty string' => [''], + 'invalid wkt' => ['INVALID_WKT'], + 'missing coordinates' => ['POINT()'], + 'not a string' => [123], + ]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php deleted file mode 100644 index 1234727e..00000000 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTest.php +++ /dev/null @@ -1,64 +0,0 @@ -getGeometryType()); - self::assertSame($expectedSrid, $geometry->getSrid()); - self::assertSame($wkt, $geometry->getWkt()); - self::assertSame($wkt, (string) $geometry); - } - - /** - * @return array - */ - public static function provideValidWkt(): array - { - return [ - 'point' => ['POINT(1 2)', 'POINT', null], - 'point with srid' => ['SRID=4326;POINT(-122.4194 37.7749)', 'POINT', 4326], - 'linestring' => ['LINESTRING(0 0, 1 1, 2 2)', 'LINESTRING', null], - 'polygon' => ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 'POLYGON', null], - ]; - } - - #[DataProvider('provideInvalidWkt')] - #[Test] - public function throws_exception_for_invalid_wkt(string $invalidWkt): void - { - $this->expectException(InvalidGeometryException::class); - Geometry::fromWkt($invalidWkt); - } - - /** - * @return array - */ - public static function provideInvalidWkt(): array - { - return [ - 'empty' => [''], - 'missing semicolon after srid' => ['SRID=4326POINT(1 2)'], - 'invalid srid' => ['SRID=abc;POINT(1 2)'], - 'invalid body' => ['POINT()'], - 'invalid format' => ['INVALID_WKT'], - 'unsupported geometry type' => ['UNSUPPORTED(1 2)'], - ]; - } -} - From 90f6509ef3d72124a58782c2abcdb3139d5d0b7a Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 20 Aug 2025 00:49:10 +0300 Subject: [PATCH 06/26] test setup improvements --- tests/Integration/MartinGeorgiev/TestCase.php | 21 ++++++++++++++++++- .../Doctrine/DBAL/Types/GeographyTest.php | 6 +++--- .../Doctrine/DBAL/Types/GeometryTest.php | 12 +++++------ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/tests/Integration/MartinGeorgiev/TestCase.php b/tests/Integration/MartinGeorgiev/TestCase.php index c70253c9..d22801a4 100644 --- a/tests/Integration/MartinGeorgiev/TestCase.php +++ b/tests/Integration/MartinGeorgiev/TestCase.php @@ -19,6 +19,8 @@ use MartinGeorgiev\Doctrine\DBAL\Types\CidrArray; use MartinGeorgiev\Doctrine\DBAL\Types\DateRange; use MartinGeorgiev\Doctrine\DBAL\Types\DoublePrecisionArray; +use MartinGeorgiev\Doctrine\DBAL\Types\Geography; +use MartinGeorgiev\Doctrine\DBAL\Types\Geometry; use MartinGeorgiev\Doctrine\DBAL\Types\Inet; use MartinGeorgiev\Doctrine\DBAL\Types\InetArray; use MartinGeorgiev\Doctrine\DBAL\Types\Int4Range; @@ -156,7 +158,22 @@ protected function createTestSchema(): void { $this->connection->executeStatement(\sprintf('DROP SCHEMA IF EXISTS %s CASCADE', self::DATABASE_SCHEMA)); $this->connection->executeStatement(\sprintf('CREATE SCHEMA %s', self::DATABASE_SCHEMA)); - $this->connection->executeStatement(\sprintf('SET search_path TO %s', self::DATABASE_SCHEMA)); + + // Ensure PostGIS is available for geometry/geography types + // Ensure PostGIS is available in the test schema and as default search_path + try { + // Ensure PostGIS is installed and, if possible, placed in the test schema + $this->connection->executeStatement('CREATE EXTENSION IF NOT EXISTS postgis'); + // Move the extension objects into the test schema to resolve types without relying on public + $this->connection->executeStatement(\sprintf('ALTER EXTENSION postgis SET SCHEMA %s', self::DATABASE_SCHEMA)); + } catch (\Throwable) { + // Fallback: if moving the extension is not possible, keep public in the search_path below + } + + // Ensure our schema is first, but include public so extensions installed there resolve + $this->connection->executeStatement(\sprintf('SET search_path TO %s, public', self::DATABASE_SCHEMA)); + // Stabilize timezone-dependent tests + $this->connection->executeStatement("SET TIME ZONE 'UTC'"); } protected function registerCustomTypes(): void @@ -168,6 +185,8 @@ protected function registerCustomTypes(): void 'cidr[]' => CidrArray::class, 'daterange' => DateRange::class, 'double precision[]' => DoublePrecisionArray::class, + 'geography' => Geography::class, + 'geometry' => Geometry::class, 'inet' => Inet::class, 'inet[]' => InetArray::class, 'int4range' => Int4Range::class, diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php index 2c887bc1..122f07f7 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php @@ -58,17 +58,17 @@ public function can_transform_to_php_value(?WktSpatialData $wktSpatialData, ?str } /** - * @return array + * @return array */ public static function provideValidTransformations(): array { return [ 'null' => [ - 'valueObject' => null, + 'wktSpatialData' => null, 'postgresValue' => null, ], 'point' => [ - 'valueObject' => WktSpatialData::fromWkt('POINT(1 2)'), + 'wktSpatialData' => WktSpatialData::fromWkt('POINT(1 2)'), 'postgresValue' => 'POINT(1 2)', ], ]; diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php index 4e672923..05765c66 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php @@ -58,29 +58,29 @@ public function can_transform_to_php_value(?WktSpatialData $wktSpatialData, ?str } /** - * @return array + * @return array */ public static function provideValidTransformations(): array { return [ 'null' => [ - 'valueObject' => null, + 'wktSpatialData' => null, 'postgresValue' => null, ], 'point' => [ - 'valueObject' => WktSpatialData::fromWkt('POINT(1 2)'), + 'wktSpatialData' => WktSpatialData::fromWkt('POINT(1 2)'), 'postgresValue' => 'POINT(1 2)', ], 'point with srid' => [ - 'valueObject' => WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), 'postgresValue' => 'SRID=4326;POINT(-122.4194 37.7749)', ], 'linestring' => [ - 'valueObject' => WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)'), + 'wktSpatialData' => WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)'), 'postgresValue' => 'LINESTRING(0 0, 1 1, 2 2)', ], 'polygon' => [ - 'valueObject' => WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), + 'wktSpatialData' => WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), 'postgresValue' => 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', ], ]; From b1eeaf4f9d15aa8d6ce83b55f9dec1416435ca94 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 20 Aug 2025 01:06:56 +0300 Subject: [PATCH 07/26] dimensional modifier preservation --- .../DBAL/Types/ValueObject/WktSpatialData.php | 21 +++++++--- .../Doctrine/DBAL/Types/GeographyTypeTest.php | 3 ++ .../Doctrine/DBAL/Types/GeometryTypeTest.php | 4 ++ .../Doctrine/DBAL/Types/GeographyTest.php | 12 ++++++ .../Doctrine/DBAL/Types/GeometryTest.php | 16 ++++++++ .../Types/ValueObject/WktSpatialDataTest.php | 41 +++++++++++++++++++ 6 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php index ad8f0088..b538ace1 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php @@ -21,11 +21,21 @@ */ final class WktSpatialData implements \Stringable { - private function __construct(private readonly ?int $srid, private readonly WktGeometryType $wktType, private readonly string $wktBody) {} + private function __construct( + private readonly ?int $srid, + private readonly WktGeometryType $wktType, + private readonly string $wktBody, + private readonly ?string $dimensionalModifier = null + ) {} public function __toString(): string { - $typeAndBody = $this->wktType->value.'('.$this->wktBody.')'; + $typeWithModifier = $this->wktType->value; + if ($this->dimensionalModifier !== null) { + $typeWithModifier .= ' '.$this->dimensionalModifier; + } + + $typeAndBody = $typeWithModifier.'('.$this->wktBody.')'; if ($this->srid === null) { return $typeAndBody; } @@ -57,13 +67,14 @@ public static function fromWkt(string $wkt): self $wkt = \substr($wkt, $sridSeparatorPosition + 1); } - $wktTypeWithOptionalModifiersPattern = '/^([A-Z][A-Z0-9_]*)(?:\s+(?:ZM|Z|M))?\s*\((.*)\)$/s'; + $wktTypeWithOptionalModifiersPattern = '/^([A-Z][A-Z0-9_]*)(?:\s+(ZM|Z|M))?\s*\((.*)\)$/s'; if (!\preg_match($wktTypeWithOptionalModifiersPattern, $wkt, $matches)) { throw InvalidWktSpatialDataException::forInvalidWktFormat($wkt); } $typeString = $matches[1]; - $body = $matches[2]; + $dimensionalModifier = empty($matches[2]) ? null : $matches[2]; + $body = \trim($matches[3]); if ($body === '') { throw InvalidWktSpatialDataException::forEmptyCoordinateSection(); } @@ -73,7 +84,7 @@ public static function fromWkt(string $wkt): self throw InvalidWktSpatialDataException::forUnsupportedGeometryType($typeString); } - return new self($srid, $geometryType, $body); + return new self($srid, $geometryType, $body, $dimensionalModifier); } public function getSrid(): ?int diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php index 00d7544e..bb27fd68 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -43,6 +43,9 @@ public static function provideValidTransformations(): array 'linestring' => ['linestring', WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)')], 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')], 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))')], + 'point z' => ['point z', WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)')], + 'linestring m' => ['linestring m', WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)')], + 'polygon zm' => ['polygon zm', WktSpatialData::fromWkt('POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))')], ]; } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php index cc7741a1..d05a136a 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -44,6 +44,10 @@ public static function provideValidTransformations(): array 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')], 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))')], 'point with srid' => ['point with srid', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)')], + 'point z' => ['point z', WktSpatialData::fromWkt('POINT Z(1 2 3)')], + 'linestring m' => ['linestring m', WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)')], + 'polygon zm' => ['polygon zm', WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))')], + 'point z with srid' => ['point z with srid', WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)')], ]; } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php index 122f07f7..99864a70 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php @@ -71,6 +71,18 @@ public static function provideValidTransformations(): array 'wktSpatialData' => WktSpatialData::fromWkt('POINT(1 2)'), 'postgresValue' => 'POINT(1 2)', ], + 'point z' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), + 'postgresValue' => 'POINT Z(-122.4194 37.7749 100)', + ], + 'linestring m' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), + 'postgresValue' => 'LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)', + ], + 'polygon zm with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), + 'postgresValue' => 'SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))', + ], ]; } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php index 05765c66..bec89d9d 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php @@ -83,6 +83,22 @@ public static function provideValidTransformations(): array 'wktSpatialData' => WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), 'postgresValue' => 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', ], + 'point z' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('POINT Z(1 2 3)'), + 'postgresValue' => 'POINT Z(1 2 3)', + ], + 'linestring m' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), + 'postgresValue' => 'LINESTRING M(0 0 1, 1 1 2)', + ], + 'polygon zm' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'), + 'postgresValue' => 'POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))', + ], + 'point z with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + 'postgresValue' => 'SRID=4326;POINT Z(-122.4194 37.7749 100)', + ], ]; } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php index 0ae99150..547a9fea 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php @@ -35,6 +35,12 @@ public static function provideValidWkt(): array 'point with srid' => ['SRID=4326;POINT(-122.4194 37.7749)', 'POINT', 4326], 'linestring' => ['LINESTRING(0 0, 1 1, 2 2)', 'LINESTRING', null], 'polygon' => ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 'POLYGON', null], + 'point z' => ['POINT Z(1 2 3)', 'POINT', null], + 'linestring m' => ['LINESTRING M(0 0 1, 1 1 2)', 'LINESTRING', null], + 'polygon zm' => ['POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))', 'POLYGON', null], + 'point z with srid' => ['SRID=4326;POINT Z(-122.4194 37.7749 100)', 'POINT', 4326], + 'multipoint z' => ['MULTIPOINT Z((1 2 3), (4 5 6))', 'MULTIPOINT', null], + 'geometrycollection m' => ['GEOMETRYCOLLECTION M(POINT M(1 2 3), LINESTRING M(0 0 1, 1 1 2))', 'GEOMETRYCOLLECTION', null], ]; } @@ -58,6 +64,41 @@ public static function provideInvalidWkt(): array 'invalid body' => ['POINT()'], 'invalid format' => ['INVALID_WKT'], 'unsupported geometry type' => ['UNSUPPORTED(1 2)'], + 'whitespace-only coordinates' => ['POINT( )'], + ]; + } + + #[DataProvider('provideDimensionalModifierRoundTripCases')] + #[Test] + public function preserves_dimensional_modifiers_in_round_trip(string $wkt): void + { + $wktSpatialData = WktSpatialData::fromWkt($wkt); + $output = (string) $wktSpatialData; + + self::assertSame($wkt, $output, 'Dimensional modifier should be preserved in round-trip conversion'); + } + + /** + * @return array + */ + public static function provideDimensionalModifierRoundTripCases(): array + { + return [ + 'point z' => ['POINT Z(1 2 3)'], + 'point m' => ['POINT M(1 2 3)'], + 'point zm' => ['POINT ZM(1 2 3 4)'], + 'linestring z' => ['LINESTRING Z(0 0 1, 1 1 2)'], + 'linestring m' => ['LINESTRING M(0 0 1, 1 1 2)'], + 'linestring zm' => ['LINESTRING ZM(0 0 1 2, 1 1 3 4)'], + 'polygon z' => ['POLYGON Z((0 0 1, 0 1 1, 1 1 1, 1 0 1, 0 0 1))'], + 'polygon m' => ['POLYGON M((0 0 1, 0 1 2, 1 1 3, 1 0 4, 0 0 1))'], + 'polygon zm' => ['POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'], + 'multipoint z' => ['MULTIPOINT Z((1 2 3), (4 5 6))'], + 'multilinestring m' => ['MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))'], + 'multipolygon zm' => ['MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'], + 'geometrycollection z' => ['GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))'], + 'srid with point z' => ['SRID=4326;POINT Z(-122.4194 37.7749 100)'], + 'srid with polygon zm' => ['SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'], ]; } } From 8787b19c923a3cf75ab197116788d46b93aa71b6 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 20 Aug 2025 01:07:35 +0300 Subject: [PATCH 08/26] devenv AI improvement --- devenv.nix | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/devenv.nix b/devenv.nix index b336842d..1d8e2a3e 100644 --- a/devenv.nix +++ b/devenv.nix @@ -74,8 +74,19 @@ in ]; initialScript = '' - CREATE ROLE "${config.env.POSTGRES_USER}" - WITH SUPERUSER LOGIN PASSWORD '${config.env.POSTGRES_PASSWORD}'; + -- Create role if it doesn't exist, or update password if it does + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${config.env.POSTGRES_USER}') THEN + CREATE ROLE "${config.env.POSTGRES_USER}" WITH SUPERUSER LOGIN PASSWORD '${config.env.POSTGRES_PASSWORD}'; + ELSE + ALTER ROLE "${config.env.POSTGRES_USER}" WITH SUPERUSER LOGIN PASSWORD '${config.env.POSTGRES_PASSWORD}'; + END IF; + END + $$; + + -- Set database owner + ALTER DATABASE "${config.env.POSTGRES_DB}" OWNER TO "${config.env.POSTGRES_USER}"; -- Enable PostGIS extension in the database \c ${config.env.POSTGRES_DB} From 1679b8b77b0e7f8fb6a359b827427ee5525d6811 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 20 Aug 2025 11:43:48 +0300 Subject: [PATCH 09/26] add support for DBAL types of arrays of Geometry and Geography --- .../Doctrine/DBAL/Types/GeographyArray.php | 37 ++++ .../Doctrine/DBAL/Types/GeometryArray.php | 37 ++++ .../Doctrine/DBAL/Types/SpatialDataArray.php | 126 +++++++++++ .../DBAL/Types/GeographyArrayTypeTest.php | 90 ++++++++ .../DBAL/Types/GeometryArrayTypeTest.php | 83 +++++++ tests/Integration/MartinGeorgiev/TestCase.php | 4 + .../DBAL/Types/GeographyArrayTest.php | 204 ++++++++++++++++++ .../Doctrine/DBAL/Types/GeometryArrayTest.php | 195 +++++++++++++++++ 8 files changed, 776 insertions(+) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php new file mode 100644 index 00000000..46364349 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php @@ -0,0 +1,37 @@ +isValidArrayItemForDatabase($item)) { + throw InvalidGeographyForPHPException::forInvalidType($item); + } + + return (string) $item; + } + + protected function createInvalidTypeExceptionForPHP(mixed $item): ConversionException + { + return InvalidGeographyForPHPException::forInvalidType($item); + } + + protected function createInvalidFormatExceptionForPHP(mixed $item): ConversionException + { + return InvalidGeographyForPHPException::forInvalidFormat($item); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php new file mode 100644 index 00000000..b7e0f156 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php @@ -0,0 +1,37 @@ +isValidArrayItemForDatabase($item)) { + throw InvalidGeometryForPHPException::forInvalidType($item); + } + + return (string) $item; + } + + protected function createInvalidTypeExceptionForPHP(mixed $item): ConversionException + { + return InvalidGeometryForPHPException::forInvalidType($item); + } + + protected function createInvalidFormatExceptionForPHP(mixed $item): ConversionException + { + return InvalidGeometryForPHPException::forInvalidFormat($item); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php new file mode 100644 index 00000000..c32a44f2 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php @@ -0,0 +1,126 @@ + ['POINT(1 2)', 'LINESTRING(0 0, 1 1)'] + * - '{POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))}' -> ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'] + * - '{}' -> [] + */ + protected function transformPostgresArrayToPHPArray(string $postgresArray): array + { + $trimmedArray = \trim($postgresArray); + if ($trimmedArray === '{}' || $trimmedArray === '') { + return []; + } + + $arrayContentWithoutBraces = \substr($trimmedArray, 1, -1); + if ($arrayContentWithoutBraces === '') { + return []; + } + + $wktItems = []; + $nestedBracketDepth = 0; + $currentWktItem = ''; + $contentLength = \strlen($arrayContentWithoutBraces); + + for ($charIndex = 0; $charIndex < $contentLength; $charIndex++) { + $currentChar = $arrayContentWithoutBraces[$charIndex]; + + // Track opening brackets/parentheses to handle nested WKT structures + if ($currentChar === '(' || $currentChar === '{') { + $nestedBracketDepth++; + $currentWktItem .= $currentChar; + + continue; + } + + // Track closing brackets/parentheses + if ($currentChar === ')' || $currentChar === '}') { + $nestedBracketDepth--; + $currentWktItem .= $currentChar; + + continue; + } + + // Only split on commas at the top level (not inside WKT coordinate groups) + if ($currentChar === ',' && $nestedBracketDepth === 0) { + $wktItems[] = $currentWktItem; + $currentWktItem = ''; + + continue; + } + + $currentWktItem .= $currentChar; + } + + // Add the last WKT item if there's content + if ($currentWktItem !== '') { + $wktItems[] = $currentWktItem; + } + + return \array_map('trim', $wktItems); + } + + /** + * Validates that an array item is suitable for database storage. + * + * For WKT spatial data arrays, items must be WktSpatialData instances. + */ + public function isValidArrayItemForDatabase(mixed $item): bool + { + return $item instanceof WktSpatialData; + } + + /** + * Transforms PostgreSQL array item to a PHP compatible array item. + */ + public function transformArrayItemForPHP(mixed $item): ?WktSpatialData + { + if ($item === null) { + return null; + } + + if (!\is_string($item)) { + throw $this->createInvalidTypeExceptionForPHP($item); + } + + try { + return WktSpatialData::fromWkt($item); + } catch (InvalidWktSpatialDataException) { + throw $this->createInvalidFormatExceptionForPHP($item); + } + } + + /** + * Creates an exception for invalid type during PHP conversion. + * Subclasses should override this to provide specific exception types. + */ + abstract protected function createInvalidTypeExceptionForPHP(mixed $item): ConversionException; + + /** + * Creates an exception for invalid format during PHP conversion. + * Subclasses should override this to provide specific exception types. + */ + abstract protected function createInvalidFormatExceptionForPHP(mixed $item): ConversionException; +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php new file mode 100644 index 00000000..8965edb5 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php @@ -0,0 +1,90 @@ +runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValues')] + #[Test] + public function can_handle_values(array $values): void + { + $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $values); + } + + public static function provideValues(): array + { + return [ + 'simple geographic features' => [[ + WktSpatialData::fromWkt('POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('LINESTRING(-122.4194 37.7749, -122.4094 37.7849)'), + WktSpatialData::fromWkt('POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'), + WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(-122.4194 37.7749), LINESTRING(-122.4194 37.7749, -122.4094 37.7849))'), + ]], + 'geographic features with elevation (z)' => [[ + WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('LINESTRING Z(-122.4194 37.7749 100, -122.4094 37.7849 150)'), + WktSpatialData::fromWkt('POLYGON Z((-122.5 37.7 0, -122.5 37.8 0, -122.4 37.8 0, -122.4 37.7 0, -122.5 37.7 0))'), + ]], + 'geographic features with measure (m)' => [[ + WktSpatialData::fromWkt('POINT M(-122.4194 37.7749 1)'), + WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), + WktSpatialData::fromWkt('MULTIPOINT M((-122.4194 37.7749 1), (-122.4094 37.7849 2))'), + ]], + 'geographic features with elevation and measure (zm)' => [[ + WktSpatialData::fromWkt('POINT ZM(-122.4194 37.7749 100 1)'), + WktSpatialData::fromWkt('LINESTRING ZM(-122.4194 37.7749 100 1, -122.4094 37.7849 150 2)'), + WktSpatialData::fromWkt('POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), + ]], + 'geographic features with srid' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'), + WktSpatialData::fromWkt('SRID=4269;LINESTRING(-122.4194 37.7749, -122.4094 37.7849)'), + ]], + 'mixed geographic features with dimensions and srid' => [[ + WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), + ]], + 'world coordinate edge cases' => [[ + WktSpatialData::fromWkt('POINT(0 0)'), // Null Island + WktSpatialData::fromWkt('POINT(180 0)'), // International Date Line + WktSpatialData::fromWkt('POINT(-180 0)'), // International Date Line (other side) + WktSpatialData::fromWkt('POINT(0 90)'), // North Pole + WktSpatialData::fromWkt('POINT(0 -90)'), // South Pole + ]], + 'complex geographic multigeometries' => [[ + WktSpatialData::fromWkt('MULTIPOINT Z((-122.4194 37.7749 100), (-122.4094 37.7849 150))'), + WktSpatialData::fromWkt('MULTILINESTRING M((-122.4194 37.7749 1, -122.4094 37.7849 2), (-122.4294 37.7649 3, -122.4394 37.7549 4))'), + WktSpatialData::fromWkt('MULTIPOLYGON ZM(((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1)))'), + WktSpatialData::fromWkt('GEOMETRYCOLLECTION Z(POINT Z(-122.4194 37.7749 100), LINESTRING Z(-122.4194 37.7749 100, -122.4094 37.7849 150))'), + ]], + 'single geographic point' => [[ + WktSpatialData::fromWkt('POINT(-122.4194 37.7749)'), + ]], + 'empty geographic array' => [[]], + ]; + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php new file mode 100644 index 00000000..f2de95ef --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php @@ -0,0 +1,83 @@ +runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValues')] + #[Test] + public function can_handle_values(array $values): void + { + $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $values); + } + + public static function provideValues(): array + { + return [ + 'simple geometries' => [[ + WktSpatialData::fromWkt('POINT(0 0)'), + WktSpatialData::fromWkt('LINESTRING(0 0, 1 1)'), + WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), + WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'), + ]], + 'dimensional modifiers z' => [[ + WktSpatialData::fromWkt('POINT Z(1 2 3)'), + WktSpatialData::fromWkt('LINESTRING Z(0 0 1, 1 1 2)'), + WktSpatialData::fromWkt('POLYGON Z((0 0 1, 0 1 1, 1 1 1, 1 0 1, 0 0 1))'), + ]], + 'dimensional modifiers m' => [[ + WktSpatialData::fromWkt('POINT M(1 2 3)'), + WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), + WktSpatialData::fromWkt('MULTIPOINT M((1 2 3), (4 5 6))'), + ]], + 'dimensional modifiers zm' => [[ + WktSpatialData::fromWkt('POINT ZM(1 2 3 4)'), + WktSpatialData::fromWkt('LINESTRING ZM(0 0 1 2, 1 1 3 4)'), + WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'), + ]], + 'ewkt with srid' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), + WktSpatialData::fromWkt('SRID=3857;LINESTRING(0 0, 1000 1000)'), + ]], + 'mixed dimensional and srid' => [[ + WktSpatialData::fromWkt('POINT Z(1 2 3)'), + WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'), + ]], + 'complex multigeometries' => [[ + WktSpatialData::fromWkt('MULTIPOINT Z((1 2 3), (4 5 6))'), + WktSpatialData::fromWkt('MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))'), + WktSpatialData::fromWkt('MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'), + WktSpatialData::fromWkt('GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))'), + ]], + 'single item array' => [[ + WktSpatialData::fromWkt('POINT(42 42)'), + ]], + 'empty array' => [[]], + ]; + } +} diff --git a/tests/Integration/MartinGeorgiev/TestCase.php b/tests/Integration/MartinGeorgiev/TestCase.php index d22801a4..b112c5be 100644 --- a/tests/Integration/MartinGeorgiev/TestCase.php +++ b/tests/Integration/MartinGeorgiev/TestCase.php @@ -20,7 +20,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\DateRange; use MartinGeorgiev\Doctrine\DBAL\Types\DoublePrecisionArray; use MartinGeorgiev\Doctrine\DBAL\Types\Geography; +use MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray; use MartinGeorgiev\Doctrine\DBAL\Types\Geometry; +use MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray; use MartinGeorgiev\Doctrine\DBAL\Types\Inet; use MartinGeorgiev\Doctrine\DBAL\Types\InetArray; use MartinGeorgiev\Doctrine\DBAL\Types\Int4Range; @@ -186,7 +188,9 @@ protected function registerCustomTypes(): void 'daterange' => DateRange::class, 'double precision[]' => DoublePrecisionArray::class, 'geography' => Geography::class, + 'geography[]' => GeographyArray::class, 'geometry' => Geometry::class, + 'geometry[]' => GeometryArray::class, 'inet' => Inet::class, 'inet[]' => InetArray::class, 'int4range' => Int4Range::class, diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php new file mode 100644 index 00000000..375e95dc --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php @@ -0,0 +1,204 @@ +type = new GeographyArray(); + $this->platform = $this->createMock(AbstractPlatform::class); + } + + #[Test] + public function can_convert_null_to_database_value(): void + { + $result = $this->type->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_convert_empty_array_to_database_value(): void + { + $result = $this->type->convertToDatabaseValue([], $this->platform); + + self::assertSame('{}', $result); + } + + #[DataProvider('provideValidArraysForDatabase')] + #[Test] + public function can_convert_valid_arrays_to_database_value(array $phpArray, string $expectedPostgresArray): void + { + $result = $this->type->convertToDatabaseValue($phpArray, $this->platform); + + self::assertSame($expectedPostgresArray, $result); + } + + /** + * @return array, string}> + */ + public static function provideValidArraysForDatabase(): array + { + return [ + 'single geographic point' => [ + [WktSpatialData::fromWkt('POINT(-122.4194 37.7749)')], + '{POINT(-122.4194 37.7749)}', + ], + 'geographic point with elevation' => [ + [WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)')], + '{POINT Z(-122.4194 37.7749 100)}', + ], + 'mixed geographic features with dimensions' => [ + [ + WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), + ], + '{POINT Z(-122.4194 37.7749 100),LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)}', + ], + 'geographic areas with srid' => [ + [ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'), + ], + '{SRID=4326;POINT(-122.4194 37.7749),SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))}', + ], + 'complex geographic zm features' => [ + [ + WktSpatialData::fromWkt('POINT ZM(-122.4194 37.7749 100 1)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), + ], + '{POINT ZM(-122.4194 37.7749 100 1),SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))}', + ], + 'world geographic features' => [ + [ + WktSpatialData::fromWkt('POINT(0 0)'), // Null Island + WktSpatialData::fromWkt('POINT(180 0)'), // International Date Line + WktSpatialData::fromWkt('POINT(-180 0)'), // International Date Line (other side) + ], + '{POINT(0 0),POINT(180 0),POINT(-180 0)}', + ], + ]; + } + + #[Test] + public function can_convert_null_to_php_value(): void + { + $result = $this->type->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_convert_empty_postgres_array_to_php_value(): void + { + $result = $this->type->convertToPHPValue('{}', $this->platform); + + self::assertSame([], $result); + } + + #[DataProvider('provideValidPostgresArraysForPHP')] + #[Test] + public function can_convert_valid_postgres_arrays_to_php_value(string $postgresArray, array $expectedPhpArray): void + { + $result = $this->type->convertToPHPValue($postgresArray, $this->platform); + + self::assertCount(\count($expectedPhpArray), $result); + + foreach ($result as $index => $item) { + self::assertInstanceOf(WktSpatialData::class, $item); + self::assertSame($expectedPhpArray[$index], (string) $item); + } + } + + /** + * @return array}> + */ + public static function provideValidPostgresArraysForPHP(): array + { + return [ + 'single geographic point' => [ + '{POINT(-122.4194 37.7749)}', + ['POINT(-122.4194 37.7749)'], + ], + 'geographic point with elevation' => [ + '{POINT Z(-122.4194 37.7749 100)}', + ['POINT Z(-122.4194 37.7749 100)'], + ], + 'mixed geographic features' => [ + '{POINT Z(-122.4194 37.7749 100),LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)}', + ['POINT Z(-122.4194 37.7749 100)', 'LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'], + ], + 'geographic areas with srid' => [ + '{SRID=4326;POINT(-122.4194 37.7749),SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))}', + ['SRID=4326;POINT(-122.4194 37.7749)', 'SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'], + ], + 'complex geographic multigeometry' => [ + '{MULTIPOINT((-122.4194 37.7749), (-122.4094 37.7849)),MULTILINESTRING((-122.4194 37.7749, -122.4094 37.7849), (-122.4294 37.7649, -122.4394 37.7549))}', + ['MULTIPOINT((-122.4194 37.7749), (-122.4094 37.7849))', 'MULTILINESTRING((-122.4194 37.7749, -122.4094 37.7849), (-122.4294 37.7649, -122.4394 37.7549))'], + ], + ]; + } + + #[DataProvider('provideBidirectionalTestCases')] + #[Test] + public function preserves_data_in_bidirectional_conversion(array $phpArray): void + { + // PHP -> Database -> PHP + $databaseValue = $this->type->convertToDatabaseValue($phpArray, $this->platform); + $convertedBack = $this->type->convertToPHPValue($databaseValue, $this->platform); + + self::assertCount(\count($phpArray), $convertedBack); + + foreach ($convertedBack as $index => $item) { + self::assertInstanceOf(WktSpatialData::class, $item); + self::assertSame((string) $phpArray[$index], (string) $item); + } + } + + /** + * @return array}> + */ + public static function provideBidirectionalTestCases(): array + { + return [ + 'geographic dimensional modifiers preservation' => [ + [ + WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), + WktSpatialData::fromWkt('POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), + ], + ], + 'geographic srid preservation' => [ + [ + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('SRID=4326;LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), + ], + ], + 'world coordinate edge cases' => [ + [ + WktSpatialData::fromWkt('POINT(-180 -90)'), // Southwest corner + WktSpatialData::fromWkt('POINT(180 90)'), // Northeast corner + WktSpatialData::fromWkt('POINT(0 0)'), // Null Island + ], + ], + ]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php new file mode 100644 index 00000000..e92fb140 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php @@ -0,0 +1,195 @@ +type = new GeometryArray(); + $this->platform = $this->createMock(AbstractPlatform::class); + } + + #[Test] + public function can_convert_null_to_database_value(): void + { + $result = $this->type->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_convert_empty_array_to_database_value(): void + { + $result = $this->type->convertToDatabaseValue([], $this->platform); + + self::assertSame('{}', $result); + } + + #[DataProvider('provideValidArraysForDatabase')] + #[Test] + public function can_convert_valid_arrays_to_database_value(array $phpArray, string $expectedPostgresArray): void + { + $result = $this->type->convertToDatabaseValue($phpArray, $this->platform); + + self::assertSame($expectedPostgresArray, $result); + } + + /** + * @return array, string}> + */ + public static function provideValidArraysForDatabase(): array + { + return [ + 'single point' => [ + [WktSpatialData::fromWkt('POINT(1 2)')], + '{POINT(1 2)}', + ], + 'point with z dimension' => [ + [WktSpatialData::fromWkt('POINT Z(1 2 3)')], + '{POINT Z(1 2 3)}', + ], + 'mixed dimensional modifiers' => [ + [ + WktSpatialData::fromWkt('POINT Z(1 2 3)'), + WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), + ], + '{POINT Z(1 2 3),LINESTRING M(0 0 1, 1 1 2)}', + ], + 'ewkt with srid' => [ + [ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), + ], + '{SRID=4326;POINT(-122.4194 37.7749),SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))}', + ], + 'complex zm geometries' => [ + [ + WktSpatialData::fromWkt('POINT ZM(1 2 3 4)'), + WktSpatialData::fromWkt('MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'), + ], + '{POINT ZM(1 2 3 4),MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))}', + ], + ]; + } + + #[Test] + public function can_convert_null_to_php_value(): void + { + $result = $this->type->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_convert_empty_postgres_array_to_php_value(): void + { + $result = $this->type->convertToPHPValue('{}', $this->platform); + + self::assertSame([], $result); + } + + #[DataProvider('provideValidPostgresArraysForPHP')] + #[Test] + public function can_convert_valid_postgres_arrays_to_php_value(string $postgresArray, array $expectedPhpArray): void + { + $result = $this->type->convertToPHPValue($postgresArray, $this->platform); + + self::assertCount(\count($expectedPhpArray), $result); + + foreach ($result as $index => $item) { + self::assertInstanceOf(WktSpatialData::class, $item); + self::assertSame($expectedPhpArray[$index], (string) $item); + } + } + + /** + * @return array}> + */ + public static function provideValidPostgresArraysForPHP(): array + { + return [ + 'single point' => [ + '{POINT(1 2)}', + ['POINT(1 2)'], + ], + 'point with z dimension' => [ + '{POINT Z(1 2 3)}', + ['POINT Z(1 2 3)'], + ], + 'mixed dimensional modifiers' => [ + '{POINT Z(1 2 3),LINESTRING M(0 0 1, 1 1 2)}', + ['POINT Z(1 2 3)', 'LINESTRING M(0 0 1, 1 1 2)'], + ], + 'ewkt with srid' => [ + '{SRID=4326;POINT(-122.4194 37.7749),SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))}', + ['SRID=4326;POINT(-122.4194 37.7749)', 'SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'], + ], + 'complex nested geometry' => [ + '{POLYGON((0 0, 0 1, 1 1, 1 0, 0 0)),MULTIPOINT((1 2), (3 4))}', + ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 'MULTIPOINT((1 2), (3 4))'], + ], + ]; + } + + #[DataProvider('provideBidirectionalTestCases')] + #[Test] + public function preserves_data_in_bidirectional_conversion(array $phpArray): void + { + // PHP -> Database -> PHP + $databaseValue = $this->type->convertToDatabaseValue($phpArray, $this->platform); + $convertedBack = $this->type->convertToPHPValue($databaseValue, $this->platform); + + self::assertCount(\count($phpArray), $convertedBack); + + foreach ($convertedBack as $index => $item) { + self::assertInstanceOf(WktSpatialData::class, $item); + self::assertSame((string) $phpArray[$index], (string) $item); + } + } + + /** + * @return array}> + */ + public static function provideBidirectionalTestCases(): array + { + return [ + 'dimensional modifiers preservation' => [ + [ + WktSpatialData::fromWkt('POINT Z(1 2 3)'), + WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), + WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'), + ], + ], + 'srid preservation' => [ + [ + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('SRID=3857;LINESTRING M(0 0 1, 1000 1000 2)'), + ], + ], + 'mixed complex geometries' => [ + [ + WktSpatialData::fromWkt('GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))'), + WktSpatialData::fromWkt('MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))'), + ], + ], + ]; + } +} From 76d0b6d68619a22176acede16a2d20a8a6f7964c Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Thu, 21 Aug 2025 01:16:10 +0300 Subject: [PATCH 10/26] extract dimensional modifier enum --- .../Types/ValueObject/DimensionalModifier.php | 36 ++++++++++ .../InvalidWktSpatialDataException.php | 6 +- .../DBAL/Types/ValueObject/GeometryType.php | 42 +++++++++++ .../DBAL/Types/ValueObject/WktSpatialData.php | 22 +++--- .../Doctrine/DBAL/Types/TestCase.php | 2 + .../DBAL/Types/GeographyArrayTest.php | 35 ++++++++- .../Doctrine/DBAL/Types/GeometryArrayTest.php | 34 ++++++++- .../Types/ValueObject/GeometryTypeTest.php | 71 +++++++++++++++++++ .../Types/ValueObject/WktGeometryTypeTest.php | 71 ------------------- .../Types/ValueObject/WktSpatialDataTest.php | 32 ++++++++- 10 files changed, 263 insertions(+), 88 deletions(-) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifier.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryType.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTypeTest.php delete mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifier.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifier.php new file mode 100644 index 00000000..d8d5bf24 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifier.php @@ -0,0 +1,36 @@ + + */ +enum DimensionalModifier: string +{ + /** + * Z dimension - represents elevation/altitude coordinate. + * Results in 3D geometries with X, Y, Z coordinates. + */ + case Z = 'Z'; + + /** + * M dimension - represents measure coordinate for linear referencing. + * Results in measured geometries with X, Y, M coordinates. + */ + case M = 'M'; + + /** + * ZM dimensions - represents both elevation and measure coordinates. + * Results in 4D geometries with X, Y, Z, M coordinates. + */ + case ZM = 'ZM'; +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php index 45816068..0f92e8f4 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Exceptions/InvalidWktSpatialDataException.php @@ -4,7 +4,7 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions; -use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktGeometryType; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\GeometryType; /** * Exception thrown when creating or manipulating WktSpatialData value objects with invalid data. @@ -45,10 +45,10 @@ public static function forEmptyCoordinateSection(): self public static function forUnsupportedGeometryType(string $type): self { - $supportedTypes = \array_map(static fn (WktGeometryType $wktGeometryType) => $wktGeometryType->value, WktGeometryType::cases()); + $supportedTypes = \array_map(static fn (GeometryType $geometryType) => $geometryType->value, GeometryType::cases()); return new self(\sprintf( - 'Unsupported Wkt geometry type: %s. Supported types: %s', + 'Unsupported geometry type: %s. Supported types: %s', \var_export($type, true), \implode(', ', $supportedTypes) )); diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryType.php new file mode 100644 index 00000000..5343ebde --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryType.php @@ -0,0 +1,42 @@ + + */ +enum GeometryType: string +{ + // Basic geometry types + case POINT = 'POINT'; + case LINESTRING = 'LINESTRING'; + case POLYGON = 'POLYGON'; + + // Multi-geometry types + case MULTIPOINT = 'MULTIPOINT'; + case MULTILINESTRING = 'MULTILINESTRING'; + case MULTIPOLYGON = 'MULTIPOLYGON'; + + // Collection types + case GEOMETRYCOLLECTION = 'GEOMETRYCOLLECTION'; + + // Circular geometry types (PostGIS extensions) + case CIRCULARSTRING = 'CIRCULARSTRING'; + case COMPOUNDCURVE = 'COMPOUNDCURVE'; + case CURVEPOLYGON = 'CURVEPOLYGON'; + case MULTICURVE = 'MULTICURVE'; + case MULTISURFACE = 'MULTISURFACE'; + + // Triangle and TIN types + case TRIANGLE = 'TRIANGLE'; + case TIN = 'TIN'; + case POLYHEDRALSURFACE = 'POLYHEDRALSURFACE'; +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php index b538ace1..887cb075 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php @@ -4,6 +4,7 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DimensionalModifier; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidWktSpatialDataException; /** @@ -23,16 +24,16 @@ final class WktSpatialData implements \Stringable { private function __construct( private readonly ?int $srid, - private readonly WktGeometryType $wktType, + private readonly GeometryType $geometryType, private readonly string $wktBody, - private readonly ?string $dimensionalModifier = null + private readonly ?DimensionalModifier $dimensionalModifier = null ) {} public function __toString(): string { - $typeWithModifier = $this->wktType->value; + $typeWithModifier = $this->geometryType->value; if ($this->dimensionalModifier !== null) { - $typeWithModifier .= ' '.$this->dimensionalModifier; + $typeWithModifier .= ' '.$this->dimensionalModifier->value; } $typeAndBody = $typeWithModifier.'('.$this->wktBody.')'; @@ -73,13 +74,13 @@ public static function fromWkt(string $wkt): self } $typeString = $matches[1]; - $dimensionalModifier = empty($matches[2]) ? null : $matches[2]; + $dimensionalModifier = empty($matches[2]) ? null : DimensionalModifier::tryFrom($matches[2]); $body = \trim($matches[3]); if ($body === '') { throw InvalidWktSpatialDataException::forEmptyCoordinateSection(); } - $geometryType = WktGeometryType::tryFrom($typeString); + $geometryType = GeometryType::tryFrom($typeString); if ($geometryType === null) { throw InvalidWktSpatialDataException::forUnsupportedGeometryType($typeString); } @@ -92,9 +93,14 @@ public function getSrid(): ?int return $this->srid; } - public function getGeometryType(): WktGeometryType + public function getGeometryType(): GeometryType { - return $this->wktType; + return $this->geometryType; + } + + public function getDimensionalModifier(): ?DimensionalModifier + { + return $this->dimensionalModifier; } public function getWkt(): string diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php index 5fc822f2..d61cbaca 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php @@ -103,6 +103,8 @@ private function getSelectExpressionForType(string $typeName, string $columnName return match ($typeName) { 'geometry' => \sprintf('ST_AsEWKT("%s") AS "%s"', $columnName, $columnName), 'geography' => \sprintf('ST_AsEWKT("%s"::geometry) AS "%s"', $columnName, $columnName), + 'geometry[]' => \sprintf('ARRAY(SELECT CASE WHEN ST_SRID(geom) = 0 THEN ST_AsText(geom) ELSE \'SRID=\' || ST_SRID(geom) || \';\' || ST_AsText(geom) END FROM unnest("%s") AS geom) AS "%s"', $columnName, $columnName), + 'geography[]' => \sprintf('ARRAY(SELECT CASE WHEN ST_SRID(geog::geometry) = 0 THEN ST_AsText(geog::geometry) ELSE \'SRID=\' || ST_SRID(geog::geometry) || \';\' || ST_AsText(geog::geometry) END FROM unnest("%s") AS geog) AS "%s"', $columnName, $columnName), default => $columnName, }; } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php index 375e95dc..a66fd357 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php @@ -19,7 +19,7 @@ final class GeographyArrayTest extends TestCase { private GeographyArray $type; - private MockObject $platform; + private AbstractPlatform&MockObject $platform; protected function setUp(): void { @@ -95,6 +95,33 @@ public static function provideValidArraysForDatabase(): array ], '{POINT(0 0),POINT(180 0),POINT(-180 0)}', ], + 'mixed geographic geometry types' => [ + [ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('SRID=4326;LINESTRING(-122.4194 37.7749, -122.4094 37.7849)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'), + WktSpatialData::fromWkt('SRID=4326;MULTIPOINT((-122.4194 37.7749), (-122.4094 37.7849))'), + ], + '{SRID=4326;POINT(-122.4194 37.7749),SRID=4326;LINESTRING(-122.4194 37.7749, -122.4094 37.7849),SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7)),SRID=4326;MULTIPOINT((-122.4194 37.7749), (-122.4094 37.7849))}', + ], + 'mixed geographic dimensional modifiers' => [ + [ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('SRID=4326;POINT M(-122.4194 37.7749 1)'), + WktSpatialData::fromWkt('SRID=4326;POINT ZM(-122.4194 37.7749 100 1)'), + ], + '{SRID=4326;POINT(-122.4194 37.7749),SRID=4326;POINT Z(-122.4194 37.7749 100),SRID=4326;POINT M(-122.4194 37.7749 1),SRID=4326;POINT ZM(-122.4194 37.7749 100 1)}', + ], + 'complex geographic mix' => [ + [ + WktSpatialData::fromWkt('POINT(0 0)'), // Null Island (no SRID) + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('SRID=4269;LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), + WktSpatialData::fromWkt('SRID=4326;MULTIPOINT((-122.4194 37.7749), (-122.4094 37.7849))'), + ], + '{POINT(0 0),SRID=4326;POINT Z(-122.4194 37.7749 100),SRID=4269;LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2),SRID=4326;MULTIPOINT((-122.4194 37.7749), (-122.4094 37.7849))}', + ], ]; } @@ -120,6 +147,7 @@ public function can_convert_valid_postgres_arrays_to_php_value(string $postgresA { $result = $this->type->convertToPHPValue($postgresArray, $this->platform); + self::assertIsArray($result); self::assertCount(\count($expectedPhpArray), $result); foreach ($result as $index => $item) { @@ -165,11 +193,14 @@ public function preserves_data_in_bidirectional_conversion(array $phpArray): voi $databaseValue = $this->type->convertToDatabaseValue($phpArray, $this->platform); $convertedBack = $this->type->convertToPHPValue($databaseValue, $this->platform); + self::assertIsArray($convertedBack); self::assertCount(\count($phpArray), $convertedBack); foreach ($convertedBack as $index => $item) { self::assertInstanceOf(WktSpatialData::class, $item); - self::assertSame((string) $phpArray[$index], (string) $item); + $originalItem = $phpArray[$index]; + self::assertInstanceOf(WktSpatialData::class, $originalItem); + self::assertSame((string) $originalItem, (string) $item); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php index e92fb140..f34a0890 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php @@ -19,7 +19,7 @@ final class GeometryArrayTest extends TestCase { private GeometryArray $type; - private MockObject $platform; + private AbstractPlatform&MockObject $platform; protected function setUp(): void { @@ -87,6 +87,32 @@ public static function provideValidArraysForDatabase(): array ], '{POINT ZM(1 2 3 4),MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))}', ], + 'mixed geometry types' => [ + [ + WktSpatialData::fromWkt('POINT(0 0)'), + WktSpatialData::fromWkt('LINESTRING(0 0, 1 1)'), + WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), + ], + '{POINT(0 0),LINESTRING(0 0, 1 1),POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))}', + ], + 'mixed srid usage' => [ + [ + WktSpatialData::fromWkt('POINT(0 0)'), + WktSpatialData::fromWkt('SRID=4326;POINT(-122 37)'), + WktSpatialData::fromWkt('SRID=3857;POINT(1000 2000)'), + ], + '{POINT(0 0),SRID=4326;POINT(-122 37),SRID=3857;POINT(1000 2000)}', + ], + 'complex mixed array' => [ + [ + WktSpatialData::fromWkt('POINT(0 0)'), + WktSpatialData::fromWkt('SRID=4326;POINT Z(1 2 3)'), + WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), + WktSpatialData::fromWkt('MULTIPOINT((1 2), (3 4))'), + WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'), + ], + '{POINT(0 0),SRID=4326;POINT Z(1 2 3),LINESTRING M(0 0 1, 1 1 2),MULTIPOINT((1 2), (3 4)),GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))}', + ], ]; } @@ -112,6 +138,7 @@ public function can_convert_valid_postgres_arrays_to_php_value(string $postgresA { $result = $this->type->convertToPHPValue($postgresArray, $this->platform); + self::assertIsArray($result); self::assertCount(\count($expectedPhpArray), $result); foreach ($result as $index => $item) { @@ -157,11 +184,14 @@ public function preserves_data_in_bidirectional_conversion(array $phpArray): voi $databaseValue = $this->type->convertToDatabaseValue($phpArray, $this->platform); $convertedBack = $this->type->convertToPHPValue($databaseValue, $this->platform); + self::assertIsArray($convertedBack); self::assertCount(\count($phpArray), $convertedBack); foreach ($convertedBack as $index => $item) { self::assertInstanceOf(WktSpatialData::class, $item); - self::assertSame((string) $phpArray[$index], (string) $item); + $originalItem = $phpArray[$index]; + self::assertInstanceOf(WktSpatialData::class, $originalItem); + self::assertSame((string) $originalItem, (string) $item); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTypeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTypeTest.php new file mode 100644 index 00000000..783cf6a0 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/GeometryTypeTest.php @@ -0,0 +1,71 @@ +value); + } + + #[Test] + public function throws_exception_for_invalid_type(): void + { + $this->expectException(\ValueError::class); + + GeometryType::from('INVALID'); + } + + #[DataProvider('provideValidGeometryTypes')] + #[Test] + public function returns_enum_for_valid_types(string $typeString, GeometryType $geometryType): void + { + $result = GeometryType::tryFrom($typeString); + + self::assertSame($geometryType, $result); + } + + /** + * @return array + */ + public static function provideValidGeometryTypes(): array + { + return [ + 'point' => ['POINT', GeometryType::POINT], + 'linestring' => ['LINESTRING', GeometryType::LINESTRING], + 'polygon' => ['POLYGON', GeometryType::POLYGON], + 'multipoint' => ['MULTIPOINT', GeometryType::MULTIPOINT], + 'multilinestring' => ['MULTILINESTRING', GeometryType::MULTILINESTRING], + 'multipolygon' => ['MULTIPOLYGON', GeometryType::MULTIPOLYGON], + 'geometrycollection' => ['GEOMETRYCOLLECTION', GeometryType::GEOMETRYCOLLECTION], + 'circularstring' => ['CIRCULARSTRING', GeometryType::CIRCULARSTRING], + 'compoundcurve' => ['COMPOUNDCURVE', GeometryType::COMPOUNDCURVE], + 'curvepolygon' => ['CURVEPOLYGON', GeometryType::CURVEPOLYGON], + 'multicurve' => ['MULTICURVE', GeometryType::MULTICURVE], + 'multisurface' => ['MULTISURFACE', GeometryType::MULTISURFACE], + 'triangle' => ['TRIANGLE', GeometryType::TRIANGLE], + 'tin' => ['TIN', GeometryType::TIN], + 'polyhedralsurface' => ['POLYHEDRALSURFACE', GeometryType::POLYHEDRALSURFACE], + ]; + } + + #[Test] + public function returns_null_for_invalid_types(): void + { + self::assertNull(GeometryType::tryFrom('INVALID_TYPE')); + self::assertNull(GeometryType::tryFrom('')); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php deleted file mode 100644 index 4dd8ef4f..00000000 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryTypeTest.php +++ /dev/null @@ -1,71 +0,0 @@ -value); - } - - #[Test] - public function throws_exception_for_invalid_type(): void - { - $this->expectException(\ValueError::class); - - WktGeometryType::from('INVALID'); - } - - #[DataProvider('provideValidGeometryTypes')] - #[Test] - public function returns_enum_for_valid_types(string $typeString, WktGeometryType $wktGeometryType): void - { - $result = WktGeometryType::tryFrom($typeString); - - self::assertSame($wktGeometryType, $result); - } - - /** - * @return array - */ - public static function provideValidGeometryTypes(): array - { - return [ - 'point' => ['POINT', WktGeometryType::POINT], - 'linestring' => ['LINESTRING', WktGeometryType::LINESTRING], - 'polygon' => ['POLYGON', WktGeometryType::POLYGON], - 'multipoint' => ['MULTIPOINT', WktGeometryType::MULTIPOINT], - 'multilinestring' => ['MULTILINESTRING', WktGeometryType::MULTILINESTRING], - 'multipolygon' => ['MULTIPOLYGON', WktGeometryType::MULTIPOLYGON], - 'geometrycollection' => ['GEOMETRYCOLLECTION', WktGeometryType::GEOMETRYCOLLECTION], - 'circularstring' => ['CIRCULARSTRING', WktGeometryType::CIRCULARSTRING], - 'compoundcurve' => ['COMPOUNDCURVE', WktGeometryType::COMPOUNDCURVE], - 'curvepolygon' => ['CURVEPOLYGON', WktGeometryType::CURVEPOLYGON], - 'multicurve' => ['MULTICURVE', WktGeometryType::MULTICURVE], - 'multisurface' => ['MULTISURFACE', WktGeometryType::MULTISURFACE], - 'triangle' => ['TRIANGLE', WktGeometryType::TRIANGLE], - 'tin' => ['TIN', WktGeometryType::TIN], - 'polyhedralsurface' => ['POLYHEDRALSURFACE', WktGeometryType::POLYHEDRALSURFACE], - ]; - } - - #[Test] - public function returns_null_for_invalid_types(): void - { - self::assertNull(WktGeometryType::tryFrom('INVALID_TYPE')); - self::assertNull(WktGeometryType::tryFrom('')); - } -} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php index 547a9fea..63df3d2c 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php @@ -4,8 +4,9 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DimensionalModifier; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidWktSpatialDataException; -use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktGeometryType; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\GeometryType; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -19,12 +20,21 @@ public function can_create_from_wkt(string $wkt, string $expectedType, ?int $exp { $wktSpatialData = WktSpatialData::fromWkt($wkt); - self::assertSame(WktGeometryType::from($expectedType), $wktSpatialData->getGeometryType()); + self::assertSame(GeometryType::from($expectedType), $wktSpatialData->getGeometryType()); self::assertSame($expectedSrid, $wktSpatialData->getSrid()); self::assertSame($wkt, $wktSpatialData->getWkt()); self::assertSame($wkt, (string) $wktSpatialData); } + #[DataProvider('provideDimensionalModifierWkt')] + #[Test] + public function can_extract_dimensional_modifier(string $wkt, ?DimensionalModifier $expectedModifier): void + { + $wktSpatialData = WktSpatialData::fromWkt($wkt); + + self::assertSame($expectedModifier, $wktSpatialData->getDimensionalModifier()); + } + /** * @return array */ @@ -101,4 +111,22 @@ public static function provideDimensionalModifierRoundTripCases(): array 'srid with polygon zm' => ['SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'], ]; } + + /** + * @return array + */ + public static function provideDimensionalModifierWkt(): array + { + return [ + 'point without modifier' => ['POINT(1 2)', null], + 'point with z' => ['POINT Z(1 2 3)', DimensionalModifier::Z], + 'point with m' => ['POINT M(1 2 3)', DimensionalModifier::M], + 'point with zm' => ['POINT ZM(1 2 3 4)', DimensionalModifier::ZM], + 'linestring with z' => ['LINESTRING Z(0 0 1, 1 1 2)', DimensionalModifier::Z], + 'polygon with m' => ['POLYGON M((0 0 1, 0 1 2, 1 1 3, 1 0 4, 0 0 1))', DimensionalModifier::M], + 'multipoint with zm' => ['MULTIPOINT ZM((1 2 3 4), (5 6 7 8))', DimensionalModifier::ZM], + 'srid with z modifier' => ['SRID=4326;POINT Z(-122.4194 37.7749 100)', DimensionalModifier::Z], + 'srid without modifier' => ['SRID=4326;POINT(-122.4194 37.7749)', null], + ]; + } } From 8f6d4a812b82ce727c4b079e42d82489cf9e86a4 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Thu, 21 Aug 2025 01:23:57 +0300 Subject: [PATCH 11/26] cleanup --- .../Doctrine/DBAL/Types/GeographyArray.php | 6 +- .../Doctrine/DBAL/Types/GeometryArray.php | 6 +- .../Types/ValueObject/WktGeometryType.php | 40 ------------ .../DBAL/Types/ValueObject/WktSpatialData.php | 3 +- .../ValueObject/DimensionalModifierTest.php | 61 +++++++++++++++++++ .../Types/ValueObject/WktSpatialDataTest.php | 54 ++++++++-------- 6 files changed, 91 insertions(+), 79 deletions(-) delete mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifierTest.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php index 46364349..0d16e5db 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php @@ -18,11 +18,7 @@ final class GeographyArray extends SpatialDataArray protected function transformArrayItemForPostgres(mixed $item): string { - if (!$this->isValidArrayItemForDatabase($item)) { - throw InvalidGeographyForPHPException::forInvalidType($item); - } - - return (string) $item; + return (string) $this->getValidatedArrayItem($item); } protected function createInvalidTypeExceptionForPHP(mixed $item): ConversionException diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php index b7e0f156..7a19a209 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php @@ -18,11 +18,7 @@ final class GeometryArray extends SpatialDataArray protected function transformArrayItemForPostgres(mixed $item): string { - if (!$this->isValidArrayItemForDatabase($item)) { - throw InvalidGeometryForPHPException::forInvalidType($item); - } - - return (string) $item; + return (string) $this->getValidatedArrayItem($item); } protected function createInvalidTypeExceptionForPHP(mixed $item): ConversionException diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php deleted file mode 100644 index cf40444b..00000000 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktGeometryType.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ -enum WktGeometryType: string -{ - // Basic geometry types - case POINT = 'POINT'; - case LINESTRING = 'LINESTRING'; - case POLYGON = 'POLYGON'; - - // Multi-geometry types - case MULTIPOINT = 'MULTIPOINT'; - case MULTILINESTRING = 'MULTILINESTRING'; - case MULTIPOLYGON = 'MULTIPOLYGON'; - - // Collection types - case GEOMETRYCOLLECTION = 'GEOMETRYCOLLECTION'; - - // Circular geometry types (PostGIS extensions) - case CIRCULARSTRING = 'CIRCULARSTRING'; - case COMPOUNDCURVE = 'COMPOUNDCURVE'; - case CURVEPOLYGON = 'CURVEPOLYGON'; - case MULTICURVE = 'MULTICURVE'; - case MULTISURFACE = 'MULTISURFACE'; - - // Triangle and TIN types - case TRIANGLE = 'TRIANGLE'; - case TIN = 'TIN'; - case POLYHEDRALSURFACE = 'POLYHEDRALSURFACE'; -} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php index 887cb075..a8057509 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php @@ -4,7 +4,6 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; -use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DimensionalModifier; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidWktSpatialDataException; /** @@ -32,7 +31,7 @@ private function __construct( public function __toString(): string { $typeWithModifier = $this->geometryType->value; - if ($this->dimensionalModifier !== null) { + if ($this->dimensionalModifier instanceof DimensionalModifier) { $typeWithModifier .= ' '.$this->dimensionalModifier->value; } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifierTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifierTest.php new file mode 100644 index 00000000..e5065f6e --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DimensionalModifierTest.php @@ -0,0 +1,61 @@ +value); + } + + #[Test] + public function throws_exception_for_invalid_modifier(): void + { + $this->expectException(\ValueError::class); + + DimensionalModifier::from('INVALID'); + } + + #[DataProvider('provideValidDimensionalModifiers')] + #[Test] + public function returns_enum_for_valid_modifiers(string $modifierString, DimensionalModifier $dimensionalModifier): void + { + $result = DimensionalModifier::tryFrom($modifierString); + + self::assertSame($dimensionalModifier, $result); + } + + /** + * @return array + */ + public static function provideValidDimensionalModifiers(): array + { + return [ + 'z dimension' => ['Z', DimensionalModifier::Z], + 'm dimension' => ['M', DimensionalModifier::M], + 'zm dimensions' => ['ZM', DimensionalModifier::ZM], + ]; + } + + #[Test] + public function returns_null_for_invalid_modifiers(): void + { + self::assertNull(DimensionalModifier::tryFrom('INVALID_MODIFIER')); + self::assertNull(DimensionalModifier::tryFrom('')); + self::assertNull(DimensionalModifier::tryFrom('X')); + self::assertNull(DimensionalModifier::tryFrom('Y')); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php index 63df3d2c..5cf29a08 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php @@ -26,15 +26,6 @@ public function can_create_from_wkt(string $wkt, string $expectedType, ?int $exp self::assertSame($wkt, (string) $wktSpatialData); } - #[DataProvider('provideDimensionalModifierWkt')] - #[Test] - public function can_extract_dimensional_modifier(string $wkt, ?DimensionalModifier $expectedModifier): void - { - $wktSpatialData = WktSpatialData::fromWkt($wkt); - - self::assertSame($expectedModifier, $wktSpatialData->getDimensionalModifier()); - } - /** * @return array */ @@ -54,6 +45,33 @@ public static function provideValidWkt(): array ]; } + #[DataProvider('provideDimensionalModifierWkt')] + #[Test] + public function can_extract_dimensional_modifier(string $wkt, ?DimensionalModifier $dimensionalModifier): void + { + $wktSpatialData = WktSpatialData::fromWkt($wkt); + + self::assertSame($dimensionalModifier, $wktSpatialData->getDimensionalModifier()); + } + + /** + * @return array + */ + public static function provideDimensionalModifierWkt(): array + { + return [ + 'point without modifier' => ['POINT(1 2)', null], + 'point with z' => ['POINT Z(1 2 3)', DimensionalModifier::Z], + 'point with m' => ['POINT M(1 2 3)', DimensionalModifier::M], + 'point with zm' => ['POINT ZM(1 2 3 4)', DimensionalModifier::ZM], + 'linestring with z' => ['LINESTRING Z(0 0 1, 1 1 2)', DimensionalModifier::Z], + 'polygon with m' => ['POLYGON M((0 0 1, 0 1 2, 1 1 3, 1 0 4, 0 0 1))', DimensionalModifier::M], + 'multipoint with zm' => ['MULTIPOINT ZM((1 2 3 4), (5 6 7 8))', DimensionalModifier::ZM], + 'srid with z modifier' => ['SRID=4326;POINT Z(-122.4194 37.7749 100)', DimensionalModifier::Z], + 'srid without modifier' => ['SRID=4326;POINT(-122.4194 37.7749)', null], + ]; + } + #[DataProvider('provideInvalidWkt')] #[Test] public function throws_exception_for_invalid_wkt(string $invalidWkt): void @@ -111,22 +129,4 @@ public static function provideDimensionalModifierRoundTripCases(): array 'srid with polygon zm' => ['SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'], ]; } - - /** - * @return array - */ - public static function provideDimensionalModifierWkt(): array - { - return [ - 'point without modifier' => ['POINT(1 2)', null], - 'point with z' => ['POINT Z(1 2 3)', DimensionalModifier::Z], - 'point with m' => ['POINT M(1 2 3)', DimensionalModifier::M], - 'point with zm' => ['POINT ZM(1 2 3 4)', DimensionalModifier::ZM], - 'linestring with z' => ['LINESTRING Z(0 0 1, 1 1 2)', DimensionalModifier::Z], - 'polygon with m' => ['POLYGON M((0 0 1, 0 1 2, 1 1 3, 1 0 4, 0 0 1))', DimensionalModifier::M], - 'multipoint with zm' => ['MULTIPOINT ZM((1 2 3 4), (5 6 7 8))', DimensionalModifier::ZM], - 'srid with z modifier' => ['SRID=4326;POINT Z(-122.4194 37.7749 100)', DimensionalModifier::Z], - 'srid without modifier' => ['SRID=4326;POINT(-122.4194 37.7749)', null], - ]; - } } From 2f8b760c6e0ac09739b1087bbc2c12191eaadd5d Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Thu, 21 Aug 2025 15:34:11 +0300 Subject: [PATCH 12/26] add multi item support --- .../DBAL/Types/GeographyArrayTypeTest.php | 111 ++++++++++-------- .../DBAL/Types/GeometryArrayTypeTest.php | 98 ++++++++++------ 2 files changed, 123 insertions(+), 86 deletions(-) diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php index 8965edb5..f730e77f 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php @@ -20,71 +20,86 @@ protected function getPostgresTypeName(): string return 'GEOGRAPHY[]'; } + #[DataProvider('provideSingleItemArrays')] #[Test] - public function can_handle_null_values(): void - { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); - } - - #[DataProvider('provideValues')] - #[Test] - public function can_handle_values(array $values): void + public function can_handle_single_item_array(array $values): void { $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $values); } - public static function provideValues(): array + public static function provideSingleItemArrays(): array { return [ - 'simple geographic features' => [[ - WktSpatialData::fromWkt('POINT(-122.4194 37.7749)'), - WktSpatialData::fromWkt('LINESTRING(-122.4194 37.7749, -122.4094 37.7849)'), - WktSpatialData::fromWkt('POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'), - WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(-122.4194 37.7749), LINESTRING(-122.4194 37.7749, -122.4094 37.7849))'), + // Single item tests - These work perfectly with Doctrine DBAL parameter binding + 'single point' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), ]], - 'geographic features with elevation (z)' => [[ - WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), - WktSpatialData::fromWkt('LINESTRING Z(-122.4194 37.7749 100, -122.4094 37.7849 150)'), - WktSpatialData::fromWkt('POLYGON Z((-122.5 37.7 0, -122.5 37.8 0, -122.4 37.8 0, -122.4 37.7 0, -122.5 37.7 0))'), + 'single point with z dimension' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), ]], - 'geographic features with measure (m)' => [[ - WktSpatialData::fromWkt('POINT M(-122.4194 37.7749 1)'), - WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), - WktSpatialData::fromWkt('MULTIPOINT M((-122.4194 37.7749 1), (-122.4094 37.7849 2))'), + 'single point with m dimension' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT M(-122.4194 37.7749 1)'), ]], - 'geographic features with elevation and measure (zm)' => [[ - WktSpatialData::fromWkt('POINT ZM(-122.4194 37.7749 100 1)'), - WktSpatialData::fromWkt('LINESTRING ZM(-122.4194 37.7749 100 1, -122.4094 37.7849 150 2)'), - WktSpatialData::fromWkt('POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), + 'single point with zm dimension' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT ZM(-122.4194 37.7749 100 1)'), ]], - 'geographic features with srid' => [[ - WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), - WktSpatialData::fromWkt('SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'), - WktSpatialData::fromWkt('SRID=4269;LINESTRING(-122.4194 37.7749, -122.4094 37.7849)'), + 'single linestring' => [[ + WktSpatialData::fromWkt('SRID=4326;LINESTRING(-122.4194 37.7749,-122.4094 37.7849,-122.4 37.79)'), ]], - 'mixed geographic features with dimensions and srid' => [[ - WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), - WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), - WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), - WktSpatialData::fromWkt('SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), + 'single polygon' => [[ + WktSpatialData::fromWkt('SRID=4326;POLYGON((-122.5 37.7,-122.5 37.8,-122.4 37.8,-122.4 37.7,-122.5 37.7))'), + ]], + 'single multipoint' => [[ + WktSpatialData::fromWkt('SRID=4326;MULTIPOINT((-122.4194 37.7749),(-122.4094 37.7849))'), ]], - 'world coordinate edge cases' => [[ - WktSpatialData::fromWkt('POINT(0 0)'), // Null Island - WktSpatialData::fromWkt('POINT(180 0)'), // International Date Line - WktSpatialData::fromWkt('POINT(-180 0)'), // International Date Line (other side) - WktSpatialData::fromWkt('POINT(0 90)'), // North Pole - WktSpatialData::fromWkt('POINT(0 -90)'), // South Pole + 'world coordinate null island' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(0 0)'), ]], - 'complex geographic multigeometries' => [[ - WktSpatialData::fromWkt('MULTIPOINT Z((-122.4194 37.7749 100), (-122.4094 37.7849 150))'), - WktSpatialData::fromWkt('MULTILINESTRING M((-122.4194 37.7749 1, -122.4094 37.7849 2), (-122.4294 37.7649 3, -122.4394 37.7549 4))'), - WktSpatialData::fromWkt('MULTIPOLYGON ZM(((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1)))'), - WktSpatialData::fromWkt('GEOMETRYCOLLECTION Z(POINT Z(-122.4194 37.7749 100), LINESTRING Z(-122.4194 37.7749 100, -122.4094 37.7849 150))'), + 'world coordinate north pole' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(0 90)'), ]], - 'single geographic point' => [[ + 'world coordinate south pole' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(0 -90)'), + ]], + + // Edge cases + 'empty array' => [[]], + ]; + } + + /** + * @param array $phpArray + */ + #[DataProvider('provideMultiItemArrays')] + #[Test] + public function can_handle_multi_item_array(array $phpArray): void + { + $wkts = \array_values(\array_map(static fn ($v): string => (string) $v, $phpArray)); + $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wkts, 'geography'); + } + + /** + * @return array}> + */ + public static function provideMultiItemArrays(): array + { + return [ + 'two points' => [[ WktSpatialData::fromWkt('POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('POINT(-122.4094 37.7849)'), + ]], + 'dimensional modifiers' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('SRID=4326;POINT M(-122.4194 37.7749 1)'), + WktSpatialData::fromWkt('SRID=4326;POINT ZM(-122.4194 37.7749 100 1)'), + ]], + 'mixed types' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + WktSpatialData::fromWkt('SRID=4326;LINESTRING(-122.4194 37.7749,-122.4094 37.7849)'), + WktSpatialData::fromWkt('SRID=4326;POLYGON((-122.5 37.7,-122.5 37.8,-122.4 37.8,-122.4 37.7,-122.5 37.7))'), + WktSpatialData::fromWkt('SRID=4326;MULTIPOINT((-122.4194 37.7749),(-122.4094 37.7849))'), ]], - 'empty geographic array' => [[]], ]; } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php index f2de95ef..b68bb264 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php @@ -20,64 +20,86 @@ protected function getPostgresTypeName(): string return 'GEOMETRY[]'; } + #[DataProvider('provideSingleItemArrays')] #[Test] - public function can_handle_null_values(): void - { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); - } - - #[DataProvider('provideValues')] - #[Test] - public function can_handle_values(array $values): void + public function can_handle_single_item_array(array $values): void { $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $values); } - public static function provideValues(): array + public static function provideSingleItemArrays(): array { return [ - 'simple geometries' => [[ + // Single item tests - These work perfectly with Doctrine DBAL parameter binding + 'single point' => [[ WktSpatialData::fromWkt('POINT(0 0)'), - WktSpatialData::fromWkt('LINESTRING(0 0, 1 1)'), - WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), - WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'), ]], - 'dimensional modifiers z' => [[ + 'single point with z dimension' => [[ WktSpatialData::fromWkt('POINT Z(1 2 3)'), - WktSpatialData::fromWkt('LINESTRING Z(0 0 1, 1 1 2)'), - WktSpatialData::fromWkt('POLYGON Z((0 0 1, 0 1 1, 1 1 1, 1 0 1, 0 0 1))'), ]], - 'dimensional modifiers m' => [[ + 'single point with m dimension' => [[ WktSpatialData::fromWkt('POINT M(1 2 3)'), - WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), - WktSpatialData::fromWkt('MULTIPOINT M((1 2 3), (4 5 6))'), ]], - 'dimensional modifiers zm' => [[ + 'single point with zm dimensions' => [[ WktSpatialData::fromWkt('POINT ZM(1 2 3 4)'), - WktSpatialData::fromWkt('LINESTRING ZM(0 0 1 2, 1 1 3 4)'), - WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'), ]], - 'ewkt with srid' => [[ - WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), - WktSpatialData::fromWkt('SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), - WktSpatialData::fromWkt('SRID=3857;LINESTRING(0 0, 1000 1000)'), + 'single ewkt with srid' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(0 0)'), ]], - 'mixed dimensional and srid' => [[ - WktSpatialData::fromWkt('POINT Z(1 2 3)'), - WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), - WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), - WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'), + 'single ewkt with srid and dimensions' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT Z(1 2 3)'), + ]], + 'single linestring' => [[ + WktSpatialData::fromWkt('LINESTRING(0 0,1 1,2 2)'), + ]], + 'single polygon' => [[ + WktSpatialData::fromWkt('POLYGON((0 0,0 1,1 1,1 0,0 0))'), ]], - 'complex multigeometries' => [[ - WktSpatialData::fromWkt('MULTIPOINT Z((1 2 3), (4 5 6))'), - WktSpatialData::fromWkt('MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))'), - WktSpatialData::fromWkt('MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'), - WktSpatialData::fromWkt('GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))'), + 'single multipoint' => [[ + WktSpatialData::fromWkt('MULTIPOINT((1 2),(3 4))'), ]], - 'single item array' => [[ - WktSpatialData::fromWkt('POINT(42 42)'), + 'single complex geometry with srid' => [[ + WktSpatialData::fromWkt('SRID=4326;POLYGON((-122.5 37.7,-122.5 37.8,-122.4 37.8,-122.4 37.7,-122.5 37.7))'), ]], + + // Edge cases 'empty array' => [[]], ]; } + + /** + * @param array $phpArray + */ + #[DataProvider('provideMultiItemArrays')] + #[Test] + public function can_handle_multi_item_array(array $phpArray): void + { + $wktsAsString = \array_values(\array_map(static fn ($v): string => (string) $v, $phpArray)); + $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wktsAsString, 'geometry'); + } + + /** + * @return array}> + */ + public static function provideMultiItemArrays(): array + { + return [ + 'two points' => [[ + WktSpatialData::fromWkt('POINT(0 0)'), + WktSpatialData::fromWkt('POINT(1 1)'), + ]], + 'dimensional modifiers' => [[ + WktSpatialData::fromWkt('POINT Z(1 2 3)'), + WktSpatialData::fromWkt('POINT M(1 2 3)'), + WktSpatialData::fromWkt('POINT ZM(1 2 3 4)'), + ]], + 'mixed types' => [[ + WktSpatialData::fromWkt('POINT(0 0)'), + WktSpatialData::fromWkt('SRID=4326;POINT Z(1 2 3)'), + WktSpatialData::fromWkt('LINESTRING M(0 0 1,1 1 2)'), + WktSpatialData::fromWkt('SRID=3857;POLYGON((0 0,0 1000,1000 1000,1000 0,0 0))'), + WktSpatialData::fromWkt('MULTIPOINT((1 2),(3 4))'), + ]], + ]; + } } From 3717176eda1ed67c4f5bc4e5e4e7bbb3516da6ff Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Thu, 21 Aug 2025 17:01:07 +0300 Subject: [PATCH 13/26] tests -> cleanup -> tests :) --- ci/php-cs-fixer/config.php | 1 + .../DBAL/Types/GeographyArrayTypeTest.php | 11 ++++++++++- .../Doctrine/DBAL/Types/GeographyTypeTest.php | 5 +++++ .../DBAL/Types/GeometryArrayTypeTest.php | 11 ++++++++++- .../Doctrine/DBAL/Types/GeometryTypeTest.php | 5 +++++ .../Doctrine/DBAL/Types/TestCase.php | 16 ++++------------ .../PostgresArrayToPHPArrayTransformerTest.php | 2 +- .../DBAL/Types/ValueObject/BaseRangeTestCase.php | 6 +++--- 8 files changed, 39 insertions(+), 18 deletions(-) diff --git a/ci/php-cs-fixer/config.php b/ci/php-cs-fixer/config.php index 060a37a5..8567404d 100644 --- a/ci/php-cs-fixer/config.php +++ b/ci/php-cs-fixer/config.php @@ -43,6 +43,7 @@ 'php_unit_internal_class' => false, 'php_unit_method_casing' => ['case' => 'snake_case'], 'php_unit_test_class_requires_covers' => false, + 'phpdoc_align' => ['align' => 'left'], 'phpdoc_types_order' => ['null_adjustment' => 'always_last'], 'simplified_null_return' => false, 'single_line_comment_style' => false, diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php index f730e77f..2486b609 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -final class GeographyArrayTypeTest extends TestCase +final class GeographyArrayTypeTest extends SpatialArrayTypeTestCase { protected function getTypeName(): string { @@ -20,6 +20,15 @@ protected function getPostgresTypeName(): string return 'GEOGRAPHY[]'; } + protected function getSelectExpression(string $columnName): string + { + return \sprintf( + 'ARRAY(SELECT CASE WHEN ST_SRID(geog::geometry) = 0 THEN ST_AsText(geog::geometry) ELSE \"SRID=\" || ST_SRID(geog::geometry) || \";\" || ST_AsText(geog::geometry) END FROM unnest(\"%s\") AS geog) AS \"%s\"', + $columnName, + $columnName + ); + } + #[DataProvider('provideSingleItemArrays')] #[Test] public function can_handle_single_item_array(array $values): void diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php index bb27fd68..fd15a6b7 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -20,6 +20,11 @@ protected function getPostgresTypeName(): string return 'GEOGRAPHY'; } + protected function getSelectExpression(string $columnName): string + { + return \sprintf('ST_AsEWKT("%s"::geometry) AS "%s"', $columnName, $columnName); + } + #[Test] public function can_handle_null_values(): void { diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php index b68bb264..80385928 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -final class GeometryArrayTypeTest extends TestCase +final class GeometryArrayTypeTest extends SpatialArrayTypeTestCase { protected function getTypeName(): string { @@ -20,6 +20,15 @@ protected function getPostgresTypeName(): string return 'GEOMETRY[]'; } + protected function getSelectExpression(string $columnName): string + { + return \sprintf( + 'ARRAY(SELECT CASE WHEN ST_SRID(geom) = 0 THEN ST_AsText(geom) ELSE \"SRID=\" || ST_SRID(geom) || \";\" || ST_AsText(geom) END FROM unnest(\"%s\") AS geom) AS \"%s\"', + $columnName, + $columnName + ); + } + #[DataProvider('provideSingleItemArrays')] #[Test] public function can_handle_single_item_array(array $values): void diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php index d05a136a..96e3a690 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -20,6 +20,11 @@ protected function getPostgresTypeName(): string return 'GEOMETRY'; } + protected function getSelectExpression(string $columnName): string + { + return \sprintf('ST_AsEWKT("%s") AS "%s"', $columnName, $columnName); + } + #[Test] public function can_handle_null_values(): void { diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php index d61cbaca..7c2bed33 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php @@ -48,12 +48,11 @@ protected function runTypeTest(string $typeName, string $columnType, mixed $test // Query the value back $queryBuilder = $this->connection->createQueryBuilder(); $queryBuilder - ->select($this->getSelectExpressionForType($typeName, $columnName)) + ->select($this->getSelectExpression($columnType)) ->from(self::DATABASE_SCHEMA.'.'.$tableName) ->where('id = 1'); - $result = $queryBuilder->executeQuery(); - $row = $result->fetchAssociative(); + $row = $queryBuilder->executeQuery()->fetchAssociative(); \assert(\is_array($row) && \array_key_exists($columnName, $row)); // Get the value with the correct type @@ -97,15 +96,8 @@ public function type_will_be_registered(): void Type::getType($typeName); } - private function getSelectExpressionForType(string $typeName, string $columnName): string + protected function getSelectExpression(string $columnName): string { - // Ensure we get a text representation for PostGIS types that DBAL might map to resource/stream - return match ($typeName) { - 'geometry' => \sprintf('ST_AsEWKT("%s") AS "%s"', $columnName, $columnName), - 'geography' => \sprintf('ST_AsEWKT("%s"::geometry) AS "%s"', $columnName, $columnName), - 'geometry[]' => \sprintf('ARRAY(SELECT CASE WHEN ST_SRID(geom) = 0 THEN ST_AsText(geom) ELSE \'SRID=\' || ST_SRID(geom) || \';\' || ST_AsText(geom) END FROM unnest("%s") AS geom) AS "%s"', $columnName, $columnName), - 'geography[]' => \sprintf('ARRAY(SELECT CASE WHEN ST_SRID(geog::geometry) = 0 THEN ST_AsText(geog::geometry) ELSE \'SRID=\' || ST_SRID(geog::geometry) || \';\' || ST_AsText(geog::geometry) END FROM unnest("%s") AS geog) AS "%s"', $columnName, $columnName), - default => $columnName, - }; + return $columnName; } } diff --git a/tests/Integration/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php b/tests/Integration/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php index 54bb2434..84112ca1 100644 --- a/tests/Integration/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php +++ b/tests/Integration/MartinGeorgiev/Utils/PostgresArrayToPHPArrayTransformerTest.php @@ -93,7 +93,7 @@ private function createTestTable(): void * @template T * * @param array $params - * @param callable(string): T $transform + * @param callable(string): T $transform * * @return T */ diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php index 5af5b9ee..728c9e21 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php @@ -191,7 +191,7 @@ protected function assertRangeEquals(Range $expected, Range $actual, string $mes /** * Assert that a range contains all the given values. * - * @param Range $range + * @param Range $range * @param array $values */ protected function assertRangeContainsAll(Range $range, array $values, string $message = ''): void @@ -207,7 +207,7 @@ protected function assertRangeContainsAll(Range $range, array $values, string $m /** * Assert that a range does not contain any of the given values. * - * @param Range $range + * @param Range $range * @param array $values */ protected function assertRangeContainsNone(Range $range, array $values, string $message = ''): void @@ -255,7 +255,7 @@ protected function assertRangeIsNotEmpty(Range $range, string $message = ''): vo /** * Test boundary conditions for a range with known bounds. * - * @param Range $range + * @param Range $range * @param array $testCases */ protected function assertBoundaryConditions(Range $range, array $testCases, string $message = ''): void From caab1d8820f90d88416f9647275eef10465d3277 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Thu, 21 Aug 2025 17:08:24 +0300 Subject: [PATCH 14/26] fixes --- .../Doctrine/DBAL/Types/GeographyTypeTest.php | 10 +++++----- .../Doctrine/DBAL/Types/GeometryTypeTest.php | 10 +++++----- .../MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php index fd15a6b7..8f96fdc5 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -45,12 +45,12 @@ public static function provideValidTransformations(): array { return [ 'point' => ['point', WktSpatialData::fromWkt('POINT(1 2)')], - 'linestring' => ['linestring', WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)')], - 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')], - 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))')], + 'linestring' => ['linestring', WktSpatialData::fromWkt('LINESTRING(0 0,1 1,2 2)')], + 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0,0 1,1 1,1 0,0 0))')], + 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2),LINESTRING(0 0,1 1))')], 'point z' => ['point z', WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)')], - 'linestring m' => ['linestring m', WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)')], - 'polygon zm' => ['polygon zm', WktSpatialData::fromWkt('POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))')], + 'linestring m' => ['linestring m', WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1,-122.4094 37.7849 2)')], + 'polygon zm' => ['polygon zm', WktSpatialData::fromWkt('POLYGON ZM((-122.5 37.7 0 1,-122.5 37.8 0 1,-122.4 37.8 0 1,-122.4 37.7 0 1,-122.5 37.7 0 1))')], ]; } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php index 96e3a690..f004425c 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -45,13 +45,13 @@ public static function provideValidTransformations(): array { return [ 'point' => ['point', WktSpatialData::fromWkt('POINT(1 2)')], - 'linestring' => ['linestring', WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)')], - 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')], - 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))')], + 'linestring' => ['linestring', WktSpatialData::fromWkt('LINESTRING(0 0,1 1,2 2)')], + 'polygon' => ['polygon', WktSpatialData::fromWkt('POLYGON((0 0,0 1,1 1,1 0,0 0))')], + 'geometrycollection' => ['geometrycollection', WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2),LINESTRING(0 0,1 1))')], 'point with srid' => ['point with srid', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)')], 'point z' => ['point z', WktSpatialData::fromWkt('POINT Z(1 2 3)')], - 'linestring m' => ['linestring m', WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)')], - 'polygon zm' => ['polygon zm', WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))')], + 'linestring m' => ['linestring m', WktSpatialData::fromWkt('LINESTRING M(0 0 1,1 1 2)')], + 'polygon zm' => ['polygon zm', WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1,0 1 0 1,1 1 0 1,1 0 0 1,0 0 0 1))')], 'point z with srid' => ['point z with srid', WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)')], ]; } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php index 7c2bed33..3299c52d 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php @@ -48,7 +48,7 @@ protected function runTypeTest(string $typeName, string $columnType, mixed $test // Query the value back $queryBuilder = $this->connection->createQueryBuilder(); $queryBuilder - ->select($this->getSelectExpression($columnType)) + ->select($this->getSelectExpression($columnName)) ->from(self::DATABASE_SCHEMA.'.'.$tableName) ->where('id = 1'); From 9ee1f1128c81e4f524f518005260ed2bfad46fd1 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Thu, 21 Aug 2025 20:47:40 +0300 Subject: [PATCH 15/26] complete set of test scenarios (but some cleanup expected):) --- .../Doctrine/DBAL/Types/SpatialDataArray.php | 175 ++++++++++++++++-- .../DBAL/Types/GeographyArrayTypeTest.php | 6 +- .../Doctrine/DBAL/Types/GeographyTypeTest.php | 3 +- .../DBAL/Types/GeometryArrayTypeTest.php | 6 +- .../Doctrine/DBAL/Types/GeometryTypeTest.php | 10 +- .../DBAL/Types/SpatialArrayTypeTestCase.php | 55 ++++++ 6 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php index c32a44f2..be3f3598 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php @@ -5,7 +5,9 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types; use Doctrine\DBAL\Types\ConversionException; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DimensionalModifier; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidWktSpatialDataException; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\GeometryType; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; /** @@ -19,6 +21,59 @@ */ abstract class SpatialDataArray extends BaseArray { + /** + * Get a regex pattern that matches all supported geometry types. + * + * This method dynamically builds the pattern from the GeometryType enum + * to ensure consistency and eliminate duplication. + */ + private function getGeometryTypesPattern(): string + { + $geometryTypes = \array_map( + static fn (GeometryType $geometryType): string => $geometryType->value, + GeometryType::cases() + ); + + return '('.\implode('|', $geometryTypes).')'; + } + + /** + * Build dimensional modifier regex patterns for geometry type normalization. + * + * Uses the DimensionalModifier enum to ensure consistency and eliminate duplication. + * + * @return array Array of regex pattern => replacement pairs + */ + private function getDimensionalModifierPatterns(): array + { + $geometryTypesPattern = $this->getGeometryTypesPattern(); + $modifierValues = \array_map( + static fn (DimensionalModifier $dimensionalModifier): string => $dimensionalModifier->value, + DimensionalModifier::cases() + ); + $modifiersPattern = '('.\implode('|', $modifierValues).')'; + + return [ + // Handle no-space format (POINTZM -> POINT ZM, POINTZ -> POINT Z, POINTM -> POINT M) + \sprintf('/^%sZM\b/', $geometryTypesPattern) => '$1 ZM', + \sprintf('/^%sZ\b/', $geometryTypesPattern) => '$1 Z', + \sprintf('/^%sM\b/', $geometryTypesPattern) => '$1 M', + // Handle ST_AsText extra space format (POINT Z (1 2 3) -> POINT Z(1 2 3)) + \sprintf('/^%s\s+%s\s+\(/', $geometryTypesPattern, $modifiersPattern) => '$1 $2(', + // Handle multiple spaces (POINT Z -> POINT Z) + \sprintf('/^%s\s+%s\b/', $geometryTypesPattern, $modifiersPattern) => '$1 $2', + ]; + } + + protected function getValidatedArrayItem(mixed $item): WktSpatialData + { + if ($this->isValidArrayItemForDatabase($item)) { + return $item; // @phpstan-ignore-line + } + + throw $this->createInvalidTypeExceptionForPHP($item); + } + /** * Transforms a PostgreSQL array containing WKT/EWKT geometries to a PHP array. * @@ -34,18 +89,86 @@ protected function transformPostgresArrayToPHPArray(string $postgresArray): arra return []; } + // Handle quoted array format: {"item1","item2","item3"} + $isQuotedArray = \str_starts_with($trimmedArray, '{"') && \str_ends_with($trimmedArray, '"}'); + if ($isQuotedArray) { + $arrayContentWithoutBraces = \substr($trimmedArray, 2, -2); + if ($arrayContentWithoutBraces === '') { + return []; + } + + return $this->parseQuotedWktArray($arrayContentWithoutBraces); + } + + // Handle unquoted array format: {item1,item2,item3} (fallback for backward compatibility) $arrayContentWithoutBraces = \substr($trimmedArray, 1, -1); if ($arrayContentWithoutBraces === '') { return []; } + return $this->parseUnquotedWktArray($arrayContentWithoutBraces); + } + + private function parseQuotedWktArray(string $content): array + { + $wktItems = []; + $currentWktItem = ''; + $nestedBracketDepth = 0; + $contentLength = \strlen($content); + $charIndex = 0; + + while ($charIndex < $contentLength) { + $currentChar = $content[$charIndex]; + + // Track nested parentheses within the quoted WKT + if ($currentChar === '(') { + $nestedBracketDepth++; + $currentWktItem .= $currentChar; + } elseif ($currentChar === ')') { + $nestedBracketDepth--; + $currentWktItem .= $currentChar; + } elseif ($currentChar === '"' && $nestedBracketDepth === 0) { + // Found end quote at top level - this ends the current item + if ($currentWktItem !== '') { + $wktItems[] = $currentWktItem; + $currentWktItem = ''; + } + + // Skip the quote and comma separator: "," + $charIndex++; // Skip the quote + if ($charIndex < $contentLength && $content[$charIndex] === ',') { + $charIndex++; // Skip the comma + } + + if ($charIndex < $contentLength && $content[$charIndex] === '"') { + $charIndex++; // Skip the opening quote of next item + } + + continue; + } else { + $currentWktItem .= $currentChar; + } + + $charIndex++; + } + + // Add the last item if there's content + if ($currentWktItem !== '') { + $wktItems[] = $currentWktItem; + } + + return $wktItems; + } + + private function parseUnquotedWktArray(string $content): array + { $wktItems = []; $nestedBracketDepth = 0; $currentWktItem = ''; - $contentLength = \strlen($arrayContentWithoutBraces); + $contentLength = \strlen($content); for ($charIndex = 0; $charIndex < $contentLength; $charIndex++) { - $currentChar = $arrayContentWithoutBraces[$charIndex]; + $currentChar = $content[$charIndex]; // Track opening brackets/parentheses to handle nested WKT structures if ($currentChar === '(' || $currentChar === '{') { @@ -82,19 +205,11 @@ protected function transformPostgresArrayToPHPArray(string $postgresArray): arra return \array_map('trim', $wktItems); } - /** - * Validates that an array item is suitable for database storage. - * - * For WKT spatial data arrays, items must be WktSpatialData instances. - */ public function isValidArrayItemForDatabase(mixed $item): bool { return $item instanceof WktSpatialData; } - /** - * Transforms PostgreSQL array item to a PHP compatible array item. - */ public function transformArrayItemForPHP(mixed $item): ?WktSpatialData { if ($item === null) { @@ -106,12 +221,50 @@ public function transformArrayItemForPHP(mixed $item): ?WktSpatialData } try { - return WktSpatialData::fromWkt($item); + $normalizedWkt = $this->normalizePostgreSQLDimensionalModifiers($item); + + return WktSpatialData::fromWkt($normalizedWkt); } catch (InvalidWktSpatialDataException) { throw $this->createInvalidFormatExceptionForPHP($item); } } + /** + * Normalize PostgreSQL dimensional modifier format to standard WKT format. + * + * PostgreSQL can return dimensional modifiers in different formats: + * - ST_AsEWKT(): POINTZ, POINTM, POINTZM (no spaces) + * - ST_AsText(): POINT Z, POINT M, POINT ZM (with spaces) + * - Hybrid approach: SRID=4326;POINT Z (1 2 3) (SRID + extra space) + * + * This method normalizes all formats to the standard WKT format using + * patterns dynamically built from the GeometryType and DimensionalModifier enums. + */ + private function normalizePostgreSQLDimensionalModifiers(string $wkt): string + { + // Handle SRID prefix if present + $sridPrefix = ''; + $hasSrid = \str_starts_with($wkt, 'SRID='); + if ($hasSrid) { + $sridEnd = \strpos($wkt, ';'); + if ($sridEnd === false) { + throw new \RuntimeException(); + } + + $sridPrefix = \substr($wkt, 0, $sridEnd + 1); + $wkt = \substr($wkt, $sridEnd + 1); + } + + // Normalize dimensional modifiers using patterns built from WktGeometryType enum + $patterns = $this->getDimensionalModifierPatterns(); + + foreach ($patterns as $pattern => $replacement) { + $wkt = \preg_replace($pattern, $replacement, (string) $wkt); + } + + return $sridPrefix.$wkt; + } + /** * Creates an exception for invalid type during PHP conversion. * Subclasses should override this to provide specific exception types. diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php index 2486b609..c9a7c20e 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php @@ -23,7 +23,8 @@ protected function getPostgresTypeName(): string protected function getSelectExpression(string $columnName): string { return \sprintf( - 'ARRAY(SELECT CASE WHEN ST_SRID(geog::geometry) = 0 THEN ST_AsText(geog::geometry) ELSE \"SRID=\" || ST_SRID(geog::geometry) || \";\" || ST_AsText(geog::geometry) END FROM unnest(\"%s\") AS geog) AS \"%s\"', + 'ARRAY(SELECT CASE WHEN ST_SRID(geog::geometry) = 0 THEN ST_AsText(geog::geometry) ELSE ' + ."'SRID=' || ST_SRID(geog::geometry) || ';' || ST_AsText(geog::geometry) END FROM unnest(\"%s\") AS geog) AS \"%s\"", $columnName, $columnName ); @@ -83,8 +84,7 @@ public static function provideSingleItemArrays(): array #[Test] public function can_handle_multi_item_array(array $phpArray): void { - $wkts = \array_values(\array_map(static fn ($v): string => (string) $v, $phpArray)); - $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wkts, 'geography'); + $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), 'geography', ...$phpArray); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php index 8f96fdc5..ac226333 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -22,7 +22,8 @@ protected function getPostgresTypeName(): string protected function getSelectExpression(string $columnName): string { - return \sprintf('ST_AsEWKT("%s"::geometry) AS "%s"', $columnName, $columnName); + // For geography, avoid adding SRID prefix to preserve original input format + return \sprintf('ST_AsText("%s"::geometry) AS "%s"', $columnName, $columnName); } #[Test] diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php index 80385928..74652ec4 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php @@ -23,7 +23,8 @@ protected function getPostgresTypeName(): string protected function getSelectExpression(string $columnName): string { return \sprintf( - 'ARRAY(SELECT CASE WHEN ST_SRID(geom) = 0 THEN ST_AsText(geom) ELSE \"SRID=\" || ST_SRID(geom) || \";\" || ST_AsText(geom) END FROM unnest(\"%s\") AS geom) AS \"%s\"', + 'ARRAY(SELECT CASE WHEN ST_SRID(geom) = 0 THEN ST_AsText(geom) ELSE ' + ."'SRID=' || ST_SRID(geom) || ';' || ST_AsText(geom) END FROM unnest(\"%s\") AS geom) AS \"%s\"", $columnName, $columnName ); @@ -83,8 +84,7 @@ public static function provideSingleItemArrays(): array #[Test] public function can_handle_multi_item_array(array $phpArray): void { - $wktsAsString = \array_values(\array_map(static fn ($v): string => (string) $v, $phpArray)); - $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wktsAsString, 'geometry'); + $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), 'geometry', ...$phpArray); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php index f004425c..4e70e0dd 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -22,7 +22,15 @@ protected function getPostgresTypeName(): string protected function getSelectExpression(string $columnName): string { - return \sprintf('ST_AsEWKT("%s") AS "%s"', $columnName, $columnName); + return \sprintf( + 'CASE WHEN ST_SRID("%s") = 0 THEN ST_AsText("%s") ELSE ' + ."'SRID=' || ST_SRID(\"%s\") || ';' || ST_AsText(\"%s\") END AS \"%s\"", + $columnName, + $columnName, + $columnName, + $columnName, + $columnName + ); } #[Test] diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php new file mode 100644 index 00000000..350c753b --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php @@ -0,0 +1,55 @@ +createTestTableForDataType($tableName, $columnName, $columnType); + + $placeholders = \implode(',', \array_fill(0, \count($spatialData), '?::'.$elementPgType)); + $sql = \sprintf( + 'INSERT INTO %s.%s ("%s") VALUES (ARRAY[%s])', + self::DATABASE_SCHEMA, + $tableName, + $columnName, + $placeholders + ); + + $stringifiedWkts = \array_map(static fn (WktSpatialData $wktSpatialData): string => (string) $wktSpatialData, $spatialData); + $this->connection->executeStatement($sql, \array_values($stringifiedWkts)); + + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder + ->select($this->getSelectExpression($columnName)) + ->from(self::DATABASE_SCHEMA.'.'.$tableName) + ->where('id = 1'); + + $row = $queryBuilder->executeQuery()->fetchAssociative(); + \assert(\is_array($row) && \array_key_exists($columnName, $row)); + + // Get the value with the correct type + $platform = $this->connection->getDatabasePlatform(); + $retrievedValue = Type::getType($typeName)->convertToPHPValue($row[$columnName], $platform); + + $this->assertTypeValueEquals($spatialData, $retrievedValue, $typeName); + } finally { + $this->dropTestTableIfItExists($tableName); + } + } +} From 63e13507580c29ed8d85a56bb238180a348d6d90 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Fri, 22 Aug 2025 00:18:19 +0300 Subject: [PATCH 16/26] final touches --- .../Doctrine/DBAL/Types/SpatialDataArray.php | 16 ++-- .../Doctrine/DBAL/Types/ArrayTypeTestCase.php | 6 +- .../Doctrine/DBAL/Types/CidrArrayTypeTest.php | 2 +- .../Doctrine/DBAL/Types/CidrTypeTest.php | 4 +- .../Doctrine/DBAL/Types/DateRangeTypeTest.php | 4 +- .../DBAL/Types/GeographyArrayTypeTest.php | 2 +- .../Doctrine/DBAL/Types/GeographyTypeTest.php | 4 +- .../DBAL/Types/GeometryArrayTypeTest.php | 4 +- .../Doctrine/DBAL/Types/GeometryTypeTest.php | 4 +- .../Doctrine/DBAL/Types/InetArrayTypeTest.php | 2 +- .../Doctrine/DBAL/Types/InetTypeTest.php | 6 +- .../Doctrine/DBAL/Types/Int4RangeTypeTest.php | 4 +- .../Doctrine/DBAL/Types/Int8RangeTypeTest.php | 4 +- .../Doctrine/DBAL/Types/JsonbTypeTest.php | 6 +- .../DBAL/Types/MacaddrArrayTypeTest.php | 2 +- .../Doctrine/DBAL/Types/MacaddrTypeTest.php | 4 +- .../Doctrine/DBAL/Types/NumRangeTypeTest.php | 4 +- .../Doctrine/DBAL/Types/PointTypeTest.php | 4 +- .../Doctrine/DBAL/Types/RangeTypeTestCase.php | 4 +- .../DBAL/Types/ScalarTypeTestCase.php | 2 +- .../DBAL/Types/SpatialArrayTypeTestCase.php | 76 +++++++++------- .../Doctrine/DBAL/Types/TestCase.php | 87 +++++++++++++------ .../Doctrine/DBAL/Types/TsRangeTypeTest.php | 4 +- .../Doctrine/DBAL/Types/TstzRangeTypeTest.php | 4 +- 24 files changed, 149 insertions(+), 110 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php index be3f3598..e7c1bcd4 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php @@ -54,13 +54,11 @@ private function getDimensionalModifierPatterns(): array $modifiersPattern = '('.\implode('|', $modifierValues).')'; return [ - // Handle no-space format (POINTZM -> POINT ZM, POINTZ -> POINT Z, POINTM -> POINT M) - \sprintf('/^%sZM\b/', $geometryTypesPattern) => '$1 ZM', - \sprintf('/^%sZ\b/', $geometryTypesPattern) => '$1 Z', - \sprintf('/^%sM\b/', $geometryTypesPattern) => '$1 M', - // Handle ST_AsText extra space format (POINT Z (1 2 3) -> POINT Z(1 2 3)) + // No-space variants: POINTZM/POINTZ/POINTM -> POINT ZM|Z|M (built from enum) + \sprintf('/^%s%s\b/', $geometryTypesPattern, $modifiersPattern) => '$1 $2', + // ST_AsText extra space format: POINT Z ( -> POINT Z( \sprintf('/^%s\s+%s\s+\(/', $geometryTypesPattern, $modifiersPattern) => '$1 $2(', - // Handle multiple spaces (POINT Z -> POINT Z) + // Multiple spaces: POINT Z -> POINT Z \sprintf('/^%s\s+%s\b/', $geometryTypesPattern, $modifiersPattern) => '$1 $2', ]; } @@ -248,7 +246,7 @@ private function normalizePostgreSQLDimensionalModifiers(string $wkt): string if ($hasSrid) { $sridEnd = \strpos($wkt, ';'); if ($sridEnd === false) { - throw new \RuntimeException(); + throw InvalidWktSpatialDataException::forMissingSemicolonInEwkt(); } $sridPrefix = \substr($wkt, 0, $sridEnd + 1); @@ -256,9 +254,7 @@ private function normalizePostgreSQLDimensionalModifiers(string $wkt): string } // Normalize dimensional modifiers using patterns built from WktGeometryType enum - $patterns = $this->getDimensionalModifierPatterns(); - - foreach ($patterns as $pattern => $replacement) { + foreach ($this->getDimensionalModifierPatterns() as $pattern => $replacement) { $wkt = \preg_replace($pattern, $replacement, (string) $wkt); } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ArrayTypeTestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ArrayTypeTestCase.php index 9ecc1488..2dbaa118 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ArrayTypeTestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ArrayTypeTestCase.php @@ -14,7 +14,7 @@ public function can_handle_empty_array(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, []); + $this->runDbalBindingRoundTrip($typeName, $columnType, []); } #[Test] @@ -23,7 +23,7 @@ public function can_handle_null_values(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, null); + $this->runDbalBindingRoundTrip($typeName, $columnType, null); } /** @@ -35,6 +35,6 @@ public function can_handle_array_values(string $testName, array $arrayValue): vo $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, $arrayValue); + $this->runDbalBindingRoundTrip($typeName, $columnType, $arrayValue); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrArrayTypeTest.php index 6e91bf65..79fa8f7b 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrArrayTypeTest.php @@ -58,6 +58,6 @@ public function can_handle_invalid_networks(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, ['invalid-network', '192.168.1.0/24']); + $this->runDbalBindingRoundTrip($typeName, $columnType, ['invalid-network', '192.168.1.0/24']); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrTypeTest.php index 51589f82..3d8481e7 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/CidrTypeTest.php @@ -27,7 +27,7 @@ public function can_transform_from_php_value(string $testValue): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, $testValue); + $this->runDbalBindingRoundTrip($typeName, $columnType, $testValue); } /** @@ -53,6 +53,6 @@ public function can_handle_invalid_networks(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, 'invalid-network'); + $this->runDbalBindingRoundTrip($typeName, $columnType, 'invalid-network'); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTypeTest.php index 6bfc7eba..6083aaff 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTypeTest.php @@ -68,7 +68,7 @@ public function can_handle_infinite_ranges(): void $columnType = $this->getPostgresTypeName(); $dateRange = new DateRangeValueObject(null, null, false, false); - $this->runTypeTest($typeName, $columnType, $dateRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $dateRange); } #[Test] @@ -84,7 +84,7 @@ public function can_handle_empty_ranges(): void false, false ); - $this->runTypeTest($typeName, $columnType, $dateRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $dateRange); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php index c9a7c20e..cf5b9eb7 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php @@ -34,7 +34,7 @@ protected function getSelectExpression(string $columnName): string #[Test] public function can_handle_single_item_array(array $values): void { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $values); + $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $values); } public static function provideSingleItemArrays(): array diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php index ac226333..476f243f 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -29,14 +29,14 @@ protected function getSelectExpression(string $columnName): string #[Test] public function can_handle_null_values(): void { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); + $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), null); } #[DataProvider('provideValidTransformations')] #[Test] public function can_handle_geography_values(string $testName, WktSpatialData $wktSpatialData): void { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php index 74652ec4..c9d7fbf6 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php @@ -34,7 +34,7 @@ protected function getSelectExpression(string $columnName): string #[Test] public function can_handle_single_item_array(array $values): void { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $values); + $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $values); } public static function provideSingleItemArrays(): array @@ -104,7 +104,7 @@ public static function provideMultiItemArrays(): array ]], 'mixed types' => [[ WktSpatialData::fromWkt('POINT(0 0)'), - WktSpatialData::fromWkt('SRID=4326;POINT Z(1 2 3)'), + WktSpatialData::fromWkt('SRID=3035;POINT Z(1 2 3)'), WktSpatialData::fromWkt('LINESTRING M(0 0 1,1 1 2)'), WktSpatialData::fromWkt('SRID=3857;POLYGON((0 0,0 1000,1000 1000,1000 0,0 0))'), WktSpatialData::fromWkt('MULTIPOINT((1 2),(3 4))'), diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php index 4e70e0dd..16f8f16a 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -36,14 +36,14 @@ protected function getSelectExpression(string $columnName): string #[Test] public function can_handle_null_values(): void { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), null); + $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), null); } #[DataProvider('provideValidTransformations')] #[Test] public function can_handle_geometry_values(string $testName, WktSpatialData $wktSpatialData): void { - $this->runTypeTest($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetArrayTypeTest.php index 9bf36aae..39fee804 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetArrayTypeTest.php @@ -57,6 +57,6 @@ public function can_handle_invalid_addresses(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, ['invalid-address', '192.168.1.1']); + $this->runDbalBindingRoundTrip($typeName, $columnType, ['invalid-address', '192.168.1.1']); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetTypeTest.php index 8567d896..08bb2623 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/InetTypeTest.php @@ -27,7 +27,7 @@ public function can_transform_from_php_value(string $testValue): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, $testValue); + $this->runDbalBindingRoundTrip($typeName, $columnType, $testValue); } /** @@ -53,7 +53,7 @@ public function can_handle_invalid_addresses(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, 'invalid-address'); + $this->runDbalBindingRoundTrip($typeName, $columnType, 'invalid-address'); } #[Test] @@ -64,6 +64,6 @@ public function can_handle_empty_string(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, ''); + $this->runDbalBindingRoundTrip($typeName, $columnType, ''); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTypeTest.php index d6705ffa..97bb08df 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTypeTest.php @@ -48,7 +48,7 @@ public function can_handle_infinite_ranges(): void $columnType = $this->getPostgresTypeName(); $int4Range = new Int4RangeValueObject(null, null, false, false); - $this->runTypeTest($typeName, $columnType, $int4Range); + $this->runDbalBindingRoundTrip($typeName, $columnType, $int4Range); } #[Test] @@ -59,7 +59,7 @@ public function can_handle_empty_ranges(): void // lower > upper shall result in an empty range $int4Range = new Int4RangeValueObject(10, 5, false, false); - $this->runTypeTest($typeName, $columnType, $int4Range); + $this->runDbalBindingRoundTrip($typeName, $columnType, $int4Range); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTypeTest.php index 2624b6a0..3ec2b440 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTypeTest.php @@ -48,7 +48,7 @@ public function can_handle_infinite_ranges(): void $columnType = $this->getPostgresTypeName(); $int8Range = new Int8RangeValueObject(null, null, false, false); - $this->runTypeTest($typeName, $columnType, $int8Range); + $this->runDbalBindingRoundTrip($typeName, $columnType, $int8Range); } #[Test] @@ -59,7 +59,7 @@ public function can_handle_empty_ranges(): void // lower > upper shall result in an empty range $int8Range = new Int8RangeValueObject(10, 5, false, false); - $this->runTypeTest($typeName, $columnType, $int8Range); + $this->runDbalBindingRoundTrip($typeName, $columnType, $int8Range); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbTypeTest.php index f7e50063..7828ab6e 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/JsonbTypeTest.php @@ -25,7 +25,7 @@ public function can_handle_null_values(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, null); + $this->runDbalBindingRoundTrip($typeName, $columnType, null); } #[Test] @@ -34,7 +34,7 @@ public function can_handle_empty_arrays(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, []); + $this->runDbalBindingRoundTrip($typeName, $columnType, []); } #[DataProvider('provideValidTransformations')] @@ -44,7 +44,7 @@ public function can_handle_json_values(string $testName, array $json): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, $json); + $this->runDbalBindingRoundTrip($typeName, $columnType, $json); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrArrayTypeTest.php index 6c179083..f4ee44f0 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrArrayTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrArrayTypeTest.php @@ -55,6 +55,6 @@ public function can_handle_invalid_addresses(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, ['invalid-mac', '08:00:2b:01:02:03']); + $this->runDbalBindingRoundTrip($typeName, $columnType, ['invalid-mac', '08:00:2b:01:02:03']); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrTypeTest.php index cdf392c2..d58678d9 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/MacaddrTypeTest.php @@ -27,7 +27,7 @@ public function can_transform_from_php_value(string $testValue): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, $testValue); + $this->runDbalBindingRoundTrip($typeName, $columnType, $testValue); } /** @@ -52,6 +52,6 @@ public function can_handle_invalid_addresses(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, 'invalid-mac'); + $this->runDbalBindingRoundTrip($typeName, $columnType, 'invalid-mac'); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTypeTest.php index 7329427f..0aaf7ad9 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTypeTest.php @@ -48,7 +48,7 @@ public function can_handle_infinite_ranges(): void $columnType = $this->getPostgresTypeName(); $numericRange = new NumRangeValueObject(null, null, false, false); - $this->runTypeTest($typeName, $columnType, $numericRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $numericRange); } #[Test] @@ -59,7 +59,7 @@ public function can_handle_empty_ranges(): void // lower > upper shall result in an empty range $numericRange = new NumRangeValueObject(10.5, 5.7, false, false); - $this->runTypeTest($typeName, $columnType, $numericRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $numericRange); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PointTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PointTypeTest.php index 48d0d134..97926d77 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PointTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PointTypeTest.php @@ -28,7 +28,7 @@ public function can_handle_null_values(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, null); + $this->runDbalBindingRoundTrip($typeName, $columnType, null); } /** @@ -50,7 +50,7 @@ public function can_handle_point_values(string $testName, PointValueObject $poin $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, $pointValueObject); + $this->runDbalBindingRoundTrip($typeName, $columnType, $pointValueObject); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypeTestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypeTestCase.php index b02e9ed8..09c7fc8e 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypeTestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypeTestCase.php @@ -129,7 +129,7 @@ public function can_handle_null_values(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, null); + $this->runDbalBindingRoundTrip($typeName, $columnType, null); } /** @@ -143,7 +143,7 @@ public function can_handle_range_values(string $testName, RangeValueObject $rang $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, $rangeValueObject); + $this->runDbalBindingRoundTrip($typeName, $columnType, $rangeValueObject); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ScalarTypeTestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ScalarTypeTestCase.php index f2e80d56..e92401dc 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ScalarTypeTestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ScalarTypeTestCase.php @@ -14,6 +14,6 @@ public function can_handle_null_values(): void $typeName = $this->getTypeName(); $columnType = $this->getPostgresTypeName(); - $this->runTypeTest($typeName, $columnType, null); + $this->runDbalBindingRoundTrip($typeName, $columnType, null); } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php index 350c753b..4e424d87 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php @@ -4,52 +4,62 @@ namespace Tests\Integration\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; abstract class SpatialArrayTypeTestCase extends TestCase { /** - * Insert an array of spatial WKTs using ARRAY[...] with per-element casts, then retrieve and assert. + * Perform a round-trip using ARRAY[...] constructor for insertion for spatial WKT arrays. * - * @param non-empty-string $elementPgType 'geometry' or 'geography' + * @param non-empty-string $elementPgType */ - protected function runArrayConstructorTypeTest(string $typeName, string $columnType, string $elementPgType, WktSpatialData ...$spatialData): void + protected function runArrayConstructorRoundTrip(string $typeName, string $columnType, string $elementPgType, WktSpatialData ...$spatialData): void { - $tableName = 'test_type_'.\strtolower(\str_replace([' ', '[]', '()'], ['_', '_array', ''], $columnType)).'_ctor'; - $columnName = 'test_column'; + [$tableName, $columnName] = $this->prepareTestTable($columnType); try { - $this->createTestTableForDataType($tableName, $columnName, $columnType); - $placeholders = \implode(',', \array_fill(0, \count($spatialData), '?::'.$elementPgType)); - $sql = \sprintf( - 'INSERT INTO %s.%s ("%s") VALUES (ARRAY[%s])', - self::DATABASE_SCHEMA, - $tableName, - $columnName, - $placeholders - ); - - $stringifiedWkts = \array_map(static fn (WktSpatialData $wktSpatialData): string => (string) $wktSpatialData, $spatialData); - $this->connection->executeStatement($sql, \array_values($stringifiedWkts)); - - $queryBuilder = $this->connection->createQueryBuilder(); - $queryBuilder - ->select($this->getSelectExpression($columnName)) - ->from(self::DATABASE_SCHEMA.'.'.$tableName) - ->where('id = 1'); - - $row = $queryBuilder->executeQuery()->fetchAssociative(); - \assert(\is_array($row) && \array_key_exists($columnName, $row)); - - // Get the value with the correct type - $platform = $this->connection->getDatabasePlatform(); - $retrievedValue = Type::getType($typeName)->convertToPHPValue($row[$columnName], $platform); - - $this->assertTypeValueEquals($spatialData, $retrievedValue, $typeName); + $sql = \sprintf('INSERT INTO %s.%s ("%s") VALUES (ARRAY[%s])', self::DATABASE_SCHEMA, $tableName, $columnName, $placeholders); + /** @var list $params */ + $params = \array_values(\array_map(static fn (WktSpatialData $wktSpatialData): string => (string) $wktSpatialData, $spatialData)); + $this->connection->executeStatement($sql, $params); + + $retrieved = $this->fetchConvertedValue($typeName, $tableName, $columnName); + $this->assertRoundTrip($typeName, $spatialData, $retrieved); } finally { $this->dropTestTableIfItExists($tableName); } } + + /** + * Insert an array of spatial WKTs using ARRAY[...] with per-element casts, then retrieve and assert. + * + * @param non-empty-string $elementPgType 'geometry' or 'geography' + */ + protected function runArrayConstructorTypeTest(string $typeName, string $columnType, string $elementPgType, WktSpatialData ...$spatialData): void + { + $this->runArrayConstructorRoundTrip($typeName, $columnType, $elementPgType, ...$spatialData); + } + + protected function assertTypeValueEquals(mixed $expected, mixed $actual, string $typeName): void + { + \assert(\is_array($expected) && \is_array($actual)); + + $toString = static fn (WktSpatialData $wktSpatialData): string => (string) $wktSpatialData; + + /** @var list $expected */ + /** @var list $expectedStrings */ + $expectedStrings = \array_values(\array_map($toString, $expected)); + + /** @var list $actual */ + /** @var list $actualStrings */ + $actualStrings = \array_values(\array_map($toString, $actual)); + + $stripDefaultSrid = static fn (string $wkt): string => \str_starts_with($wkt, 'SRID=4326;') ? \substr($wkt, 10) : $wkt; + + $expectedStrings = \array_map($stripDefaultSrid, $expectedStrings); + $actualStrings = \array_map($stripDefaultSrid, $actualStrings); + + $this->assertEquals($expectedStrings, $actualStrings, \sprintf('Array type %s round-trip failed', $typeName)); + } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php index 3299c52d..9617569b 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php @@ -28,44 +28,77 @@ protected function assertTypeValueEquals(mixed $expected, mixed $actual, string }; } - protected function runTypeTest(string $typeName, string $columnType, mixed $testValue): void + /** + * Build a deterministic table name for the given column type and optional suffix. + */ + protected function buildTableName(string $columnType): string + { + return 'test_type_'.\strtolower(\str_replace([' ', '[]', '()'], ['_', '_array', ''], $columnType)); + } + + /** + * Prepare a test table for a round trip and return the [tableName, columnName]. + * Caller is responsible to drop the table (typically in a finally block). + * + * @return array{string,string} + */ + protected function prepareTestTable(string $columnType): array { - $tableName = 'test_type_'.\strtolower(\str_replace([' ', '[]', '()'], ['_', '_array', ''], $columnType)); + $tableName = $this->buildTableName($columnType); $columnName = 'test_column'; + $this->createTestTableForDataType($tableName, $columnName, $columnType); - try { - $this->createTestTableForDataType($tableName, $columnName, $columnType); + return [$tableName, $columnName]; + } - // Insert test value using proper type conversion - $queryBuilder = $this->connection->createQueryBuilder(); - $queryBuilder - ->insert(self::DATABASE_SCHEMA.'.'.$tableName) - ->values([$columnName => ':value']) - ->setParameter('value', $testValue, $typeName); + /** + * Read value back from the DB, convert using DBAL type and return it. + */ + protected function fetchConvertedValue(string $typeName, string $tableName, string $columnName): mixed + { + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select($this->getSelectExpression($columnName)) + ->from(self::DATABASE_SCHEMA.'.'.$tableName) + ->where('id = 1'); - $queryBuilder->executeStatement(); + $row = $queryBuilder->executeQuery()->fetchAssociative(); + \assert(\is_array($row) && \array_key_exists($columnName, $row)); - // Query the value back - $queryBuilder = $this->connection->createQueryBuilder(); - $queryBuilder - ->select($this->getSelectExpression($columnName)) - ->from(self::DATABASE_SCHEMA.'.'.$tableName) - ->where('id = 1'); + $platform = $this->connection->getDatabasePlatform(); - $row = $queryBuilder->executeQuery()->fetchAssociative(); - \assert(\is_array($row) && \array_key_exists($columnName, $row)); + return Type::getType($typeName)->convertToPHPValue($row[$columnName], $platform); + } - // Get the value with the correct type - $platform = $this->connection->getDatabasePlatform(); - $retrievedValue = Type::getType($typeName)->convertToPHPValue($row[$columnName], $platform); + /** + * Assert the round trip value, allowing nulls. + */ + protected function assertRoundTrip(string $typeName, mixed $expected, mixed $retrieved): void + { + if ($expected === null) { + $this->assertNull($retrieved); - if ($testValue === null) { - $this->assertNull($retrievedValue); + return; + } + + $this->assertTypeValueEquals($expected, $retrieved, $typeName); + } - return; - } + /** + * Perform a round-trip using DBAL parameter binding for insertion. + */ + protected function runDbalBindingRoundTrip(string $typeName, string $columnType, mixed $value): void + { + [$tableName, $columnName] = $this->prepareTestTable($columnType); + + try { + $qb = $this->connection->createQueryBuilder(); + $qb->insert(self::DATABASE_SCHEMA.'.'.$tableName) + ->values([$columnName => ':value']) + ->setParameter('value', $value, $typeName) + ->executeStatement(); - $this->assertTypeValueEquals($testValue, $retrievedValue, $typeName); + $retrieved = $this->fetchConvertedValue($typeName, $tableName, $columnName); + $this->assertRoundTrip($typeName, $value, $retrieved); } finally { $this->dropTestTableIfItExists($tableName); } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTypeTest.php index 0985b8f6..c5a97b1e 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTypeTest.php @@ -68,7 +68,7 @@ public function can_handle_infinite_ranges(): void $columnType = $this->getPostgresTypeName(); $tsRange = new TsRangeValueObject(null, null, false, false); - $this->runTypeTest($typeName, $columnType, $tsRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $tsRange); } #[Test] @@ -84,7 +84,7 @@ public function can_handle_empty_ranges(): void false, false ); - $this->runTypeTest($typeName, $columnType, $tsRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $tsRange); } /** diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTypeTest.php index 4ec2cb49..324652dd 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTypeTest.php @@ -62,7 +62,7 @@ public function can_handle_infinite_ranges(): void $columnType = $this->getPostgresTypeName(); $tstzRange = new TstzRangeValueObject(null, null, false, false); - $this->runTypeTest($typeName, $columnType, $tstzRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $tstzRange); } #[Test] @@ -78,7 +78,7 @@ public function can_handle_empty_ranges(): void false, false ); - $this->runTypeTest($typeName, $columnType, $tstzRange); + $this->runDbalBindingRoundTrip($typeName, $columnType, $tstzRange); } /** From e69e4120df298cc7dbb1052dd1cc3d321b0e3926 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Fri, 22 Aug 2025 01:21:21 +0300 Subject: [PATCH 17/26] increase test coverage --- .../Doctrine/DBAL/Types/SpatialDataArray.php | 8 +++--- .../DBAL/Types/GeographyArrayTest.php | 26 +++++++++++++++++++ .../Doctrine/DBAL/Types/GeometryArrayTest.php | 25 ++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php index e7c1bcd4..da8bf615 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php @@ -244,13 +244,13 @@ private function normalizePostgreSQLDimensionalModifiers(string $wkt): string $sridPrefix = ''; $hasSrid = \str_starts_with($wkt, 'SRID='); if ($hasSrid) { - $sridEnd = \strpos($wkt, ';'); - if ($sridEnd === false) { + $sridSeparatorPosition = \strpos($wkt, ';'); + if ($sridSeparatorPosition === false) { throw InvalidWktSpatialDataException::forMissingSemicolonInEwkt(); } - $sridPrefix = \substr($wkt, 0, $sridEnd + 1); - $wkt = \substr($wkt, $sridEnd + 1); + $sridPrefix = \substr($wkt, 0, $sridSeparatorPosition + 1); + $wkt = \substr($wkt, $sridSeparatorPosition + 1); } // Normalize dimensional modifiers using patterns built from WktGeometryType enum diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php index a66fd357..3b590dc5 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php @@ -174,6 +174,32 @@ public static function provideValidPostgresArraysForPHP(): array '{POINT Z(-122.4194 37.7749 100),LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)}', ['POINT Z(-122.4194 37.7749 100)', 'LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'], ], + // Normalization coverage: no-space and spacing variants + 'no-space POINTZ geography' => [ + '{POINTZ(-122.4194 37.7749 100)}', + ['POINT Z(-122.4194 37.7749 100)'], + ], + 'no-space LINESTRINGM geography' => [ + '{LINESTRINGM(-122.4194 37.7749 1, -122.4094 37.7849 2)}', + ['LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'], + ], + 'no-space POLYGONZM geography' => [ + '{POLYGONZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))}', + ['POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'], + ], + 'extra space before parentheses geography' => [ + '{POINT Z (-122.4194 37.7749 100)}', + ['POINT Z(-122.4194 37.7749 100)'], + ], + 'multiple spaces between type and modifier geography' => [ + '{POINT Z(-122.4194 37.7749 100)}', + ['POINT Z(-122.4194 37.7749 100)'], + ], + 'srid with extra space before parentheses geography' => [ + '{SRID=4326;POINT Z (-122.4194 37.7749 100)}', + ['SRID=4326;POINT Z(-122.4194 37.7749 100)'], + ], + 'geographic areas with srid' => [ '{SRID=4326;POINT(-122.4194 37.7749),SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))}', ['SRID=4326;POINT(-122.4194 37.7749)', 'SRID=4326;POLYGON((-122.5 37.7, -122.5 37.8, -122.4 37.8, -122.4 37.7, -122.5 37.7))'], diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php index f34a0890..7bb174ef 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php @@ -173,6 +173,31 @@ public static function provideValidPostgresArraysForPHP(): array '{POLYGON((0 0, 0 1, 1 1, 1 0, 0 0)),MULTIPOINT((1 2), (3 4))}', ['POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 'MULTIPOINT((1 2), (3 4))'], ], + // Normalization coverage for no-space and extra-space dimensional modifiers + 'no-space POINTZ' => [ + '{POINTZ(1 2 3)}', + ['POINT Z(1 2 3)'], + ], + 'no-space LINESTRINGM' => [ + '{LINESTRINGM(0 0 1, 1 1 2)}', + ['LINESTRING M(0 0 1, 1 1 2)'], + ], + 'no-space POLYGONZM' => [ + '{POLYGONZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))}', + ['POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'], + ], + 'extra space before parentheses' => [ + '{POINT Z (1 2 3)}', + ['POINT Z(1 2 3)'], + ], + 'multiple spaces between type and modifier' => [ + '{POINT Z(1 2 3)}', + ['POINT Z(1 2 3)'], + ], + 'srid with extra space before parentheses' => [ + '{SRID=4326;POINT Z (1 2 3)}', + ['SRID=4326;POINT Z(1 2 3)'], + ], ]; } From 10fcc618597ea0633b63c67907b7fa4887960c61 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Fri, 22 Aug 2025 22:15:12 +0300 Subject: [PATCH 18/26] no message --- docs/GEOMETRY_ARRAYS.md | 272 ++++++++++++++++++++++++++++++ docs/INTEGRATING-WITH-DOCTRINE.md | 10 ++ docs/USE-CASES-AND-EXAMPLES.md | 36 ++++ 3 files changed, 318 insertions(+) create mode 100644 docs/GEOMETRY_ARRAYS.md diff --git a/docs/GEOMETRY_ARRAYS.md b/docs/GEOMETRY_ARRAYS.md new file mode 100644 index 00000000..7aba2008 --- /dev/null +++ b/docs/GEOMETRY_ARRAYS.md @@ -0,0 +1,272 @@ +# Geometry and Geography Arrays + +This document explains the usage, limitations, and workarounds for PostgreSQL `geometry` and `geography` array types in Doctrine DBAL. + +## Overview + +The `GeometryArray` and `GeographyArray` types provide support for PostgreSQL's `GEOMETRY[]` and `GEOGRAPHY[]` array types, allowing you to store collections of spatial data in a single database column. + + +## Registration and Type Mapping + +```php +use Doctrine\DBAL\Types\Type; + +Type::addType('geometry', \MartinGeorgiev\Doctrine\DBAL\Types\Geometry::class); +Type::addType('geometry[]', \MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray::class); +Type::addType('geography', \MartinGeorgiev\Doctrine\DBAL\Types\Geography::class); +Type::addType('geography[]', \MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray::class); + +$platform = $connection->getDatabasePlatform(); +$platform->registerDoctrineTypeMapping('geometry', 'geometry'); +$platform->registerDoctrineTypeMapping('_geometry', 'geometry[]'); +$platform->registerDoctrineTypeMapping('geography', 'geography'); +$platform->registerDoctrineTypeMapping('_geography', 'geography[]'); +``` + +## Basic Usage + +### Entity Definition + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; + +class Location +{ + /** + * @ORM\Column(type="geometry[]") + */ + private array $geometries; + + /** + * @ORM\Column(type="geography[]") + */ + private array $geographies; + + public function setGeometries(array $geometries): void + { + $this->geometries = array_map( + fn(string $wkt) => WktSpatialData::fromWkt($wkt), + $geometries + ); + } +} +``` + +### Parameter Binding with DBAL + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; + +// Single-item geometry[] array +$qb = $connection->createQueryBuilder(); +$qb->insert('locations')->values(['geometries' => ':wktSpatialData']); +$qb->setParameter('wktSpatialData', [WktSpatialData::fromWkt('POINT(0 0)')], 'geometry[]'); +$qb->executeStatement(); + +// Single geography value +$qb = $connection->createQueryBuilder(); +$qb->insert('places')->values(['locations' => ':wktSpatialData']); +$qb->setParameter('wktSpatialData', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), 'geography'); +$qb->executeStatement(); +``` + +### Parameter Binding with DBAL + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; + +// Single-item geometry[] array +$qb = $connection->createQueryBuilder(); +$qb->insert('locations')->values(['geometries' => ':wktSpatialData']); +$qb->setParameter('wktSpatialData', [WktSpatialData::fromWkt('POINT(0 0)')], 'geometry[]'); +$qb->executeStatement(); + +// Single geography value +$qb = $connection->createQueryBuilder(); +$qb->insert('places')->values(['locations' => ':wktSpatialData']); +$qb->setParameter('wktSpatialData', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), 'geography'); +$qb->executeStatement(); +``` + $this->geometries = array_map( + fn(string $wkt) => WktSpatialData::fromWkt($wkt), + $geometries + ); + } +} +``` + +### Working Examples + +```php +// Single-item arrays +$singleGeometry = [WktSpatialData::fromWkt('POINT(0 0)')]; +$singleGeography = [WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)')]; + +// Complex single geometries +$complexGeometry = [WktSpatialData::fromWkt('POLYGON((0 0,0 1,1 1,1 0,0 0))')]; +$geometryWithSrid = [WktSpatialData::fromWkt('SRID=4326;LINESTRING(-122 37,-121 38)')]; +``` + +## Important Limitation: Multi-Item Arrays + +### The Problem + +**Multi-item geometry and geography arrays have a fundamental limitation with Doctrine DBAL parameter binding** due to PostGIS parsing behavior. + +When Doctrine DBAL tries to bind a multi-item array like: +```php +$multiItem = [ + WktSpatialData::fromWkt('POINT(1 2)'), + WktSpatialData::fromWkt('POINT(3 4)'), +]; +``` + +It generates a PostgreSQL array literal: `{POINT(1 2),POINT(3 4)}` + +However, **PostGIS intercepts this and tries to parse the entire string as a single geometry**, causing this error: +``` +ERROR: parse error - invalid geometry +HINT: "POINT(1 2),POI" <-- parse error at position 14 +``` + +### This is NOT a Bug + +This is a **PostGIS-specific limitation**, not a bug in our implementation: + +1. βœ… **PostgreSQL arrays work fine** with other complex types (text, inet, etc.) +2. βœ… **Our parsing logic is correct** (verified in unit tests) +3. ❌ **PostGIS geometry parsing is aggressive** and conflicts with array literals +4. βœ… **Single-item arrays work perfectly** + +## Workarounds for Multi-Item Arrays + +### Option 1: Raw SQL with ARRAY Constructor + +```php +$sql = "INSERT INTO locations (geometries) VALUES (ARRAY[?::geometry, ?::geometry])"; +$connection->executeStatement($sql, ['POINT(1 2)', 'POINT(3 4)']); +``` + +### Option 2: Multiple Single-Item Operations + +```php +// Instead of one multi-item array +$multiArray = [geom1, geom2, geom3]; + +// Use multiple single-item arrays +foreach ($geometries as $geometry) { + $entity->addGeometry([$geometry]); +} +``` + +### Option 3: Application-Level Array Building + +## Normalization Rules (Dimensional Modifiers) + +The library normalizes dimensional modifiers based on enums for geometry types and modifiers. + +Examples: + +``` +POINTZ(1 2 3) => POINT Z(1 2 3) +LINESTRINGM(0 0 1, 1 1 2) => LINESTRING M(0 0 1, 1 1 2) +POLYGONZM((...)) => POLYGON ZM((...)) +POINT Z (1 2 3) => POINT Z(1 2 3) +SRID=4326;POINT Z (1 2 3) => SRID=4326;POINT Z(1 2 3) +``` + +See also: Spatial foundations and parser behavior in the Spatial Types document. + +```php +// Build arrays in application code, then use raw SQL +$geometries = ['POINT(1 2)', 'POINT(3 4)', 'LINESTRING(0 0,1 1)']; +$arrayConstructor = 'ARRAY[' . implode('::geometry,', $geometries) . '::geometry]'; + +$sql = "INSERT INTO locations (geoms) VALUES ({$arrayConstructor})"; +$connection->executeStatement($sql); +``` + +### Option 4: JSON Storage Alternative + +For complex multi-item scenarios, consider using JSON storage: + +```php +/** + * @ORM\Column(type="json") + */ +private array $geometriesAsJson; + +public function setGeometries(array $wktStrings): void +{ + $this->geometriesAsJson = $wktStrings; +} + +public function getGeometries(): array +{ + return array_map( + fn(string $wkt) => WktSpatialData::fromWkt($wkt), + $this->geometriesAsJson + ); +} +``` + +## Test Coverage + +### Integration Tests +- βœ… **Single-item arrays**: Fully tested against real PostgreSQL database +- ❌ **Multi-item arrays**: Tested to demonstrate PostGIS limitation (expected failures) +- βœ… **All geometry types**: POINT, LINESTRING, POLYGON, MULTIPOINT, etc. +- βœ… **Dimensional modifiers**: Z, M, ZM coordinates +- βœ… **SRID support**: EWKT format with coordinate systems +- βœ… **Geography specifics**: Auto-SRID behavior, world coordinates + +The integration tests include both working single-item arrays and workarounded (through ARRAY[]) multi-item arrays to provide complete documentation of the PostGIS limitation. + +### Unit Tests +- βœ… **Multi-item arrays**: Tested for parsing logic +- βœ… **Mixed scenarios**: Different geometry types, SRIDs, dimensions +- βœ… **Edge cases**: Empty arrays, complex combinations +- βœ… **Round-trip conversion**: Database ↔ PHP object conversion + +## Supported Features + +### Geometry Types +- βœ… POINT, LINESTRING, POLYGON +- βœ… MULTIPOINT, MULTILINESTRING, MULTIPOLYGON +- βœ… GEOMETRYCOLLECTION +- βœ… All other PostGIS geometry types as of v3.5 + +### Coordinate Systems +- βœ… **SRID support**: `SRID=4326;POINT(-122 37)` +- βœ… **Dimensional modifiers**: Z (elevation), M (measure), ZM +- βœ… **Mixed coordinates**: Arrays with different SRIDs/dimensions + +### Geography Features +- βœ… **Auto-SRID**: Geography types automatically get SRID=4326 if none is provided +- βœ… **World coordinates**: Null Island, poles, date line +- βœ… **Geographic calculations**: Proper spherical geometry + +## Best Practices + +1. **Use single-item arrays** when possible for maximum compatibility +2. **Test thoroughly** with your specific geometry combinations +3. **Consider alternatives** (JSON, separate tables) for complex multi-item scenarios +4. **Use raw SQL** when you need multi-item arrays and can control the SQL generation +5. **Monitor PostGIS updates** - this limitation may be addressed in future versions + +## Performance Considerations + +- **Single-item arrays**: Excellent performance, full PostgreSQL optimization +- **Multi-item workarounds**: May have performance implications depending on the approach +- **Indexing**: PostgreSQL can index geometry arrays using GiST indexes +- **Query optimization**: Use appropriate spatial operators and indexes + +## Future Improvements + +This limitation may be addressed in future versions through: +- **PostGIS improvements** to array literal parsing +- **Doctrine DBAL enhancements** to custom SQL generation +- **Alternative storage strategies** built into the types + +For now, the workarounds provide full functionality while maintaining type safety and spatial capabilities. diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index 538fe2f7..5b20ddc2 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -31,6 +31,10 @@ Type::addType('macaddr[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\MacaddrArray" Type::addType('point', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Point"); Type::addType('point[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\PointArray"); +Type::addType('geometry', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Geometry"); +Type::addType('geometry[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\GeometryArray"); +Type::addType('geography', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Geography"); +Type::addType('geography[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\GeographyArray"); Type::addType('daterange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\DateRange"); Type::addType('int4range', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Int4Range"); @@ -244,6 +248,12 @@ $platform->registerDoctrineTypeMapping('_macaddr','macaddr[]'); $platform->registerDoctrineTypeMapping('point','point'); $platform->registerDoctrineTypeMapping('point[]','point[]'); $platform->registerDoctrineTypeMapping('_point','point[]'); +$platform->registerDoctrineTypeMapping('geometry','geometry'); +$platform->registerDoctrineTypeMapping('geometry[]','geometry[]'); +$platform->registerDoctrineTypeMapping('_geography','geography[]'); +$platform->registerDoctrineTypeMapping('geography','geography'); +$platform->registerDoctrineTypeMapping('geometry[]','geometry[]'); +$platform->registerDoctrineTypeMapping('_geometry','geometry[]'); $platform->registerDoctrineTypeMapping('daterange','daterange'); $platform->registerDoctrineTypeMapping('int4range','int4range'); diff --git a/docs/USE-CASES-AND-EXAMPLES.md b/docs/USE-CASES-AND-EXAMPLES.md index a41c473d..88749ece 100644 --- a/docs/USE-CASES-AND-EXAMPLES.md +++ b/docs/USE-CASES-AND-EXAMPLES.md @@ -144,6 +144,42 @@ SELECT p FROM Product p WHERE p.priceRange @> 25.0 Using PostGIS Types --- + +### Using PostGIS Types with Doctrine DBAL (Geometry/Geography) + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; + +// Insert a single geometry value +$qb = $connection->createQueryBuilder(); +$qb->insert('places')->values(['location' => ':wktSpatialData']); +$qb->setParameter('wktSpatialData', WktSpatialData::fromWkt('POINT(1 2)'), 'geometry'); +$qb->executeStatement(); + +// Insert a single geography value with SRID +$qb = $connection->createQueryBuilder(); +$qb->insert('places')->values(['boundary' => ':wktSpatialData']); +$qb->setParameter('wktSpatialData', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), 'geography'); +$qb->executeStatement(); + +// Insert a single-item geometry[] array +$qb = $connection->createQueryBuilder(); +$qb->insert('routes')->values(['geometriesLines' => ':wktSpatialData']); +$qb->setParameter('wktSpatialData', [WktSpatialData::fromWkt('LINESTRING(0 0, 1 1)')], 'geometry[]'); +$qb->executeStatement(); +``` + +Dimensional modifiers are supported and normalized: + +``` +POINTZ(1 2 3) => POINT Z(1 2 3) +LINESTRINGM(0 0 1, 1 1 2) => LINESTRING M(0 0 1, 1 1 2) +POLYGONZM((...)) => POLYGON ZM((...)) +POINT Z (1 2 3) => POINT Z(1 2 3) +``` + +For multi-item arrays, see [GEOMETRY_ARRAYS.md](./GEOMETRY_ARRAYS.md) for Doctrine DQL limitations and the suggested workarounds. + The library provides DBAL type support for PostGIS `geometry` and `geography` columns. Example usage: ```sql From c1d7a3df8e547280a698ed2807544392446bd3d8 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 23 Aug 2025 04:02:33 +0300 Subject: [PATCH 19/26] docs --- README.md | 6 +- docs/SPATIAL-TYPES.md | 231 +++++++++++++++++++++++++++++++++ docs/USE-CASES-AND-EXAMPLES.md | 2 +- 3 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 docs/SPATIAL-TYPES.md diff --git a/README.md b/README.md index 94480e84..678bae70 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ This package provides comprehensive Doctrine support for PostgreSQL features: - MAC addresses (`macaddr`, `macaddr[]`) - **Geometric Types** - Point (`point`, `point[]`) - - PostGIS Geometry (`geometry`) - - PostGIS Geography (`geography`) + - PostGIS Geometry (`geometry`, `geometry[]`) + - PostGIS Geography (`geography`, `geography[]`) - **Range Types** - Date and time ranges (`daterange`, `tsrange`, `tstzrange`) - Numeric ranges (`numrange`, `int4range`, `int8range`) @@ -100,6 +100,8 @@ Full documentation: - [Value Objects for Range Types](docs/RANGE-TYPES.md) - [Available Functions and Operators](docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md) - [Common Use Cases and Examples](docs/USE-CASES-AND-EXAMPLES.md) +- [Spatial Types](docs/SPATIAL-TYPES.md) +- [Geometry Arrays](docs/GEOMETRY-ARRAYS.md) ## πŸ“¦ Installation diff --git a/docs/SPATIAL-TYPES.md b/docs/SPATIAL-TYPES.md new file mode 100644 index 00000000..3766bf37 --- /dev/null +++ b/docs/SPATIAL-TYPES.md @@ -0,0 +1,231 @@ +# Spatial Types (Foundations) + +This document describes the core primitives used by the spatial DBAL types: parsing, normalization, and enum-driven patterns. + +## SpatialDataArray base class + +`SpatialDataArray` is the base for `GeometryArray` and `GeographyArray`. It provides: +- Parsing of PostgreSQL array literals containing WKT/EWKT elements + - Handles nested parentheses and quoted/unquoted array elements + - Splits correctly even when commas occur inside coordinate lists +- Normalization of dimensional modifiers and spacing + - `POINTZ(...)` β†’ `POINT Z(...)` + - `LINESTRINGM(...)` β†’ `LINESTRING M(...)` + - `POLYGONZM(...)` β†’ `POLYGON ZM(...)` + - `POINT Z (...)` β†’ `POINT Z(...)` + - `SRID=4326;POINT Z (...)` β†’ `SRID=4326;POINT Z(...)` + +Parsing outputs a list of `WktSpatialData` value objects that Doctrine DBAL can bind. + +## Enum-driven patterns + +Two enums drive normalization so the code and docs remain consistent: +- `GeometryType` – set of supported geometry type names (`POINT`, `LINESTRING`, `POLYGON`, etc.) +- `DimensionalModifier` – dimensional markers (`Z`, `M`, `ZM`) + +Regex patterns for geometry type detection and dimensional modifier handling are built from these enums instead of hardcoded strings. + +## Supported Geometry Types + +The library supports all PostGIS geometry types through the `GeometryType` enum: + +### Basic Geometry Types +```php +// Point geometry +$point = WktSpatialData::fromWkt('POINT(1 2)'); +$point3d = WktSpatialData::fromWkt('POINT Z(1 2 3)'); +$pointMeasured = WktSpatialData::fromWkt('POINT M(1 2 4)'); +$point4d = WktSpatialData::fromWkt('POINT ZM(1 2 3 4)'); + +// Line geometry +$line = WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)'); +$line3d = WktSpatialData::fromWkt('LINESTRING Z(0 0 0, 1 1 1, 2 2 2)'); + +// Polygon geometry +$polygon = WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'); +$polygonWithHoles = WktSpatialData::fromWkt('POLYGON((0 0, 0 3, 3 3, 3 0, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))'); +``` + +### Multi-Geometry Types +```php +// Multi-point +$multiPoint = WktSpatialData::fromWkt('MULTIPOINT((1 2), (3 4), (5 6))'); + +// Multi-line +$multiLine = WktSpatialData::fromWkt('MULTILINESTRING((0 0, 1 1), (2 2, 3 3))'); + +// Multi-polygon +$multiPolygon = WktSpatialData::fromWkt('MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))'); +``` + +### Collection Types +```php +// Geometry collection +$collection = WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'); +``` + +### Circular Geometry Types (PostGIS Extensions) +```php +// Circular string +$circularString = WktSpatialData::fromWkt('CIRCULARSTRING(0 0, 1 1, 2 0)'); + +// Compound curve +$compoundCurve = WktSpatialData::fromWkt('COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'); + +// Curve polygon +$curvePolygon = WktSpatialData::fromWkt('CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0))'); + +// Multi-curve +$multiCurve = WktSpatialData::fromWkt('MULTICURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'); + +// Multi-surface +$multiSurface = WktSpatialData::fromWkt('MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0)))'); +``` + +### Triangle and TIN Types +```php +// Triangle +$triangle = WktSpatialData::fromWkt('TRIANGLE((0 0, 1 0, 0.5 1, 0 0))'); + +// TIN (Triangulated Irregular Network) +$tin = WktSpatialData::fromWkt('TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))'); + +// Polyhedral surface +$polyhedralSurface = WktSpatialData::fromWkt('POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((0 0, 0 1, 0 0 1, 0 0)))'); +``` + +## Geography vs Geometry specifics + +- Geometry accepts WKT and EWKT (`SRID=...;...`). +- Geography commonly uses SRID 4326; EWKT is supported (e.g., `SRID=4326;POINT(...)`). +- Dimensional modifiers (Z, M, ZM) are normalized consistently for both types. + +## Arrays and multi-item caveat + +- Single-item `GEOMETRY[]` and `GEOGRAPHY[]` arrays work with DBAL parameter binding. +- Multi-item arrays have a PostGIS limitation with array literal parsing. Use one of: + - ARRAY constructor with per-element casts in raw SQL: `ARRAY[?::geometry, ?::geometry]` + - Multiple single-item operations + - Application-level array building + +See [GEOMETRY-ARRAYS.md](./GEOMETRY-ARRAYS.md) for details, workarounds, and examples. + +## Minimal examples + +### Registration + +```php +use Doctrine\DBAL\Types\Type; + +Type::addType('geometry', \MartinGeorgiev\Doctrine\DBAL\Types\Geometry::class); +Type::addType('geometry[]', \MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray::class); +Type::addType('geography', \MartinGeorgiev\Doctrine\DBAL\Types\Geography::class); +Type::addType('geography[]', \MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray::class); +``` + +### Binding a single value + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; + +$qb = $connection->createQueryBuilder(); +$qb->insert('places')->values(['location' => ':location']); +$qb->setParameter('location', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), 'geography'); +$qb->executeStatement(); +``` + +### Binding a single-item array + +```php +$qb = $connection->createQueryBuilder(); +$qb->insert('locations')->values(['geometries' => ':geometries']); +$qb->setParameter('geometries', [WktSpatialData::fromWkt('POINT(0 0)')], 'geometry[]'); +$qb->executeStatement(); +``` + +For multi-item arrays, see the raw SQL ARRAY constructor examples in [GEOMETRY-ARRAYS.md](./GEOMETRY-ARRAYS.md). + +## Error Handling and Validation + +The spatial types provide error handling for invalid spatial data: + +### Common Validation Errors + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidWktSpatialDataException; + +try { + // Invalid WKT format + $invalid = WktSpatialData::fromWkt('INVALID(1 2)'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Unsupported geometry type: INVALID" +} + +try { + // Empty coordinate section + $empty = WktSpatialData::fromWkt('POINT()'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Empty coordinate section in WKT" +} + +try { + // Invalid SRID format + $invalidSrid = WktSpatialData::fromWkt('SRID=abc;POINT(1 2)'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Invalid SRID value: abc" +} + +try { + // Missing semicolon in EWKT + $missingSemicolon = WktSpatialData::fromWkt('SRID=4326POINT(1 2)'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Missing semicolon in EWKT format" +} +``` + +### Database Conversion Errors + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidGeometryForPHPException; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidGeographyForPHPException; + +// Invalid type passed to geometry column +try { + $qb->setParameter('geom', 'not a geometry', 'geometry'); +} catch (InvalidGeometryForPHPException $e) { + // Throws: "Invalid type for geometry column" +} + +// Invalid format from database +try { + $geometryType->convertToPHPValue('invalid wkt from db', $platform); +} catch (InvalidGeometryForDatabaseException $e) { + // Throws: "Invalid format for geometry value" +} +``` + +### Validation Best Practices + +```php +// Validate WKT before database operations +function validateSpatialData(string $wkt): bool { + try { + WktSpatialData::fromWkt($wkt); + return true; + } catch (InvalidWktSpatialDataException) { + return false; + } +} + +// Check geometry type before processing +$spatialData = WktSpatialData::fromWkt('POINT(1 2)'); +if ($spatialData->getGeometryType() === GeometryType::POINT) { + // Process point-specific logic +} + +// Validate SRID for geography operations +$geographyData = WktSpatialData::fromWkt('SRID=4326;POINT(-122 37)'); +if ($geographyData->getSrid() === 4326) { + // Valid for geography operations +} +``` diff --git a/docs/USE-CASES-AND-EXAMPLES.md b/docs/USE-CASES-AND-EXAMPLES.md index 88749ece..7fa63975 100644 --- a/docs/USE-CASES-AND-EXAMPLES.md +++ b/docs/USE-CASES-AND-EXAMPLES.md @@ -178,7 +178,7 @@ POLYGONZM((...)) => POLYGON ZM((...)) POINT Z (1 2 3) => POINT Z(1 2 3) ``` -For multi-item arrays, see [GEOMETRY_ARRAYS.md](./GEOMETRY_ARRAYS.md) for Doctrine DQL limitations and the suggested workarounds. +For multi-item arrays, see [GEOMETRY-ARRAYS.md](./GEOMETRY-ARRAYS.md) for Doctrine DQL limitations and the suggested workarounds. The library provides DBAL type support for PostGIS `geometry` and `geography` columns. Example usage: From f8e91a994916e8a65c7d855f7e9283072d55e5be Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 23 Aug 2025 04:02:33 +0300 Subject: [PATCH 20/26] docs --- README.md | 6 +- ...{GEOMETRY_ARRAYS.md => GEOMETRY-ARRAYS.md} | 2 +- docs/INTEGRATING-WITH-DOCTRINE.md | 6 +- docs/SPATIAL-TYPES.md | 231 ++++++++++++++++++ docs/USE-CASES-AND-EXAMPLES.md | 2 +- 5 files changed, 240 insertions(+), 7 deletions(-) rename docs/{GEOMETRY_ARRAYS.md => GEOMETRY-ARRAYS.md} (99%) create mode 100644 docs/SPATIAL-TYPES.md diff --git a/README.md b/README.md index 94480e84..678bae70 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ This package provides comprehensive Doctrine support for PostgreSQL features: - MAC addresses (`macaddr`, `macaddr[]`) - **Geometric Types** - Point (`point`, `point[]`) - - PostGIS Geometry (`geometry`) - - PostGIS Geography (`geography`) + - PostGIS Geometry (`geometry`, `geometry[]`) + - PostGIS Geography (`geography`, `geography[]`) - **Range Types** - Date and time ranges (`daterange`, `tsrange`, `tstzrange`) - Numeric ranges (`numrange`, `int4range`, `int8range`) @@ -100,6 +100,8 @@ Full documentation: - [Value Objects for Range Types](docs/RANGE-TYPES.md) - [Available Functions and Operators](docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md) - [Common Use Cases and Examples](docs/USE-CASES-AND-EXAMPLES.md) +- [Spatial Types](docs/SPATIAL-TYPES.md) +- [Geometry Arrays](docs/GEOMETRY-ARRAYS.md) ## πŸ“¦ Installation diff --git a/docs/GEOMETRY_ARRAYS.md b/docs/GEOMETRY-ARRAYS.md similarity index 99% rename from docs/GEOMETRY_ARRAYS.md rename to docs/GEOMETRY-ARRAYS.md index 7aba2008..4aab0934 100644 --- a/docs/GEOMETRY_ARRAYS.md +++ b/docs/GEOMETRY-ARRAYS.md @@ -152,7 +152,7 @@ $connection->executeStatement($sql, ['POINT(1 2)', 'POINT(3 4)']); ```php // Instead of one multi-item array -$multiArray = [geom1, geom2, geom3]; +$geometries = [$geom1, $geom2, $geom3]; // Use multiple single-item arrays foreach ($geometries as $geometry) { diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index 5b20ddc2..1db7d947 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -250,10 +250,10 @@ $platform->registerDoctrineTypeMapping('point[]','point[]'); $platform->registerDoctrineTypeMapping('_point','point[]'); $platform->registerDoctrineTypeMapping('geometry','geometry'); $platform->registerDoctrineTypeMapping('geometry[]','geometry[]'); -$platform->registerDoctrineTypeMapping('_geography','geography[]'); -$platform->registerDoctrineTypeMapping('geography','geography'); -$platform->registerDoctrineTypeMapping('geometry[]','geometry[]'); $platform->registerDoctrineTypeMapping('_geometry','geometry[]'); +$platform->registerDoctrineTypeMapping('geography','geography'); +$platform->registerDoctrineTypeMapping('geography[]','geography[]'); +$platform->registerDoctrineTypeMapping('_geography','geography[]'); $platform->registerDoctrineTypeMapping('daterange','daterange'); $platform->registerDoctrineTypeMapping('int4range','int4range'); diff --git a/docs/SPATIAL-TYPES.md b/docs/SPATIAL-TYPES.md new file mode 100644 index 00000000..3766bf37 --- /dev/null +++ b/docs/SPATIAL-TYPES.md @@ -0,0 +1,231 @@ +# Spatial Types (Foundations) + +This document describes the core primitives used by the spatial DBAL types: parsing, normalization, and enum-driven patterns. + +## SpatialDataArray base class + +`SpatialDataArray` is the base for `GeometryArray` and `GeographyArray`. It provides: +- Parsing of PostgreSQL array literals containing WKT/EWKT elements + - Handles nested parentheses and quoted/unquoted array elements + - Splits correctly even when commas occur inside coordinate lists +- Normalization of dimensional modifiers and spacing + - `POINTZ(...)` β†’ `POINT Z(...)` + - `LINESTRINGM(...)` β†’ `LINESTRING M(...)` + - `POLYGONZM(...)` β†’ `POLYGON ZM(...)` + - `POINT Z (...)` β†’ `POINT Z(...)` + - `SRID=4326;POINT Z (...)` β†’ `SRID=4326;POINT Z(...)` + +Parsing outputs a list of `WktSpatialData` value objects that Doctrine DBAL can bind. + +## Enum-driven patterns + +Two enums drive normalization so the code and docs remain consistent: +- `GeometryType` – set of supported geometry type names (`POINT`, `LINESTRING`, `POLYGON`, etc.) +- `DimensionalModifier` – dimensional markers (`Z`, `M`, `ZM`) + +Regex patterns for geometry type detection and dimensional modifier handling are built from these enums instead of hardcoded strings. + +## Supported Geometry Types + +The library supports all PostGIS geometry types through the `GeometryType` enum: + +### Basic Geometry Types +```php +// Point geometry +$point = WktSpatialData::fromWkt('POINT(1 2)'); +$point3d = WktSpatialData::fromWkt('POINT Z(1 2 3)'); +$pointMeasured = WktSpatialData::fromWkt('POINT M(1 2 4)'); +$point4d = WktSpatialData::fromWkt('POINT ZM(1 2 3 4)'); + +// Line geometry +$line = WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)'); +$line3d = WktSpatialData::fromWkt('LINESTRING Z(0 0 0, 1 1 1, 2 2 2)'); + +// Polygon geometry +$polygon = WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'); +$polygonWithHoles = WktSpatialData::fromWkt('POLYGON((0 0, 0 3, 3 3, 3 0, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))'); +``` + +### Multi-Geometry Types +```php +// Multi-point +$multiPoint = WktSpatialData::fromWkt('MULTIPOINT((1 2), (3 4), (5 6))'); + +// Multi-line +$multiLine = WktSpatialData::fromWkt('MULTILINESTRING((0 0, 1 1), (2 2, 3 3))'); + +// Multi-polygon +$multiPolygon = WktSpatialData::fromWkt('MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))'); +``` + +### Collection Types +```php +// Geometry collection +$collection = WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'); +``` + +### Circular Geometry Types (PostGIS Extensions) +```php +// Circular string +$circularString = WktSpatialData::fromWkt('CIRCULARSTRING(0 0, 1 1, 2 0)'); + +// Compound curve +$compoundCurve = WktSpatialData::fromWkt('COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'); + +// Curve polygon +$curvePolygon = WktSpatialData::fromWkt('CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0))'); + +// Multi-curve +$multiCurve = WktSpatialData::fromWkt('MULTICURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'); + +// Multi-surface +$multiSurface = WktSpatialData::fromWkt('MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0)))'); +``` + +### Triangle and TIN Types +```php +// Triangle +$triangle = WktSpatialData::fromWkt('TRIANGLE((0 0, 1 0, 0.5 1, 0 0))'); + +// TIN (Triangulated Irregular Network) +$tin = WktSpatialData::fromWkt('TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))'); + +// Polyhedral surface +$polyhedralSurface = WktSpatialData::fromWkt('POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((0 0, 0 1, 0 0 1, 0 0)))'); +``` + +## Geography vs Geometry specifics + +- Geometry accepts WKT and EWKT (`SRID=...;...`). +- Geography commonly uses SRID 4326; EWKT is supported (e.g., `SRID=4326;POINT(...)`). +- Dimensional modifiers (Z, M, ZM) are normalized consistently for both types. + +## Arrays and multi-item caveat + +- Single-item `GEOMETRY[]` and `GEOGRAPHY[]` arrays work with DBAL parameter binding. +- Multi-item arrays have a PostGIS limitation with array literal parsing. Use one of: + - ARRAY constructor with per-element casts in raw SQL: `ARRAY[?::geometry, ?::geometry]` + - Multiple single-item operations + - Application-level array building + +See [GEOMETRY-ARRAYS.md](./GEOMETRY-ARRAYS.md) for details, workarounds, and examples. + +## Minimal examples + +### Registration + +```php +use Doctrine\DBAL\Types\Type; + +Type::addType('geometry', \MartinGeorgiev\Doctrine\DBAL\Types\Geometry::class); +Type::addType('geometry[]', \MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray::class); +Type::addType('geography', \MartinGeorgiev\Doctrine\DBAL\Types\Geography::class); +Type::addType('geography[]', \MartinGeorgiev\Doctrine\DBAL\Types\GeographyArray::class); +``` + +### Binding a single value + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; + +$qb = $connection->createQueryBuilder(); +$qb->insert('places')->values(['location' => ':location']); +$qb->setParameter('location', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), 'geography'); +$qb->executeStatement(); +``` + +### Binding a single-item array + +```php +$qb = $connection->createQueryBuilder(); +$qb->insert('locations')->values(['geometries' => ':geometries']); +$qb->setParameter('geometries', [WktSpatialData::fromWkt('POINT(0 0)')], 'geometry[]'); +$qb->executeStatement(); +``` + +For multi-item arrays, see the raw SQL ARRAY constructor examples in [GEOMETRY-ARRAYS.md](./GEOMETRY-ARRAYS.md). + +## Error Handling and Validation + +The spatial types provide error handling for invalid spatial data: + +### Common Validation Errors + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Exceptions\InvalidWktSpatialDataException; + +try { + // Invalid WKT format + $invalid = WktSpatialData::fromWkt('INVALID(1 2)'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Unsupported geometry type: INVALID" +} + +try { + // Empty coordinate section + $empty = WktSpatialData::fromWkt('POINT()'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Empty coordinate section in WKT" +} + +try { + // Invalid SRID format + $invalidSrid = WktSpatialData::fromWkt('SRID=abc;POINT(1 2)'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Invalid SRID value: abc" +} + +try { + // Missing semicolon in EWKT + $missingSemicolon = WktSpatialData::fromWkt('SRID=4326POINT(1 2)'); +} catch (InvalidWktSpatialDataException $e) { + // Throws: "Missing semicolon in EWKT format" +} +``` + +### Database Conversion Errors + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidGeometryForPHPException; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidGeographyForPHPException; + +// Invalid type passed to geometry column +try { + $qb->setParameter('geom', 'not a geometry', 'geometry'); +} catch (InvalidGeometryForPHPException $e) { + // Throws: "Invalid type for geometry column" +} + +// Invalid format from database +try { + $geometryType->convertToPHPValue('invalid wkt from db', $platform); +} catch (InvalidGeometryForDatabaseException $e) { + // Throws: "Invalid format for geometry value" +} +``` + +### Validation Best Practices + +```php +// Validate WKT before database operations +function validateSpatialData(string $wkt): bool { + try { + WktSpatialData::fromWkt($wkt); + return true; + } catch (InvalidWktSpatialDataException) { + return false; + } +} + +// Check geometry type before processing +$spatialData = WktSpatialData::fromWkt('POINT(1 2)'); +if ($spatialData->getGeometryType() === GeometryType::POINT) { + // Process point-specific logic +} + +// Validate SRID for geography operations +$geographyData = WktSpatialData::fromWkt('SRID=4326;POINT(-122 37)'); +if ($geographyData->getSrid() === 4326) { + // Valid for geography operations +} +``` diff --git a/docs/USE-CASES-AND-EXAMPLES.md b/docs/USE-CASES-AND-EXAMPLES.md index 88749ece..7fa63975 100644 --- a/docs/USE-CASES-AND-EXAMPLES.md +++ b/docs/USE-CASES-AND-EXAMPLES.md @@ -178,7 +178,7 @@ POLYGONZM((...)) => POLYGON ZM((...)) POINT Z (1 2 3) => POINT Z(1 2 3) ``` -For multi-item arrays, see [GEOMETRY_ARRAYS.md](./GEOMETRY_ARRAYS.md) for Doctrine DQL limitations and the suggested workarounds. +For multi-item arrays, see [GEOMETRY-ARRAYS.md](./GEOMETRY-ARRAYS.md) for Doctrine DQL limitations and the suggested workarounds. The library provides DBAL type support for PostGIS `geometry` and `geography` columns. Example usage: From 3c87171b54e5f4d596251fc3e6de1a3484b7c9bc Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 24 Aug 2025 04:29:30 +0300 Subject: [PATCH 21/26] no message --- .../DBAL/Types/GeographyArrayTest.php | 61 ++++++++ .../Doctrine/DBAL/Types/GeographyTest.php | 107 ++++++++++++- .../Doctrine/DBAL/Types/GeometryArrayTest.php | 142 ++++++++++++++++++ .../Doctrine/DBAL/Types/GeometryTest.php | 77 ++++++++++ 4 files changed, 380 insertions(+), 7 deletions(-) diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php index 3b590dc5..d001d6d1 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php @@ -27,6 +27,12 @@ protected function setUp(): void $this->platform = $this->createMock(AbstractPlatform::class); } + #[Test] + public function has_name(): void + { + self::assertEquals('geography[]', $this->type->getName()); + } + #[Test] public function can_convert_null_to_database_value(): void { @@ -258,4 +264,59 @@ public static function provideBidirectionalTestCases(): array ], ]; } + + #[DataProvider('provideInvalidArrayItem')] + #[Test] + public function can_validate_array_items_for_database(mixed $item): void + { + self::assertFalse($this->type->isValidArrayItemForDatabase($item)); + } + + /** + * @return array + */ + public static function provideInvalidArrayItem(): array + { + return [ + 'string is invalid' => [ + 'item' => 'not a spatial data object', + ], + 'null is invalid' => [ + 'item' => null, + ], + 'integer is invalid' => [ + 'item' => 123, + ], + 'array is invalid' => [ + 'item' => [], + ], + ]; + } + + #[DataProvider('provideDimensionalModifierNormalization')] + #[Test] + public function can_normalize_dimensional_modifiers(string $input, string $expected): void + { + $result = $this->type->transformArrayItemForPHP($input); + + self::assertInstanceOf(WktSpatialData::class, $result); + self::assertEquals($expected, (string) $result); + } + + /** + * @return array + */ + public static function provideDimensionalModifierNormalization(): array + { + return [ + 'no-space POINTZ' => ['POINTZ(1 2 3)', 'POINT Z(1 2 3)'], + 'no-space LINESTRINGM' => ['LINESTRINGM(0 0 1, 1 1 2)', 'LINESTRING M(0 0 1, 1 1 2)'], + 'no-space POLYGONZM' => ['POLYGONZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))', 'POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'], + 'extra space before parentheses' => ['POINT Z (1 2 3)', 'POINT Z(1 2 3)'], + 'multiple spaces between type and modifier' => ['POINT Z(1 2 3)', 'POINT Z(1 2 3)'], + 'srid with extra space before parentheses' => ['SRID=4326;POINT Z (1 2 3)', 'SRID=4326;POINT Z(1 2 3)'], + 'srid with no-space modifier' => ['SRID=4326;POINTZ(1 2 3)', 'SRID=4326;POINT Z(1 2 3)'], + 'complex geometry with no-space modifier' => ['MULTIPOLYGONZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))', 'MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'], + ]; + } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php index 99864a70..d2cce4be 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php @@ -71,17 +71,110 @@ public static function provideValidTransformations(): array 'wktSpatialData' => WktSpatialData::fromWkt('POINT(1 2)'), 'postgresValue' => 'POINT(1 2)', ], + 'point with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + 'postgresValue' => 'SRID=4326;POINT(-122.4194 37.7749)', + ], + 'linestring' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('LINESTRING(0 0, 1 1, 2 2)'), + 'postgresValue' => 'LINESTRING(0 0, 1 1, 2 2)', + ], + 'polygon' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'), + 'postgresValue' => 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', + ], 'point z' => [ - 'wktSpatialData' => WktSpatialData::fromWkt('POINT Z(-122.4194 37.7749 100)'), - 'postgresValue' => 'POINT Z(-122.4194 37.7749 100)', + 'wktSpatialData' => WktSpatialData::fromWkt('POINT Z(1 2 3)'), + 'postgresValue' => 'POINT Z(1 2 3)', ], 'linestring m' => [ - 'wktSpatialData' => WktSpatialData::fromWkt('LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)'), - 'postgresValue' => 'LINESTRING M(-122.4194 37.7749 1, -122.4094 37.7849 2)', + 'wktSpatialData' => WktSpatialData::fromWkt('LINESTRING M(0 0 1, 1 1 2)'), + 'postgresValue' => 'LINESTRING M(0 0 1, 1 1 2)', + ], + 'polygon zm' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'), + 'postgresValue' => 'POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))', + ], + 'point z with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + 'postgresValue' => 'SRID=4326;POINT Z(-122.4194 37.7749 100)', + ], + // Multi-geometry types + 'multipoint' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOINT((1 2), (3 4), (5 6))'), + 'postgresValue' => 'MULTIPOINT((1 2), (3 4), (5 6))', + ], + 'multilinestring' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTILINESTRING((0 0, 1 1), (2 2, 3 3))'), + 'postgresValue' => 'MULTILINESTRING((0 0, 1 1), (2 2, 3 3))', + ], + 'multipolygon' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))'), + 'postgresValue' => 'MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))', + ], + 'geometrycollection' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'), + 'postgresValue' => 'GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))', + ], + // Circular geometry types (PostGIS extensions) + 'circularstring' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('CIRCULARSTRING(0 0, 1 1, 2 0)'), + 'postgresValue' => 'CIRCULARSTRING(0 0, 1 1, 2 0)', + ], + 'compoundcurve' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'), + 'postgresValue' => 'COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))', + ], + 'curvepolygon' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0))'), + 'postgresValue' => 'CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0))', + ], + 'multicurve' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTICURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'), + 'postgresValue' => 'MULTICURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))', + ], + 'multisurface' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0)))'), + 'postgresValue' => 'MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0)))', + ], + // Triangle and TIN types + 'triangle' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('TRIANGLE((0 0, 1 0, 0.5 1, 0 0))'), + 'postgresValue' => 'TRIANGLE((0 0, 1 0, 0.5 1, 0 0))', + ], + 'tin' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))'), + 'postgresValue' => 'TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))', + ], + 'polyhedralsurface' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((0 0, 0 1, 0 0 1, 0 0)))'), + 'postgresValue' => 'POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((0 0, 0 1, 0 0 1, 0 0)))', + ], + // Complex dimensional modifiers + 'multipoint z' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOINT Z((1 2 3), (4 5 6))'), + 'postgresValue' => 'MULTIPOINT Z((1 2 3), (4 5 6))', + ], + 'multilinestring m' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))'), + 'postgresValue' => 'MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))', + ], + 'multipolygon zm' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'), + 'postgresValue' => 'MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))', + ], + 'geometrycollection z' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))'), + 'postgresValue' => 'GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))', + ], + // Complex SRID combinations + 'complex geometry with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))'), + 'postgresValue' => 'SRID=4326;MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))', ], - 'polygon zm with srid' => [ - 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))'), - 'postgresValue' => 'SRID=4326;POLYGON ZM((-122.5 37.7 0 1, -122.5 37.8 0 1, -122.4 37.8 0 1, -122.4 37.7 0 1, -122.5 37.7 0 1))', + 'circular geometry with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;CIRCULARSTRING(0 0, 1 1, 2 0)'), + 'postgresValue' => 'SRID=4326;CIRCULARSTRING(0 0, 1 1, 2 0)', ], ]; } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php index 7bb174ef..389bb238 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php @@ -27,6 +27,12 @@ protected function setUp(): void $this->platform = $this->createMock(AbstractPlatform::class); } + #[Test] + public function has_name(): void + { + self::assertEquals('geometry[]', $this->type->getName()); + } + #[Test] public function can_convert_null_to_database_value(): void { @@ -198,6 +204,61 @@ public static function provideValidPostgresArraysForPHP(): array '{SRID=4326;POINT Z (1 2 3)}', ['SRID=4326;POINT Z(1 2 3)'], ], + // Quoted array format tests - critical for PostgreSQL compatibility + 'quoted single point' => [ + '{"POINT(1 2)"}', + ['POINT(1 2)'], + ], + 'quoted multiple points' => [ + '{"POINT(1 2)","POINT(3 4)","POINT(5 6)"}', + ['POINT(1 2)', 'POINT(3 4)', 'POINT(5 6)'], + ], + 'quoted complex geometries' => [ + '{"POINT(1 2)","LINESTRING(0 0, 1 1)","POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))"}', + ['POINT(1 2)', 'LINESTRING(0 0, 1 1)', 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'], + ], + 'quoted geometries with dimensional modifiers' => [ + '{"POINT Z(1 2 3)","LINESTRING M(0 0 1, 1 1 2)","POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))"}', + ['POINT Z(1 2 3)', 'LINESTRING M(0 0 1, 1 1 2)', 'POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'], + ], + 'quoted ewkt with srid' => [ + '{"SRID=4326;POINT(-122.4194 37.7749)","SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))"}', + ['SRID=4326;POINT(-122.4194 37.7749)', 'SRID=4326;POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))'], + ], + 'quoted complex nested geometries' => [ + '{"MULTIPOINT((1 2), (3 4))","GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))"}', + ['MULTIPOINT((1 2), (3 4))', 'GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'], + ], + 'quoted circular geometries' => [ + '{"CIRCULARSTRING(0 0, 1 1, 2 0)","COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))"}', + ['CIRCULARSTRING(0 0, 1 1, 2 0)', 'COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'], + ], + 'quoted triangle and tin' => [ + '{"TRIANGLE((0 0, 1 0, 0.5 1, 0 0))","TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))"}', + ['TRIANGLE((0 0, 1 0, 0.5 1, 0 0))', 'TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))'], + ], + // Edge cases for array parsing + 'empty quoted array' => [ + '{}', + [], + ], + 'single empty quoted item' => [ + '{"POINT(1 2)",""}', + ['POINT(1 2)'], + ], + // Complex nested structures that test bracket depth tracking + 'complex nested with multiple parentheses' => [ + '{MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2))),GEOMETRYCOLLECTION(POINT(1 2), MULTILINESTRING((0 0, 1 1), (2 2, 3 3)))}', + ['MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))', 'GEOMETRYCOLLECTION(POINT(1 2), MULTILINESTRING((0 0, 1 1), (2 2, 3 3)))'], + ], + 'polygon with holes' => [ + '{POLYGON((0 0, 0 3, 3 3, 3 0, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))}', + ['POLYGON((0 0, 0 3, 3 3, 3 0, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))'], + ], + 'quoted polygon with holes' => [ + '{"POLYGON((0 0, 0 3, 3 3, 3 0, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))"}', + ['POLYGON((0 0, 0 3, 3 3, 3 0, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))'], + ], ]; } @@ -247,4 +308,85 @@ public static function provideBidirectionalTestCases(): array ], ]; } + + #[DataProvider('provideValidationCases')] + #[Test] + public function can_validate_array_items_for_database(mixed $item, bool $expected): void + { + self::assertSame($expected, $this->type->isValidArrayItemForDatabase($item)); + } + + /** + * @return array + */ + public static function provideValidationCases(): array + { + return [ + 'valid WktSpatialData' => [ + 'item' => WktSpatialData::fromWkt('POINT(1 2)'), + 'expected' => true, + ], + 'string is invalid' => [ + 'item' => 'not a spatial data object', + 'expected' => false, + ], + 'null is invalid' => [ + 'item' => null, + 'expected' => false, + ], + 'integer is invalid' => [ + 'item' => 123, + 'expected' => false, + ], + 'array is invalid' => [ + 'item' => [], + 'expected' => false, + ], + ]; + } + + #[Test] + public function can_transform_array_item_for_php(): void + { + $wktString = 'POINT(1 2)'; + $result = $this->type->transformArrayItemForPHP($wktString); + + self::assertInstanceOf(WktSpatialData::class, $result); + self::assertEquals($wktString, (string) $result); + } + + #[Test] + public function can_transform_null_array_item_for_php(): void + { + $result = $this->type->transformArrayItemForPHP(null); + + self::assertNull($result); + } + + #[DataProvider('provideDimensionalModifierNormalization')] + #[Test] + public function can_normalize_dimensional_modifiers(string $input, string $expected): void + { + $result = $this->type->transformArrayItemForPHP($input); + + self::assertInstanceOf(WktSpatialData::class, $result); + self::assertEquals($expected, (string) $result); + } + + /** + * @return array + */ + public static function provideDimensionalModifierNormalization(): array + { + return [ + 'no-space POINTZ' => ['POINTZ(1 2 3)', 'POINT Z(1 2 3)'], + 'no-space LINESTRINGM' => ['LINESTRINGM(0 0 1, 1 1 2)', 'LINESTRING M(0 0 1, 1 1 2)'], + 'no-space POLYGONZM' => ['POLYGONZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))', 'POLYGON ZM((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1))'], + 'extra space before parentheses' => ['POINT Z (1 2 3)', 'POINT Z(1 2 3)'], + 'multiple spaces between type and modifier' => ['POINT Z(1 2 3)', 'POINT Z(1 2 3)'], + 'srid with extra space before parentheses' => ['SRID=4326;POINT Z (1 2 3)', 'SRID=4326;POINT Z(1 2 3)'], + 'srid with no-space modifier' => ['SRID=4326;POINTZ(1 2 3)', 'SRID=4326;POINT Z(1 2 3)'], + 'complex geometry with no-space modifier' => ['MULTIPOLYGONZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))', 'MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'], + ]; + } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php index bec89d9d..c7f71704 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php @@ -99,6 +99,83 @@ public static function provideValidTransformations(): array 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), 'postgresValue' => 'SRID=4326;POINT Z(-122.4194 37.7749 100)', ], + // Multi-geometry types + 'multipoint' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOINT((1 2), (3 4), (5 6))'), + 'postgresValue' => 'MULTIPOINT((1 2), (3 4), (5 6))', + ], + 'multilinestring' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTILINESTRING((0 0, 1 1), (2 2, 3 3))'), + 'postgresValue' => 'MULTILINESTRING((0 0, 1 1), (2 2, 3 3))', + ], + 'multipolygon' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))'), + 'postgresValue' => 'MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))', + ], + 'geometrycollection' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))'), + 'postgresValue' => 'GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))', + ], + // Circular geometry types (PostGIS extensions) + 'circularstring' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('CIRCULARSTRING(0 0, 1 1, 2 0)'), + 'postgresValue' => 'CIRCULARSTRING(0 0, 1 1, 2 0)', + ], + 'compoundcurve' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'), + 'postgresValue' => 'COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))', + ], + 'curvepolygon' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0))'), + 'postgresValue' => 'CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0))', + ], + 'multicurve' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTICURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))'), + 'postgresValue' => 'MULTICURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))', + ], + 'multisurface' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0)))'), + 'postgresValue' => 'MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0)))', + ], + // Triangle and TIN types + 'triangle' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('TRIANGLE((0 0, 1 0, 0.5 1, 0 0))'), + 'postgresValue' => 'TRIANGLE((0 0, 1 0, 0.5 1, 0 0))', + ], + 'tin' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))'), + 'postgresValue' => 'TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))', + ], + 'polyhedralsurface' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((0 0, 0 1, 0 0 1, 0 0)))'), + 'postgresValue' => 'POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((0 0, 0 1, 0 0 1, 0 0)))', + ], + // Complex dimensional modifiers + 'multipoint z' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOINT Z((1 2 3), (4 5 6))'), + 'postgresValue' => 'MULTIPOINT Z((1 2 3), (4 5 6))', + ], + 'multilinestring m' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))'), + 'postgresValue' => 'MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))', + ], + 'multipolygon zm' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))'), + 'postgresValue' => 'MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))', + ], + 'geometrycollection z' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))'), + 'postgresValue' => 'GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))', + ], + // Complex SRID combinations + 'complex geometry with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))'), + 'postgresValue' => 'SRID=4326;MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))', + ], + 'circular geometry with srid' => [ + 'wktSpatialData' => WktSpatialData::fromWkt('SRID=4326;CIRCULARSTRING(0 0, 1 1, 2 0)'), + 'postgresValue' => 'SRID=4326;CIRCULARSTRING(0 0, 1 1, 2 0)', + ], ]; } From a442120d0a40f3cfaf2076ddae35a3fc596178fd Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 24 Aug 2025 04:37:44 +0300 Subject: [PATCH 22/26] AI docs --- docs/GEOMETRY-ARRAYS.md | 34 +++++++--------------------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/docs/GEOMETRY-ARRAYS.md b/docs/GEOMETRY-ARRAYS.md index 4aab0934..fc958b65 100644 --- a/docs/GEOMETRY-ARRAYS.md +++ b/docs/GEOMETRY-ARRAYS.md @@ -58,7 +58,7 @@ class Location ```php use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; -// Single-item geometry[] array +// Single-item geometry[] array (supported) $qb = $connection->createQueryBuilder(); $qb->insert('locations')->values(['geometries' => ':wktSpatialData']); $qb->setParameter('wktSpatialData', [WktSpatialData::fromWkt('POINT(0 0)')], 'geometry[]'); @@ -71,30 +71,7 @@ $qb->setParameter('wktSpatialData', WktSpatialData::fromWkt('SRID=4326;POINT(-12 $qb->executeStatement(); ``` -### Parameter Binding with DBAL - -```php -use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; - -// Single-item geometry[] array -$qb = $connection->createQueryBuilder(); -$qb->insert('locations')->values(['geometries' => ':wktSpatialData']); -$qb->setParameter('wktSpatialData', [WktSpatialData::fromWkt('POINT(0 0)')], 'geometry[]'); -$qb->executeStatement(); - -// Single geography value -$qb = $connection->createQueryBuilder(); -$qb->insert('places')->values(['locations' => ':wktSpatialData']); -$qb->setParameter('wktSpatialData', WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), 'geography'); -$qb->executeStatement(); -``` - $this->geometries = array_map( - fn(string $wkt) => WktSpatialData::fromWkt($wkt), - $geometries - ); - } -} -``` +**Note**: Multi-item arrays have limitations - see the "Important Limitation: Multi-Item Arrays" section below. ### Working Examples @@ -259,8 +236,11 @@ The integration tests include both working single-item arrays and workarounded ( - **Single-item arrays**: Excellent performance, full PostgreSQL optimization - **Multi-item workarounds**: May have performance implications depending on the approach -- **Indexing**: PostgreSQL can index geometry arrays using GiST indexes -- **Query optimization**: Use appropriate spatial operators and indexes +- **Indexing**: GiST/operator classes only support spatial types like `geometry`/`geography`/`MULTIPOLYGON` and cannot directly index SQL array types like `geometry[]`. For proper spatial indexing, consider: + - Normalizing arrays into separate geometry rows with individual GiST indexes + - Materializing a single geometry (e.g., union or bounding geometry) into a `geometry` column for GiST indexing + - See [PostGIS documentation on spatial indexes](https://postgis.net/docs/using_postgis_dbmanagement.html#idm6696) for details +- **Query optimization**: Use appropriate spatial operators and indexes on individual geometry columns, not arrays ## Future Improvements From d1b5e36bf023c4788195305bd45c4234beeedd60 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 24 Aug 2025 04:46:10 +0300 Subject: [PATCH 23/26] no message --- .../Types/ValueObject/WktSpatialDataTest.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php index 5cf29a08..64a1ec68 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php @@ -42,6 +42,26 @@ public static function provideValidWkt(): array 'point z with srid' => ['SRID=4326;POINT Z(-122.4194 37.7749 100)', 'POINT', 4326], 'multipoint z' => ['MULTIPOINT Z((1 2 3), (4 5 6))', 'MULTIPOINT', null], 'geometrycollection m' => ['GEOMETRYCOLLECTION M(POINT M(1 2 3), LINESTRING M(0 0 1, 1 1 2))', 'GEOMETRYCOLLECTION', null], + // Multi-geometry types + 'multipoint' => ['MULTIPOINT((1 2), (3 4), (5 6))', 'MULTIPOINT', null], + 'multilinestring' => ['MULTILINESTRING((0 0, 1 1), (2 2, 3 3))', 'MULTILINESTRING', null], + 'multipolygon' => ['MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))', 'MULTIPOLYGON', null], + 'geometrycollection' => ['GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(0 0, 1 1))', 'GEOMETRYCOLLECTION', null], + // Circular geometry types (PostGIS extensions) + 'circularstring' => ['CIRCULARSTRING(0 0, 1 1, 2 0)', 'CIRCULARSTRING', null], + 'compoundcurve' => ['COMPOUNDCURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))', 'COMPOUNDCURVE', null], + 'curvepolygon' => ['CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0))', 'CURVEPOLYGON', null], + 'multicurve' => ['MULTICURVE((0 0, 1 1), CIRCULARSTRING(1 1, 2 0, 3 1))', 'MULTICURVE', null], + 'multisurface' => ['MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 1 1, 2 0, 0 0)))', 'MULTISURFACE', null], + // Triangle and TIN types + 'triangle' => ['TRIANGLE((0 0, 1 0, 0.5 1, 0 0))', 'TRIANGLE', null], + 'tin' => ['TIN(((0 0, 1 0, 0.5 1, 0 0)), ((1 0, 2 0, 1.5 1, 1 0)))', 'TIN', null], + 'polyhedralsurface' => ['POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((0 0, 0 1, 0 0 1, 0 0)))', 'POLYHEDRALSURFACE', null], + // Complex SRID combinations + 'complex geometry with srid' => ['SRID=4326;MULTIPOLYGON(((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))', 'MULTIPOLYGON', 4326], + 'circular geometry with srid' => ['SRID=4326;CIRCULARSTRING(0 0, 1 1, 2 0)', 'CIRCULARSTRING', 4326], + 'polygon with holes' => ['POLYGON((0 0, 0 3, 3 3, 3 0, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))', 'POLYGON', null], + 'complex geometrycollection' => ['GEOMETRYCOLLECTION(POINT(1 2), MULTILINESTRING((0 0, 1 1), (2 2, 3 3)), POLYGON((0 0, 0 1, 1 1, 1 0, 0 0)))', 'GEOMETRYCOLLECTION', null], ]; } @@ -69,6 +89,24 @@ public static function provideDimensionalModifierWkt(): array 'multipoint with zm' => ['MULTIPOINT ZM((1 2 3 4), (5 6 7 8))', DimensionalModifier::ZM], 'srid with z modifier' => ['SRID=4326;POINT Z(-122.4194 37.7749 100)', DimensionalModifier::Z], 'srid without modifier' => ['SRID=4326;POINT(-122.4194 37.7749)', null], + // Multi-geometry types with dimensional modifiers + 'multipoint with z' => ['MULTIPOINT Z((1 2 3), (4 5 6))', DimensionalModifier::Z], + 'multilinestring with m' => ['MULTILINESTRING M((0 0 1, 1 1 2), (2 2 3, 3 3 4))', DimensionalModifier::M], + 'multipolygon with zm' => ['MULTIPOLYGON ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)))', DimensionalModifier::ZM], + 'geometrycollection with z' => ['GEOMETRYCOLLECTION Z(POINT Z(1 2 3), LINESTRING Z(0 0 1, 1 1 2))', DimensionalModifier::Z], + // Circular geometry types with dimensional modifiers + 'circularstring with z' => ['CIRCULARSTRING Z(0 0 1, 1 1 2, 2 0 1)', DimensionalModifier::Z], + 'compoundcurve with m' => ['COMPOUNDCURVE M((0 0 1, 1 1 2), CIRCULARSTRING M(1 1 2, 2 0 1, 3 1 2))', DimensionalModifier::M], + 'curvepolygon with zm' => ['CURVEPOLYGON ZM(CIRCULARSTRING ZM(0 0 0 1, 1 1 0 1, 2 0 0 1, 0 0 0 1))', DimensionalModifier::ZM], + 'multicurve with z' => ['MULTICURVE Z((0 0 1, 1 1 2), CIRCULARSTRING Z(1 1 2, 2 0 1, 3 1 2))', DimensionalModifier::Z], + 'multisurface with m' => ['MULTISURFACE M(CURVEPOLYGON M(CIRCULARSTRING M(0 0 1, 1 1 2, 2 0 1, 0 0 1)))', DimensionalModifier::M], + // Triangle and TIN types with dimensional modifiers + 'triangle with z' => ['TRIANGLE Z((0 0 1, 1 0 1, 0.5 1 2, 0 0 1))', DimensionalModifier::Z], + 'tin with m' => ['TIN M(((0 0 1, 1 0 2, 0.5 1 3, 0 0 1)), ((1 0 2, 2 0 3, 1.5 1 4, 1 0 2)))', DimensionalModifier::M], + 'polyhedralsurface with zm' => ['POLYHEDRALSURFACE ZM(((0 0 0 1, 0 1 0 1, 1 1 0 1, 1 0 0 1, 0 0 0 1)), ((0 0 0 1, 0 1 0 1, 0 0 1 1, 0 0 0 1)))', DimensionalModifier::ZM], + // Complex SRID combinations with dimensional modifiers + 'complex geometry with srid and z' => ['SRID=4326;MULTIPOLYGON Z(((0 0 0, 0 1 0, 1 1 0, 1 0 0, 0 0 0)), ((2 2 0, 2 3 0, 3 3 0, 3 2 0, 2 2 0)))', DimensionalModifier::Z], + 'circular geometry with srid and m' => ['SRID=4326;CIRCULARSTRING M(0 0 1, 1 1 2, 2 0 1)', DimensionalModifier::M], ]; } From 382c20aeed89c801d4c682ec6dbf29d079b9e707 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Mon, 25 Aug 2025 18:47:25 +0300 Subject: [PATCH 24/26] no message --- docs/GEOMETRY-ARRAYS.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/GEOMETRY-ARRAYS.md b/docs/GEOMETRY-ARRAYS.md index fc958b65..34a2eb33 100644 --- a/docs/GEOMETRY-ARRAYS.md +++ b/docs/GEOMETRY-ARRAYS.md @@ -4,8 +4,7 @@ This document explains the usage, limitations, and workarounds for PostgreSQL `g ## Overview -The `GeometryArray` and `GeographyArray` types provide support for PostgreSQL's `GEOMETRY[]` and `GEOGRAPHY[]` array types, allowing you to store collections of spatial data in a single database column. - +The `GeometryArray` and `GeographyArray` types provide support for PostgreSQL's `GEOMETRY[]` and `GEOGRAPHY[]` array types, allowing you to store collections of spatial data in a single database column. The use of these types currently has several limitations due to Doctrine DBAL's parameter binding behavior. Workarounds are provided for multi-item arrays in [USE-CASES-AND-EXAMPLES.md](./USE-CASES-AND-EXAMPLES.md). ## Registration and Type Mapping @@ -30,20 +29,23 @@ $platform->registerDoctrineTypeMapping('_geography', 'geography[]'); ```php use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; +use Doctrine\ORM\Mapping as ORM; class Location { /** + * @var WktSpatialData[] * @ORM\Column(type="geometry[]") */ private array $geometries; /** + * @var WktSpatialData[] * @ORM\Column(type="geography[]") */ private array $geographies; - public function setGeometries(array $geometries): void + public function setGeometries(WktSpatialData ...$geometries): void { $this->geometries = array_map( fn(string $wkt) => WktSpatialData::fromWkt($wkt), @@ -71,7 +73,7 @@ $qb->setParameter('wktSpatialData', WktSpatialData::fromWkt('SRID=4326;POINT(-12 $qb->executeStatement(); ``` -**Note**: Multi-item arrays have limitations - see the "Important Limitation: Multi-Item Arrays" section below. +**Note**: Multi-item arrays have limitations β€” see "[Important Limitation: Multi-Item Arrays](#important-limitation-multi-item-arrays)" below. ### Working Examples @@ -102,7 +104,7 @@ $multiItem = [ It generates a PostgreSQL array literal: `{POINT(1 2),POINT(3 4)}` However, **PostGIS intercepts this and tries to parse the entire string as a single geometry**, causing this error: -``` +```text ERROR: parse error - invalid geometry HINT: "POINT(1 2),POI" <-- parse error at position 14 ``` @@ -145,7 +147,7 @@ The library normalizes dimensional modifiers based on enums for geometry types a Examples: -``` +```text POINTZ(1 2 3) => POINT Z(1 2 3) LINESTRINGM(0 0 1, 1 1 2) => LINESTRING M(0 0 1, 1 1 2) POLYGONZM((...)) => POLYGON ZM((...)) @@ -156,12 +158,11 @@ SRID=4326;POINT Z (1 2 3) => SRID=4326;POINT Z(1 2 3) See also: Spatial foundations and parser behavior in the Spatial Types document. ```php -// Build arrays in application code, then use raw SQL +// Build arrays in application code, then use raw SQL with placeholders $geometries = ['POINT(1 2)', 'POINT(3 4)', 'LINESTRING(0 0,1 1)']; -$arrayConstructor = 'ARRAY[' . implode('::geometry,', $geometries) . '::geometry]'; - -$sql = "INSERT INTO locations (geoms) VALUES ({$arrayConstructor})"; -$connection->executeStatement($sql); +$placeholders = implode(',', array_fill(0, count($geometries), '?::geometry')); +$sql = "INSERT INTO locations (geometries) VALUES (ARRAY[$placeholders])"; +$connection->executeStatement($sql, $geometries); ``` ### Option 4: JSON Storage Alternative @@ -230,16 +231,16 @@ The integration tests include both working single-item arrays and workarounded ( 2. **Test thoroughly** with your specific geometry combinations 3. **Consider alternatives** (JSON, separate tables) for complex multi-item scenarios 4. **Use raw SQL** when you need multi-item arrays and can control the SQL generation -5. **Monitor PostGIS updates** - this limitation may be addressed in future versions +5. **State tested versions** β€” e.g., "Verified on PostgreSQL 16.x + PostGIS 3.5.x"; monitor PostGIS updates in case this changes. ## Performance Considerations - **Single-item arrays**: Excellent performance, full PostgreSQL optimization - **Multi-item workarounds**: May have performance implications depending on the approach -- **Indexing**: GiST/operator classes only support spatial types like `geometry`/`geography`/`MULTIPOLYGON` and cannot directly index SQL array types like `geometry[]`. For proper spatial indexing, consider: +- **Indexing**: GiST/operator classes only support spatial types like `geometry`/`geography` and cannot directly index SQL array types like `geometry[]`. For proper spatial indexing, consider: - Normalizing arrays into separate geometry rows with individual GiST indexes - Materializing a single geometry (e.g., union or bounding geometry) into a `geometry` column for GiST indexing - - See [PostGIS documentation on spatial indexes](https://postgis.net/docs/using_postgis_dbmanagement.html#idm6696) for details + - See the [PostGIS FAQ on spatial indexes](https://postgis.net/documentation/faq/spatial-indexes/) for details - **Query optimization**: Use appropriate spatial operators and indexes on individual geometry columns, not arrays ## Future Improvements From 002cbd36ba56789b8ab18af0b28102498780a0bc Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Mon, 25 Aug 2025 17:10:34 +0100 Subject: [PATCH 25/26] Update docs/GEOMETRY-ARRAYS.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/GEOMETRY-ARRAYS.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/GEOMETRY-ARRAYS.md b/docs/GEOMETRY-ARRAYS.md index 34a2eb33..ba6d2ace 100644 --- a/docs/GEOMETRY-ARRAYS.md +++ b/docs/GEOMETRY-ARRAYS.md @@ -47,10 +47,7 @@ class Location public function setGeometries(WktSpatialData ...$geometries): void { - $this->geometries = array_map( - fn(string $wkt) => WktSpatialData::fromWkt($wkt), - $geometries - ); + $this->geometries = $geometries; } } ``` From 20e8abe12fe4071f18411b68917a9df5adfb290a Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Mon, 25 Aug 2025 19:27:00 +0300 Subject: [PATCH 26/26] no message --- .../MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php index 9617569b..b19964d6 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/TestCase.php @@ -28,9 +28,6 @@ protected function assertTypeValueEquals(mixed $expected, mixed $actual, string }; } - /** - * Build a deterministic table name for the given column type and optional suffix. - */ protected function buildTableName(string $columnType): string { return 'test_type_'.\strtolower(\str_replace([' ', '[]', '()'], ['_', '_array', ''], $columnType)); @@ -69,9 +66,6 @@ protected function fetchConvertedValue(string $typeName, string $tableName, stri return Type::getType($typeName)->convertToPHPValue($row[$columnName], $platform); } - /** - * Assert the round trip value, allowing nulls. - */ protected function assertRoundTrip(string $typeName, mixed $expected, mixed $retrieved): void { if ($expected === null) {