diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseSpatialType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseSpatialType.php new file mode 100644 index 00000000..e829fe5f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseSpatialType.php @@ -0,0 +1,44 @@ + + */ +abstract class BaseSpatialType extends BaseType +{ + /** + * Modifies the SQL expression to convert spatial data from binary to EWKT format. + * + * This ensures PostgreSQL returns spatial data in text format (EWKT) instead of binary (EWKB). + * This method is called by Doctrine ORM when generating SELECT queries. + * + * The SQL expression handles SRID: + * - If SRID is 0 (no SRID), returns plain WKT: "POINT(1 2)" + * - If SRID is set, returns EWKT with SRID prefix: "SRID=4326;POINT(1 2)" + * + * @param non-empty-string $sqlExpr + * @param AbstractPlatform $platform + */ + public function convertToPHPValueSQL($sqlExpr, $platform): string + { + return \sprintf( + "CASE WHEN ST_SRID(%s) = 0 THEN ST_AsText(%s) ELSE 'SRID=' || ST_SRID(%s) || ';' || ST_AsText(%s) END", + $sqlExpr, + $sqlExpr, + $sqlExpr, + $sqlExpr + ); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php index 0506371a..9a75932a 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geography.php @@ -18,7 +18,7 @@ * * @author Martin Georgiev */ -final class Geography extends BaseType +final class Geography extends BaseSpatialType { protected const TYPE_NAME = 'geography'; diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php index 9ba7b69b..8fc4344e 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Geometry.php @@ -18,7 +18,7 @@ * * @author Martin Georgiev */ -final class Geometry extends BaseType +final class Geometry extends BaseSpatialType { protected const TYPE_NAME = 'geometry'; diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php index 476f243f..3b4113c2 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeographyTypeTest.php @@ -4,12 +4,16 @@ namespace Tests\Integration\MartinGeorgiev\Doctrine\DBAL\Types; +use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsGeometries; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; final class GeographyTypeTest extends TestCase { + use OrmEntityPersistenceTrait; + use WktAssertionTrait; + protected function getTypeName(): string { return 'geography'; @@ -20,6 +24,43 @@ protected function getPostgresTypeName(): string return 'GEOGRAPHY'; } + protected function getEntityClass(): string + { + return ContainsGeometries::class; + } + + protected function getEntityColumnName(): string + { + return 'geography1'; + } + + protected function createTestTableForEntity(string $tableName): void + { + $this->dropTestTableIfItExists($tableName); + + $fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName); + $sql = \sprintf(' + CREATE TABLE %s ( + id SERIAL PRIMARY KEY, + geometry1 GEOMETRY, + geometry2 GEOMETRY, + geography1 GEOGRAPHY, + geography2 GEOGRAPHY + ) + ', $fullTableName); + + $this->connection->executeStatement($sql); + } + + protected function assertOrmValueEquals(mixed $expected, mixed $actual, string $typeName): void + { + if (!$expected instanceof WktSpatialData || !$actual instanceof WktSpatialData) { + throw new \InvalidArgumentException('Expected WktSpatialData value objects.'); + } + + $this->assertWktEquals($expected, $actual); + } + protected function getSelectExpression(string $columnName): string { // For geography, avoid adding SRID prefix to preserve original input format @@ -39,6 +80,26 @@ public function can_handle_geography_values(string $testName, WktSpatialData $wk $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); } + #[Test] + public function can_retrieve_null_geography_using_entity_manager_find(): void + { + $this->runOrmFindRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_retrieve_geography_values_using_entity_manager_find(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runOrmFindRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_retrieve_geography_values_using_dql_select(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runOrmDqlSelectRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + } + /** * @return array */ diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php index 16f8f16a..923f8e5c 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/GeometryTypeTest.php @@ -4,12 +4,16 @@ namespace Tests\Integration\MartinGeorgiev\Doctrine\DBAL\Types; +use Fixtures\MartinGeorgiev\Doctrine\Entity\ContainsGeometries; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; final class GeometryTypeTest extends TestCase { + use OrmEntityPersistenceTrait; + use WktAssertionTrait; + protected function getTypeName(): string { return 'geometry'; @@ -20,6 +24,43 @@ protected function getPostgresTypeName(): string return 'GEOMETRY'; } + protected function getEntityClass(): string + { + return ContainsGeometries::class; + } + + protected function getEntityColumnName(): string + { + return 'geometry1'; + } + + protected function createTestTableForEntity(string $tableName): void + { + $this->dropTestTableIfItExists($tableName); + + $fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName); + $sql = \sprintf(' + CREATE TABLE %s ( + id SERIAL PRIMARY KEY, + geometry1 GEOMETRY, + geometry2 GEOMETRY, + geography1 GEOGRAPHY, + geography2 GEOGRAPHY + ) + ', $fullTableName); + + $this->connection->executeStatement($sql); + } + + protected function assertOrmValueEquals(mixed $expected, mixed $actual, string $typeName): void + { + if (!$expected instanceof WktSpatialData || !$actual instanceof WktSpatialData) { + throw new \InvalidArgumentException('Expected WktSpatialData value objects.'); + } + + $this->assertWktEquals($expected, $actual); + } + protected function getSelectExpression(string $columnName): string { return \sprintf( @@ -46,6 +87,26 @@ public function can_handle_geometry_values(string $testName, WktSpatialData $wkt $this->runDbalBindingRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); } + #[Test] + public function can_retrieve_null_geometry_using_entity_manager_find(): void + { + $this->runOrmFindRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), null); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_retrieve_geometry_values_using_entity_manager_find(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runOrmFindRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_retrieve_geometry_values_using_dql_select(string $testName, WktSpatialData $wktSpatialData): void + { + $this->runOrmDqlSelectRoundTrip($this->getTypeName(), $this->getPostgresTypeName(), $wktSpatialData); + } + /** * @return array */ diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/OrmEntityPersistenceTrait.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/OrmEntityPersistenceTrait.php new file mode 100644 index 00000000..a471c46d --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/OrmEntityPersistenceTrait.php @@ -0,0 +1,183 @@ +find() + * and DQL SELECT queries, which rely on Doctrine ORM automatically calling + * convertToPHPValueSQL() to wrap columns in SQL expressions. + * + * Usage: + * 1. Implement getEntityClass() to return the entity FQCN + * 2. Implement getEntityColumnName() to return the column property name + * 3. Optionally override assertOrmValueEquals() for custom comparison logic + * + * Example: + * ```php + * final class GeometryTypeTest extends TestCase + * { + * use OrmEntityPersistenceTrait; + * + * protected function getEntityClass(): string + * { + * return ContainsGeometries::class; + * } + * + * protected function getEntityColumnName(): string + * { + * return 'geometry1'; + * } + * } + * ``` + */ +trait OrmEntityPersistenceTrait +{ + /** + * Returns the fully qualified class name of the entity to use for ORM tests. + * + * @return class-string + */ + abstract protected function getEntityClass(): string; + + /** + * Returns the property name of the column to test in the entity. + * + * @return non-empty-string + */ + abstract protected function getEntityColumnName(): string; + + /** + * Returns the table name for the entity (defaults to lowercase entity class name). + */ + protected function getEntityTableName(): string + { + $entityClass = $this->getEntityClass(); + $shortName = \substr((string) $entityClass, \strrpos((string) $entityClass, '\\') + 1); + + return \strtolower($shortName); + } + + /** + * Assert that ORM-retrieved value equals expected value. + */ + protected function assertOrmValueEquals(mixed $expected, mixed $actual, string $typeName): void + { + $this->assertTypeValueEquals($expected, $actual, $typeName); + } + + /** + * Test EntityManager->find() retrieves the value correctly. + * This verifies that convertToPHPValueSQL() works with ORM's find() method. + */ + protected function runOrmFindRoundTrip(string $typeName, string $columnType, mixed $value): void + { + [$tableName, $columnName] = $this->prepareTestTable($columnType); + + try { + $qb = $this->connection->createQueryBuilder(); + $qb->insert(self::DATABASE_SCHEMA.'.'.$tableName) + ->values([$columnName => ':value']) + ->setParameter('value', $value, $typeName) + ->executeStatement(); + + $entity = $this->entityManager->find($this->getEntityClass(), 1); + $this->assertNotNull($entity, 'Entity should be found by EntityManager::find()'); + + $propertyName = $this->getEntityColumnName(); + + if ($value === null) { + // For NULL values, check if property is initialized + // Doctrine may not initialize properties for NULL values + $reflectionProperty = new \ReflectionProperty($entity::class, $propertyName); + if (!$reflectionProperty->isInitialized($entity)) { + // Property not initialized means NULL value - this is expected + $this->addToAssertionCount(1); + } else { + $this->assertNull($entity->{$propertyName}); + } + } else { + $retrieved = $entity->{$propertyName}; + $this->assertOrmValueEquals($value, $retrieved, $typeName); + } + } finally { + $this->dropTestTableIfItExists($tableName); + } + } + + /** + * Test DQL SELECT query retrieves the value correctly. + * This verifies that convertToPHPValueSQL() works with DQL SELECT clauses. + */ + protected function runOrmDqlSelectRoundTrip(string $typeName, string $columnType, mixed $value): void + { + [$tableName, $columnName] = $this->prepareTestTable($columnType); + + try { + $qb = $this->connection->createQueryBuilder(); + $qb->insert(self::DATABASE_SCHEMA.'.'.$tableName) + ->values([$columnName => ':value']) + ->setParameter('value', $value, $typeName) + ->executeStatement(); + + $entityClass = $this->getEntityClass(); + $dql = \sprintf('SELECT e FROM %s e WHERE e.id = 1', $entityClass); + $query = $this->entityManager->createQuery($dql); + $result = $query->getSingleResult(); + + $this->assertNotNull($result, 'Entity should be retrieved by DQL SELECT'); + \assert(\is_object($result)); + $entity = $result; + + $propertyName = $this->getEntityColumnName(); + + if ($value === null) { + // For NULL values, check if property is initialized + $reflectionProperty = new \ReflectionProperty($entity::class, $propertyName); + if ($reflectionProperty->isInitialized($entity)) { + $this->assertNull($entity->{$propertyName}); + } else { + // Property not initialized means NULL value - this is expected + $this->addToAssertionCount(1); + } + } else { + $this->assertOrmValueEquals($value, $entity->{$propertyName}, $typeName); + } + } finally { + $this->dropTestTableIfItExists($tableName); + } + } + + /** + * @return array{string, string} + */ + protected function prepareTestTable(string $columnType): array + { + $tableName = $this->getEntityTableName(); + $columnName = $this->getEntityColumnName(); + $this->createTestTableForEntity($tableName); + + return [$tableName, $columnName]; + } + + /** + * Override this method in your test class to define the complete table schema + * for your entity. The table must include ALL columns from the entity, not just + * the one being tested, so Doctrine ORM can properly hydrate the entity. + * + * Example: + * ```php + * protected function createTestTableForEntity(string $tableName): void + * { + * $this->dropTestTableIfItExists($tableName); + * $fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName); + * $sql = \sprintf('CREATE TABLE %s (id SERIAL PRIMARY KEY, col1 TYPE1, col2 TYPE2)', $fullTableName); + * $this->connection->executeStatement($sql); + * } + * ``` + */ + abstract protected function createTestTableForEntity(string $tableName): void; +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/WktAssertionTrait.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/WktAssertionTrait.php new file mode 100644 index 00000000..98ccab0c --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/WktAssertionTrait.php @@ -0,0 +1,46 @@ +assertEquals( + $this->normalizeWkt((string) $expected), + $this->normalizeWkt((string) $actual), + 'WKT spatial data mismatch' + ); + } + + /** + * Handles SRID prefixes, whitespace, and decimal formatting differences. + */ + private function normalizeWkt(string $wkt): string + { + // Remove SRID prefix for comparison if present + if (\str_starts_with($wkt, 'SRID=')) { + $parts = \explode(';', $wkt, 2); + $wkt = $parts[1] ?? $wkt; + } + + // Normalize whitespace: PostgreSQL returns WKT without spaces after commas + $normalized = \preg_replace('/,\s+/', ',', $wkt); + if ($normalized === null) { + return $wkt; + } + + // Normalize decimal numbers: PostgreSQL may return -9.0 as -9 + $result = \preg_replace('/(\d)\.0(?=\D|$)/', '$1', $normalized); + + return $result ?? $normalized; + } +}