diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index beea2a2a..b6666a95 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -34,21 +34,23 @@ 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'] + postgis: ['3.4', '3.5'] postgres: ['16', '17'] include: - php: '8.4' + postgis: '3.5' postgres: '17' calculate-code-coverage: true services: postgres: - image: postgres:${{ matrix.postgres }} + image: postgis/postgis:${{ matrix.postgres }}-${{ matrix.postgis }} env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres @@ -110,6 +112,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..678bae70 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`, `geometry[]`) + - PostGIS Geography (`geography`, `geography[]`) - **Range Types** - Date and time ranges (`daterange`, `tsrange`, `tstzrange`) - Numeric ranges (`numrange`, `int4range`, `int8range`) @@ -98,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 @@ -122,10 +126,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 +139,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/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/devenv.nix b/devenv.nix index b05176ca..1d8e2a3e 100644 --- a/devenv.nix +++ b/devenv.nix @@ -60,14 +60,37 @@ 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}'; + -- 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} + 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..ad58bc7e 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/GEOMETRY-ARRAYS.md b/docs/GEOMETRY-ARRAYS.md new file mode 100644 index 00000000..ba6d2ace --- /dev/null +++ b/docs/GEOMETRY-ARRAYS.md @@ -0,0 +1,250 @@ +# 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. 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 + +```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; +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(WktSpatialData ...$geometries): void + { + $this->geometries = $geometries; + } +} +``` + +### Parameter Binding with DBAL + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; + +// Single-item geometry[] array (supported) +$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(); +``` + +**Note**: Multi-item arrays have limitations β€” see "[Important Limitation: Multi-Item Arrays](#important-limitation-multi-item-arrays)" below. + +### 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: +```text +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 +$geometries = [$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: + +```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((...)) +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 with placeholders +$geometries = ['POINT(1 2)', 'POINT(3 4)', 'LINESTRING(0 0,1 1)']; +$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 + +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. **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` 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 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 + +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..1db7d947 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('_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 bfd62451..7fa63975 100644 --- a/docs/USE-CASES-AND-EXAMPLES.md +++ b/docs/USE-CASES-AND-EXAMPLES.md @@ -139,3 +139,68 @@ 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 +--- + + +### 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 +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/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/GeographyArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php new file mode 100644 index 00000000..0d16e5db --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArray.php @@ -0,0 +1,33 @@ +getValidatedArrayItem($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/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/GeometryArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php new file mode 100644 index 00000000..7a19a209 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArray.php @@ -0,0 +1,33 @@ +getValidatedArrayItem($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..da8bf615 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/SpatialDataArray.php @@ -0,0 +1,275 @@ + $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 [ + // 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(', + // 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. + * + * Examples: + * - '{POINT(1 2),LINESTRING(0 0, 1 1)}' -> ['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 []; + } + + // 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($content); + + for ($charIndex = 0; $charIndex < $contentLength; $charIndex++) { + $currentChar = $content[$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); + } + + public function isValidArrayItemForDatabase(mixed $item): bool + { + return $item instanceof WktSpatialData; + } + + public function transformArrayItemForPHP(mixed $item): ?WktSpatialData + { + if ($item === null) { + return null; + } + + if (!\is_string($item)) { + throw $this->createInvalidTypeExceptionForPHP($item); + } + + try { + $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) { + $sridSeparatorPosition = \strpos($wkt, ';'); + if ($sridSeparatorPosition === false) { + throw InvalidWktSpatialDataException::forMissingSemicolonInEwkt(); + } + + $sridPrefix = \substr($wkt, 0, $sridSeparatorPosition + 1); + $wkt = \substr($wkt, $sridSeparatorPosition + 1); + } + + // Normalize dimensional modifiers using patterns built from WktGeometryType enum + foreach ($this->getDimensionalModifierPatterns() 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. + */ + 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/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 new file mode 100644 index 00000000..0f92e8f4 --- /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 (GeometryType $geometryType) => $geometryType->value, GeometryType::cases()); + + return new self(\sprintf( + '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 new file mode 100644 index 00000000..a8057509 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialData.php @@ -0,0 +1,109 @@ + + */ +final class WktSpatialData implements \Stringable +{ + private function __construct( + private readonly ?int $srid, + private readonly GeometryType $geometryType, + private readonly string $wktBody, + private readonly ?DimensionalModifier $dimensionalModifier = null + ) {} + + public function __toString(): string + { + $typeWithModifier = $this->geometryType->value; + if ($this->dimensionalModifier instanceof DimensionalModifier) { + $typeWithModifier .= ' '.$this->dimensionalModifier->value; + } + + $typeAndBody = $typeWithModifier.'('.$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]; + $dimensionalModifier = empty($matches[2]) ? null : DimensionalModifier::tryFrom($matches[2]); + $body = \trim($matches[3]); + if ($body === '') { + throw InvalidWktSpatialDataException::forEmptyCoordinateSection(); + } + + $geometryType = GeometryType::tryFrom($typeString); + if ($geometryType === null) { + throw InvalidWktSpatialDataException::forUnsupportedGeometryType($typeString); + } + + return new self($srid, $geometryType, $body, $dimensionalModifier); + } + + public function getSrid(): ?int + { + return $this->srid; + } + + public function getGeometryType(): GeometryType + { + return $this->geometryType; + } + + public function getDimensionalModifier(): ?DimensionalModifier + { + return $this->dimensionalModifier; + } + + public function getWkt(): string + { + return (string) $this; + } +} 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..cf5b9eb7 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTypeTest.php @@ -0,0 +1,114 @@ +runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $values); + } + + public static function provideSingleItemArrays(): array + { + return [ + // Single item tests - These work perfectly with Doctrine DBAL parameter binding + 'single point' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(-122.4194 37.7749)'), + ]], + 'single point with z dimension' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT Z(-122.4194 37.7749 100)'), + ]], + 'single point with m dimension' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT M(-122.4194 37.7749 1)'), + ]], + 'single point with zm dimension' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT ZM(-122.4194 37.7749 100 1)'), + ]], + 'single linestring' => [[ + WktSpatialData::fromWkt('SRID=4326;LINESTRING(-122.4194 37.7749,-122.4094 37.7849,-122.4 37.79)'), + ]], + '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 null island' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(0 0)'), + ]], + 'world coordinate north pole' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(0 90)'), + ]], + '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 + { + $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), 'geography', ...$phpArray); + } + + /** + * @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))'), + ]], + ]; + } +} 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..476f243f --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -0,0 +1,57 @@ +runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_handle_geography_values(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runDbalBindingRoundTrip($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 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/GeometryArrayTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php new file mode 100644 index 00000000..c9d7fbf6 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTypeTest.php @@ -0,0 +1,114 @@ +runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $values); + } + + public static function provideSingleItemArrays(): array + { + return [ + // Single item tests - These work perfectly with Doctrine DBAL parameter binding + 'single point' => [[ + WktSpatialData::fromWkt('POINT(0 0)'), + ]], + 'single point with z dimension' => [[ + WktSpatialData::fromWkt('POINT Z(1 2 3)'), + ]], + 'single point with m dimension' => [[ + WktSpatialData::fromWkt('POINT M(1 2 3)'), + ]], + 'single point with zm dimensions' => [[ + WktSpatialData::fromWkt('POINT ZM(1 2 3 4)'), + ]], + 'single ewkt with srid' => [[ + WktSpatialData::fromWkt('SRID=4326;POINT(0 0)'), + ]], + '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))'), + ]], + 'single multipoint' => [[ + WktSpatialData::fromWkt('MULTIPOINT((1 2),(3 4))'), + ]], + '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 + { + $this->runArrayConstructorTypeTest($this->getTypeName(), $this->getPostgresTypeName(), 'geometry', ...$phpArray); + } + + /** + * @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=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 new file mode 100644 index 00000000..16f8f16a --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -0,0 +1,66 @@ +runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_handle_geometry_values(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runDbalBindingRoundTrip($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)')], + '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/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php new file mode 100644 index 00000000..4e424d87 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/SpatialArrayTypeTestCase.php @@ -0,0 +1,65 @@ +prepareTestTable($columnType); + + try { + $placeholders = \implode(',', \array_fill(0, \count($spatialData), '?::'.$elementPgType)); + $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/TestCase.php b/tests/Integration/MartinGeorgiev/TestCase.php index c70253c9..b112c5be 100644 --- a/tests/Integration/MartinGeorgiev/TestCase.php +++ b/tests/Integration/MartinGeorgiev/TestCase.php @@ -19,6 +19,10 @@ 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\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; @@ -156,7 +160,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 +187,10 @@ protected function registerCustomTypes(): void 'cidr[]' => CidrArray::class, '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/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/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`. 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..d001d6d1 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyArrayTest.php @@ -0,0 +1,322 @@ +type = new GeographyArray(); + $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 + { + $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)}', + ], + '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))}', + ], + ]; + } + + #[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::assertIsArray($result); + 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)'], + ], + // 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))'], + ], + '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::assertIsArray($convertedBack); + self::assertCount(\count($phpArray), $convertedBack); + + foreach ($convertedBack as $index => $item) { + self::assertInstanceOf(WktSpatialData::class, $item); + $originalItem = $phpArray[$index]; + self::assertInstanceOf(WktSpatialData::class, $originalItem); + self::assertSame((string) $originalItem, (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 + ], + ], + ]; + } + + #[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 new file mode 100644 index 00000000..d2cce4be --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTest.php @@ -0,0 +1,224 @@ +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' => [ + 'wktSpatialData' => null, + 'postgresValue' => null, + ], + 'point' => [ + '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(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)', + ], + // 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)', + ], + ]; + } + + #[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/GeometryArrayTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php new file mode 100644 index 00000000..389bb238 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryArrayTest.php @@ -0,0 +1,392 @@ +type = new GeometryArray(); + $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 + { + $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)))}', + ], + '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))}', + ], + ]; + } + + #[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::assertIsArray($result); + 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))'], + ], + // 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)'], + ], + // 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))'], + ], + ]; + } + + #[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::assertIsArray($convertedBack); + self::assertCount(\count($phpArray), $convertedBack); + + foreach ($convertedBack as $index => $item) { + self::assertInstanceOf(WktSpatialData::class, $item); + $originalItem = $phpArray[$index]; + self::assertInstanceOf(WktSpatialData::class, $originalItem); + self::assertSame((string) $originalItem, (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)))'), + ], + ], + ]; + } + + #[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 new file mode 100644 index 00000000..c7f71704 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTest.php @@ -0,0 +1,224 @@ +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' => [ + 'wktSpatialData' => null, + 'postgresValue' => null, + ], + 'point' => [ + '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(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)', + ], + // 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)', + ], + ]; + } + + #[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/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 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/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/WktSpatialDataTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php new file mode 100644 index 00000000..64a1ec68 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/WktSpatialDataTest.php @@ -0,0 +1,170 @@ +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], + '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], + // 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], + ]; + } + + #[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], + // 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], + ]; + } + + #[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)'], + '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))'], + ]; + } +}