From 5d313a8115bdca243deaabf3ff726ff50fda6b19 Mon Sep 17 00:00:00 2001 From: seb-jean Date: Mon, 7 Apr 2025 07:26:51 +0200 Subject: [PATCH 1/6] feat: add support for point type --- docs/AVAILABLE-TYPES.md | 2 + docs/INTEGRATING-WITH-DOCTRINE.md | 2 + docs/INTEGRATING-WITH-LARAVEL.md | 5 + docs/INTEGRATING-WITH-SYMFONY.md | 2 + ...alidPointArrayItemForDatabaseException.php | 25 ++++ .../InvalidPointArrayItemForPHPException.php | 35 +++++ .../InvalidPointForDatabaseException.php | 30 ++++ .../InvalidPointForPHPException.php | 25 ++++ .../Doctrine/DBAL/Types/Point.php | 53 +++++++ .../Doctrine/DBAL/Types/PointArray.php | 82 +++++++++++ src/MartinGeorgiev/ValueObject/Point.php | 33 +++++ .../Doctrine/DBAL/Types/PointArrayTest.php | 120 ++++++++++++++++ .../Doctrine/DBAL/Types/PointTest.php | 132 ++++++++++++++++++ 13 files changed, 546 insertions(+) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php create mode 100644 src/MartinGeorgiev/ValueObject/Point.php create mode 100644 tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php create mode 100644 tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php diff --git a/docs/AVAILABLE-TYPES.md b/docs/AVAILABLE-TYPES.md index 7fc38cd8..7e82a289 100644 --- a/docs/AVAILABLE-TYPES.md +++ b/docs/AVAILABLE-TYPES.md @@ -17,3 +17,5 @@ | cidr[] | _cidr | `MartinGeorgiev\Doctrine\DBAL\Types\CidrArray` | | 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` | diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index 096e47bf..defb6c2a 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -17,6 +17,7 @@ Type::addType('bigint[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\BigIntArray"); Type::addType('text[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TextArray"); Type::addType('jsonb', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Jsonb"); Type::addType('jsonb[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\JsonbArray"); +Type::addType('point', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Point"); ``` @@ -91,6 +92,7 @@ $platform->registerDoctrineTypeMapping('_int8','bigint[]'); $platform->registerDoctrineTypeMapping('text[]','text[]'); $platform->registerDoctrineTypeMapping('_text','text[]'); $platform->registerDoctrineTypeMapping('jsonb','jsonb'); +$platform->registerDoctrineTypeMapping('point','point'); ... ``` diff --git a/docs/INTEGRATING-WITH-LARAVEL.md b/docs/INTEGRATING-WITH-LARAVEL.md index ca0b3888..e6e7f2e9 100644 --- a/docs/INTEGRATING-WITH-LARAVEL.md +++ b/docs/INTEGRATING-WITH-LARAVEL.md @@ -30,6 +30,7 @@ return [ 'jsonb' => 'jsonb', '_jsonb' => 'jsonb[]', 'jsonb[]' => 'jsonb[]', + 'point' => 'point', ], ], ], @@ -53,6 +54,7 @@ return [ 'text[]' => MartinGeorgiev\Doctrine\DBAL\Types\TextArray::class, 'jsonb' => MartinGeorgiev\Doctrine\DBAL\Types\Jsonb::class, 'jsonb[]' => MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray::class, + 'point' => MartinGeorgiev\Doctrine\DBAL\Types\Point::class, ], ]; ``` @@ -239,6 +241,9 @@ class DoctrineEventSubscriber implements Subscriber if (!Type::hasType('jsonb[]')) { Type::addType('jsonb[]', \MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray::class); } + if (!Type::hasType('point')) { + Type::addType('point', \MartinGeorgiev\Doctrine\DBAL\Types\Point::class); + } } } ``` diff --git a/docs/INTEGRATING-WITH-SYMFONY.md b/docs/INTEGRATING-WITH-SYMFONY.md index e08e1d3c..c0a203e2 100644 --- a/docs/INTEGRATING-WITH-SYMFONY.md +++ b/docs/INTEGRATING-WITH-SYMFONY.md @@ -17,6 +17,7 @@ doctrine: text[]: MartinGeorgiev\Doctrine\DBAL\Types\TextArray jsonb: MartinGeorgiev\Doctrine\DBAL\Types\Jsonb jsonb[]: MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray + point: MartinGeorgiev\Doctrine\DBAL\Types\Point ``` @@ -46,6 +47,7 @@ doctrine: jsonb: jsonb jsonb[]: jsonb[] _jsonb: jsonb[] + point: point ``` diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php new file mode 100644 index 00000000..8e3303be --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php @@ -0,0 +1,25 @@ + + */ +class InvalidPointArrayItemForDatabaseException extends ConversionException +{ + private static function create(string $message, mixed $value): self + { + return new self(\sprintf($message, \var_export($value, true))); + } + + public static function isNotAPoint(mixed $value): self + { + return self::create('Given value of %s is not a point.', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php new file mode 100644 index 00000000..24c86ab5 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php @@ -0,0 +1,35 @@ + + */ +class InvalidPointArrayItemForPHPException 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('Array values must be strings, %s given', $value); + } + + public static function forInvalidFormat(mixed $value): self + { + return self::create('Invalid point format in array: %s', $value); + } + + public static function forInvalidArrayType(mixed $value): self + { + return self::create('Value must be an array, %s given', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php new file mode 100644 index 00000000..6ca9426b --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php @@ -0,0 +1,30 @@ + + */ +class InvalidPointForDatabaseException 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 point format in database: %s', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php new file mode 100644 index 00000000..2691982a --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php @@ -0,0 +1,25 @@ + + */ +class InvalidPointForPHPException 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 point, %s given', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php new file mode 100644 index 00000000..5360838a --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php @@ -0,0 +1,53 @@ + + */ +class Point extends BaseType +{ + protected const TYPE_NAME = 'point'; + + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if (!$value instanceof PointValueObject) { + throw InvalidPointForPHPException::forInvalidType($value); + } + + return (string) $value; + } + + public function convertToPHPValue($value, AbstractPlatform $platform): ?PointValueObject + { + if ($value === null) { + return null; + } + + if (!\is_string($value)) { + throw InvalidPointForDatabaseException::forInvalidType($value); + } + + if (!\preg_match('/\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)/', $value, $matches)) { + throw InvalidPointForDatabaseException::forInvalidFormat($value); + } + + return new PointValueObject((float) $matches[1], (float) $matches[2]); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php new file mode 100644 index 00000000..d60e0bd9 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php @@ -0,0 +1,82 @@ + + */ +class PointArray extends BaseArray +{ + protected const TYPE_NAME = 'point[]'; + + protected function transformArrayItemForPostgres(mixed $item): string + { + if (!$item instanceof PointValueObject) { + throw InvalidPointArrayItemForDatabaseException::isNotAPoint($item); + } + + return '"'.$item.'"'; + } + + protected function transformPostgresArrayToPHPArray(string $postgresArray): array + { + if (!\str_starts_with($postgresArray, '{"') || !\str_ends_with($postgresArray, '"}')) { + return []; + } + + $trimmedPostgresArray = \mb_substr($postgresArray, 2, -2); + if ($trimmedPostgresArray === '') { + return []; + } + + return \explode('","', $trimmedPostgresArray); + } + + public function transformArrayItemForPHP(mixed $item): ?PointValueObject + { + if ($item === null) { + return null; + } + + if (!\is_string($item)) { + $this->throwInvalidTypeException($item); + } + + if (!\preg_match('/^\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)$/', $item, $matches)) { + $this->throwInvalidFormatException($item); + } + + return new PointValueObject((float) $matches[1], (float) $matches[2]); + } + + public function isValidArrayItemForDatabase(mixed $item): bool + { + return $item instanceof PointValueObject; + } + + protected function throwInvalidTypeException(mixed $value): never + { + throw InvalidPointArrayItemForPHPException::forInvalidType($value); + } + + protected function throwInvalidFormatException(mixed $value): never + { + throw InvalidPointArrayItemForPHPException::forInvalidFormat($value); + } + + protected function throwInvalidItemException(): never + { + throw InvalidPointArrayItemForPHPException::forInvalidFormat('Array contains invalid point items'); + } +} diff --git a/src/MartinGeorgiev/ValueObject/Point.php b/src/MartinGeorgiev/ValueObject/Point.php new file mode 100644 index 00000000..dc4fea49 --- /dev/null +++ b/src/MartinGeorgiev/ValueObject/Point.php @@ -0,0 +1,33 @@ + + */ +final class Point implements \Stringable +{ + public function __construct( + private readonly float $x, + private readonly float $y, + ) {} + + public function __toString(): string + { + return \sprintf('(%f, %f)', $this->x, $this->y); + } + + public function getX(): float + { + return $this->x; + } + + public function getY(): float + { + return $this->y; + } +} diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php new file mode 100644 index 00000000..22f68bc6 --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php @@ -0,0 +1,120 @@ +platform = $this->createMock(AbstractPlatform::class); + $this->fixture = new PointArray(); + } + + /** + * @test + */ + public function has_name(): void + { + self::assertEquals('point[]', $this->fixture->getName()); + } + + /** + * @test + * + * @dataProvider provideValidTransformations + */ + public function can_transform_from_php_value(?array $phpValue, ?string $postgresValue): void + { + self::assertEquals($postgresValue, $this->fixture->convertToDatabaseValue($phpValue, $this->platform)); + } + + /** + * @test + * + * @dataProvider provideValidTransformations + */ + public function can_transform_to_php_value(?array $phpValue, ?string $postgresValue): void + { + self::assertEquals($phpValue, $this->fixture->convertToPHPValue($postgresValue, $this->platform)); + } + + /** + * @return array|null, postgresValue: string|null}> + */ + public static function provideValidTransformations(): array + { + return [ + 'null' => [ + 'phpValue' => null, + 'postgresValue' => null, + ], + 'empty array' => [ + 'phpValue' => [], + 'postgresValue' => '{}', + ], + 'single point' => [ + 'phpValue' => [new PointValueObject(1.23, 4.56)], + 'postgresValue' => '{"(1.230000, 4.560000)"}', + ], + 'multiple points' => [ + 'phpValue' => [ + new PointValueObject(1.23, 4.56), + new PointValueObject(-7.89, 0.12), + ], + 'postgresValue' => '{"(1.230000, 4.560000)","(-7.890000, 0.120000)"}', + ], + 'points with zero values' => [ + 'phpValue' => [ + new PointValueObject(0.0, 0.0), + new PointValueObject(10.5, -3.7), + ], + 'postgresValue' => '{"(0.000000, 0.000000)","(10.500000, -3.700000)"}', + ], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidTransformations + */ + public function throws_exception_when_invalid_data_provided_to_convert_to_database_value(mixed $phpValue): void + { + $this->expectException(InvalidPointArrayItemForPHPException::class); + $this->fixture->convertToDatabaseValue($phpValue, $this->platform); // @phpstan-ignore-line + } + + /** + * @return array + */ + public static function provideInvalidTransformations(): array + { + return [ + 'not an array' => ['string value'], + 'array containing non-Point items' => [[1, 2, 3]], + 'invalid nested point' => [['(1.23,4.56)']], + 'mixed array (valid and invalid points)' => [ + [ + new PointValueObject(1.23, 4.56), + 'invalid', + ], + ], + ]; + } +} diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php new file mode 100644 index 00000000..46796b7f --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php @@ -0,0 +1,132 @@ +platform = $this->createMock(AbstractPlatform::class); + $this->fixture = new Point(); + } + + /** + * @test + */ + public function has_name(): void + { + self::assertEquals('point', $this->fixture->getName()); + } + + /** + * @test + * + * @dataProvider provideValidTransformations + */ + public function can_transform_from_php_value(?PointValueObject $pointValueObject, ?string $postgresValue): void + { + self::assertEquals($postgresValue, $this->fixture->convertToDatabaseValue($pointValueObject, $this->platform)); + } + + /** + * @test + * + * @dataProvider provideValidTransformations + */ + public function can_transform_to_php_value(?PointValueObject $pointValueObject, ?string $postgresValue): void + { + self::assertEquals($pointValueObject, $this->fixture->convertToPHPValue($postgresValue, $this->platform)); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + 'null' => [ + 'phpValue' => null, + 'postgresValue' => null, + ], + 'valid point' => [ + 'phpValue' => new PointValueObject(1.23, 4.56), + 'postgresValue' => '(1.230000, 4.560000)', + ], + 'negative coordinates' => [ + 'phpValue' => new PointValueObject(-1.23, -4.56), + 'postgresValue' => '(-1.230000, -4.560000)', + ], + 'zero coordinates' => [ + 'phpValue' => new PointValueObject(0.0, 0.0), + 'postgresValue' => '(0.000000, 0.000000)', + ], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidTransformations + */ + public function throws_exception_when_invalid_data_provided_to_convert_to_database_value(mixed $phpValue): void + { + $this->expectException(InvalidPointForPHPException::class); + $this->fixture->convertToDatabaseValue($phpValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidTransformations(): array + { + return [ + 'empty string' => [''], + 'whitespace string' => [' '], + 'invalid format' => ['invalid point'], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidDatabaseValues + */ + public function throws_exception_when_invalid_data_provided_to_convert_to_php_value(mixed $phpValue): void + { + $this->expectException(InvalidPointForDatabaseException::class); + $this->fixture->convertToPHPValue($phpValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidDatabaseValues(): array + { + return [ + 'empty string' => [''], + 'whitespace string' => [' '], + 'invalid format' => ['1.23,4.56'], + 'missing parentheses' => ['1.23,4.56)'], + 'non-numeric values' => ['(a,b)'], + 'too many coordinates' => ['(1.23,4.56,7.89)'], + 'not a string' => [123], + ]; + } +} From 3653080e024ae5a226f22e5a06d644357eaae18c Mon Sep 17 00:00:00 2001 From: seb-jean Date: Sat, 19 Apr 2025 06:47:49 +0200 Subject: [PATCH 2/6] feat: add support for POINT and POINT[] data types --- docs/INTEGRATING-WITH-DOCTRINE.md | 3 +++ docs/INTEGRATING-WITH-LARAVEL.md | 4 ++++ docs/INTEGRATING-WITH-SYMFONY.md | 3 +++ ...nvalidPointArrayItemForDatabaseException.php | 2 +- .../InvalidPointArrayItemForPHPException.php | 2 +- .../InvalidPointForDatabaseException.php | 2 +- .../Exceptions/InvalidPointForPHPException.php | 2 +- .../Doctrine/DBAL/Types/Point.php | 8 ++++---- .../Doctrine/DBAL/Types/PointArray.php | 8 ++++---- .../DBAL/Types}/ValueObject/Point.php | 4 ++-- .../Doctrine/DBAL/Types/PointArrayTest.php | 16 ++++++++-------- .../Doctrine/DBAL/Types/PointTest.php | 17 +++++++++++------ 12 files changed, 43 insertions(+), 28 deletions(-) rename src/MartinGeorgiev/{ => Doctrine/DBAL/Types}/ValueObject/Point.php (80%) diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index a0d7fc5b..1f9371d6 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -18,6 +18,7 @@ Type::addType('text[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TextArray"); Type::addType('jsonb', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Jsonb"); Type::addType('jsonb[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\JsonbArray"); Type::addType('point', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Point"); +Type::addType('point[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\PointArray"); ``` @@ -170,6 +171,8 @@ $platform->registerDoctrineTypeMapping('text[]','text[]'); $platform->registerDoctrineTypeMapping('_text','text[]'); $platform->registerDoctrineTypeMapping('jsonb','jsonb'); $platform->registerDoctrineTypeMapping('point','point'); +$platform->registerDoctrineTypeMapping('point[]','point[]'); +$platform->registerDoctrineTypeMapping('_point','point[]'); ... ``` diff --git a/docs/INTEGRATING-WITH-LARAVEL.md b/docs/INTEGRATING-WITH-LARAVEL.md index 62217a46..4a501bb9 100644 --- a/docs/INTEGRATING-WITH-LARAVEL.md +++ b/docs/INTEGRATING-WITH-LARAVEL.md @@ -55,6 +55,7 @@ return [ 'jsonb' => MartinGeorgiev\Doctrine\DBAL\Types\Jsonb::class, 'jsonb[]' => MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray::class, 'point' => MartinGeorgiev\Doctrine\DBAL\Types\Point::class, + 'point[]' => MartinGeorgiev\Doctrine\DBAL\Types\PointArray::class, ], ]; ``` @@ -247,6 +248,9 @@ class DoctrineEventSubscriber implements Subscriber if (!Type::hasType('point')) { Type::addType('point', \MartinGeorgiev\Doctrine\DBAL\Types\Point::class); } + if (!Type::hasType('point[]')) { + Type::addType('point[]', \MartinGeorgiev\Doctrine\DBAL\Types\PointArray::class); + } } } ``` diff --git a/docs/INTEGRATING-WITH-SYMFONY.md b/docs/INTEGRATING-WITH-SYMFONY.md index cdc890ad..71470335 100644 --- a/docs/INTEGRATING-WITH-SYMFONY.md +++ b/docs/INTEGRATING-WITH-SYMFONY.md @@ -18,6 +18,7 @@ doctrine: jsonb: MartinGeorgiev\Doctrine\DBAL\Types\Jsonb jsonb[]: MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray point: MartinGeorgiev\Doctrine\DBAL\Types\Point + point[]: MartinGeorgiev\Doctrine\DBAL\Types\PointArray ``` @@ -48,6 +49,8 @@ doctrine: jsonb[]: jsonb[] _jsonb: jsonb[] point: point + point[]: point[] + _point: point[] ``` diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php index 8e3303be..ee95fb67 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForDatabaseException.php @@ -9,7 +9,7 @@ /** * @since 3.1 * - * @author Martin Georgiev + * @author Sébastien Jean */ class InvalidPointArrayItemForDatabaseException extends ConversionException { diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php index 24c86ab5..04b8e1e4 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointArrayItemForPHPException.php @@ -9,7 +9,7 @@ /** * @since 3.1 * - * @author Martin Georgiev + * @author Sébastien Jean */ class InvalidPointArrayItemForPHPException extends ConversionException { diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php index 6ca9426b..4aa55a29 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForDatabaseException.php @@ -9,7 +9,7 @@ /** * @since 3.1 * - * @author Martin Georgiev + * @author Sébastien Jean */ class InvalidPointForDatabaseException extends ConversionException { diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php index 2691982a..8c67663a 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidPointForPHPException.php @@ -9,7 +9,7 @@ /** * @since 3.1 * - * @author Martin Georgiev + * @author Sébastien Jean */ class InvalidPointForPHPException extends ConversionException { diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php index 5360838a..c3b9ac10 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php @@ -7,15 +7,15 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointForDatabaseException; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointForPHPException; -use MartinGeorgiev\ValueObject\Point as PointValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point as PointValueObject; /** * Implementation of PostgreSQL POINT data type. * - * @see https://www.postgresql.org/docs/current/datatype-geometric.html#DATATYPE-GEOMETRIC-POINTS + * @see https://www.postgresql.org/docs/17/datatype-geometric.html#DATATYPE-GEOMETRIC-POINTS * @since 3.1 * - * @author Martin Georgiev + * @author Sébastien Jean */ class Point extends BaseType { @@ -44,7 +44,7 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?PointVal throw InvalidPointForDatabaseException::forInvalidType($value); } - if (!\preg_match('/\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)/', $value, $matches)) { + if (!\preg_match('/\((-?\d+(?:\.\d{1,6})?),\s*(-?\d+(?:\.\d{1,6})?)\)/', $value, $matches)) { throw InvalidPointForDatabaseException::forInvalidFormat($value); } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php index d60e0bd9..475774e0 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php @@ -6,15 +6,15 @@ use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointArrayItemForDatabaseException; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointArrayItemForPHPException; -use MartinGeorgiev\ValueObject\Point as PointValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point as PointValueObject; /** * Implementation of PostgreSQL POINT[] data type. * - * @see https://www.postgresql.org/docs/current/datatype-geometric.html#DATATYPE-GEOMETRIC-POINTS + * @see https://www.postgresql.org/docs/17/datatype-geometric.html#DATATYPE-GEOMETRIC-POINTS * @since 3.1 * - * @author Martin Georgiev + * @author Sébastien Jean */ class PointArray extends BaseArray { @@ -53,7 +53,7 @@ public function transformArrayItemForPHP(mixed $item): ?PointValueObject $this->throwInvalidTypeException($item); } - if (!\preg_match('/^\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)$/', $item, $matches)) { + if (!\preg_match('/^\((-?\d+(?:\.\d{1,6})?),\s*(-?\d+(?:\.\d{1,6})?)\)$/', $item, $matches)) { $this->throwInvalidFormatException($item); } diff --git a/src/MartinGeorgiev/ValueObject/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php similarity index 80% rename from src/MartinGeorgiev/ValueObject/Point.php rename to src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php index dc4fea49..36a15638 100644 --- a/src/MartinGeorgiev/ValueObject/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace MartinGeorgiev\ValueObject; +namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; /** * @since 3.1 * - * @author Martin Georgiev + * @author Sébastien Jean */ final class Point implements \Stringable { diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php index 22f68bc6..2c6de1a8 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php @@ -7,7 +7,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointArrayItemForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\PointArray; -use MartinGeorgiev\ValueObject\Point as PointValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -55,7 +55,7 @@ public function can_transform_to_php_value(?array $phpValue, ?string $postgresVa } /** - * @return array|null, postgresValue: string|null}> + * @return array|null, postgresValue: string|null}> */ public static function provideValidTransformations(): array { @@ -69,20 +69,20 @@ public static function provideValidTransformations(): array 'postgresValue' => '{}', ], 'single point' => [ - 'phpValue' => [new PointValueObject(1.23, 4.56)], + 'phpValue' => [new Point(1.23, 4.56)], 'postgresValue' => '{"(1.230000, 4.560000)"}', ], 'multiple points' => [ 'phpValue' => [ - new PointValueObject(1.23, 4.56), - new PointValueObject(-7.89, 0.12), + new Point(1.23, 4.56), + new Point(-7.89, 0.12), ], 'postgresValue' => '{"(1.230000, 4.560000)","(-7.890000, 0.120000)"}', ], 'points with zero values' => [ 'phpValue' => [ - new PointValueObject(0.0, 0.0), - new PointValueObject(10.5, -3.7), + new Point(0.0, 0.0), + new Point(10.5, -3.7), ], 'postgresValue' => '{"(0.000000, 0.000000)","(10.500000, -3.700000)"}', ], @@ -111,7 +111,7 @@ public static function provideInvalidTransformations(): array 'invalid nested point' => [['(1.23,4.56)']], 'mixed array (valid and invalid points)' => [ [ - new PointValueObject(1.23, 4.56), + new Point(1.23, 4.56), 'invalid', ], ], diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php index 46796b7f..57b4526a 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php @@ -8,7 +8,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointForDatabaseException; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidPointForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\Point; -use MartinGeorgiev\ValueObject\Point as PointValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point as PointValueObject; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -56,27 +56,31 @@ public function can_transform_to_php_value(?PointValueObject $pointValueObject, } /** - * @return array + * @return array */ public static function provideValidTransformations(): array { return [ 'null' => [ - 'phpValue' => null, + 'pointValueObject' => null, 'postgresValue' => null, ], 'valid point' => [ - 'phpValue' => new PointValueObject(1.23, 4.56), + 'pointValueObject' => new PointValueObject(1.23, 4.56), 'postgresValue' => '(1.230000, 4.560000)', ], 'negative coordinates' => [ - 'phpValue' => new PointValueObject(-1.23, -4.56), + 'pointValueObject' => new PointValueObject(-1.23, -4.56), 'postgresValue' => '(-1.230000, -4.560000)', ], 'zero coordinates' => [ - 'phpValue' => new PointValueObject(0.0, 0.0), + 'pointValueObject' => new PointValueObject(0.0, 0.0), 'postgresValue' => '(0.000000, 0.000000)', ], + 'maximum float precision' => [ + 'pointValueObject' => new PointValueObject(45.123456, 179.987654), + 'postgresValue' => '(45.123456, 179.987654)', + ], ]; } @@ -127,6 +131,7 @@ public static function provideInvalidDatabaseValues(): array 'non-numeric values' => ['(a,b)'], 'too many coordinates' => ['(1.23,4.56,7.89)'], 'not a string' => [123], + 'maximum float precision' => ['(1.23456789,7.89)'], ]; } } From 69058f68584c4d88f7aaee6f905a94dfa184f32c Mon Sep 17 00:00:00 2001 From: seb-jean Date: Tue, 22 Apr 2025 21:31:18 +0200 Subject: [PATCH 3/6] feat: add support for POINT and POINT[] data types --- .../Doctrine/DBAL/Types/Point.php | 6 +- .../Doctrine/DBAL/Types/PointArray.php | 6 +- .../Doctrine/DBAL/Types/ValueObject/Point.php | 29 +++++- .../Doctrine/DBAL/Types/PointArrayTest.php | 99 ++++++++++++++++++- .../Doctrine/DBAL/Types/PointTest.php | 4 +- 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php index c3b9ac10..49f6285b 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Point.php @@ -44,10 +44,10 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?PointVal throw InvalidPointForDatabaseException::forInvalidType($value); } - if (!\preg_match('/\((-?\d+(?:\.\d{1,6})?),\s*(-?\d+(?:\.\d{1,6})?)\)/', $value, $matches)) { + try { + return PointValueObject::fromString($value); + } catch (\InvalidArgumentException) { throw InvalidPointForDatabaseException::forInvalidFormat($value); } - - return new PointValueObject((float) $matches[1], (float) $matches[2]); } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php index 475774e0..c6aa8f0f 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php @@ -53,11 +53,11 @@ public function transformArrayItemForPHP(mixed $item): ?PointValueObject $this->throwInvalidTypeException($item); } - if (!\preg_match('/^\((-?\d+(?:\.\d{1,6})?),\s*(-?\d+(?:\.\d{1,6})?)\)$/', $item, $matches)) { + try { + return PointValueObject::fromString($item); + } catch (\InvalidArgumentException) { $this->throwInvalidFormatException($item); } - - return new PointValueObject((float) $matches[1], (float) $matches[2]); } public function isValidArrayItemForDatabase(mixed $item): bool diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php index 36a15638..8ca833d0 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php @@ -11,10 +11,15 @@ */ final class Point implements \Stringable { + public const POINT_REGEX = '/\((-?\d+(?:\.\d{1,6})?),\s*(-?\d+(?:\.\d{1,6})?)\)/'; + public function __construct( private readonly float $x, private readonly float $y, - ) {} + ) { + $this->validateCoordinate($x, 'x'); + $this->validateCoordinate($y, 'y'); + } public function __toString(): string { @@ -30,4 +35,26 @@ public function getY(): float { return $this->y; } + + public static function fromString(string $pointString): self + { + if (!\preg_match(self::POINT_REGEX, $pointString, $matches)) { + throw new \InvalidArgumentException( + \sprintf('Invalid point format. Expected format matching %s, got: %s', self::POINT_REGEX, $pointString) + ); + } + + return new self((float) $matches[1], (float) $matches[2]); + } + + private function validateCoordinate(float $value, string $name): void + { + $stringValue = (string) $value; + + if (!\preg_match('/^-?\d+(\.\d{1,6})?$/', $stringValue)) { + throw new \InvalidArgumentException( + \sprintf('Invalid %s coordinate format: %s', $name, $stringValue) + ); + } + } } diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php index 2c6de1a8..4fcbe4bf 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php @@ -16,7 +16,7 @@ class PointArrayTest extends TestCase /** * @var AbstractPlatform&MockObject */ - private AbstractPlatform $platform; + private MockObject $platform; private PointArray $fixture; @@ -117,4 +117,101 @@ public static function provideInvalidTransformations(): array ], ]; } + + /** + * @test + * + * @dataProvider provideInvalidDatabaseValues + */ + public function throws_exception_when_invalid_data_provided_to_convert_to_php_value(string $postgresValue): void + { + $this->expectException(InvalidPointArrayItemForPHPException::class); + $this->fixture->convertToPHPValue($postgresValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidDatabaseValues(): array + { + return [ + 'missing parentheses' => ['{"(1.23, 4.56)","(-7.89, 0.12"}'], + 'non-numeric values' => ['{"(abc, 4.56)","(-7.89, 0.12"}'], + 'too many coordinates' => ['{"(1.23, 4.56, 7,89)","(-7.89, 0.12"}'], + 'invalid array format' => ['{"(1.23,4.56)","(a,b)"}'], + 'invalid characters' => ['{"(1.23, 4.56)","(-7.89, @,?)"}'], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidPHPValueTypes + */ + public function throws_exception_when_non_string_provided_to_convert_to_php_value(mixed $value): void + { + $this->expectException(InvalidPointArrayItemForPHPException::class); + $this->fixture->convertToDatabaseValue($value, $this->platform); // @phpstan-ignore-line + } + + /** + * @return array + */ + public static function provideInvalidPHPValueTypes(): array + { + return [ + 'integer' => [123], + 'array' => [['(1.23, 4.56)']], + 'object' => [new \stdClass()], + 'boolean' => [true], + ]; + } + + /** + * @test + */ + public function throws_exception_when_invalid_point_format_provided(): void + { + $this->expectException(InvalidPointArrayItemForPHPException::class); + + $invalidPointString = '(invalid,point)'; + $this->fixture->transformArrayItemForPHP($invalidPointString); + } + + /** + * @test + */ + public function throws_exception_for_malformed_point_strings_in_database(): void + { + $this->expectException(InvalidPointArrayItemForPHPException::class); + + // This triggers the invalid format path without using reflection + $this->fixture->convertToPHPValue('{"(invalid,point)"}', $this->platform); + } + + /** + * @test + */ + public function handles_edge_case_with_empty_and_malformed_arrays(): void + { + $result1 = $this->fixture->convertToPHPValue('{}', $this->platform); + $result2 = $this->fixture->convertToPHPValue('{invalid}', $this->platform); + $result3 = $this->fixture->convertToPHPValue('{""}', $this->platform); + + self::assertEquals([], $result1); + self::assertEquals([], $result2); + self::assertEquals([], $result3); + } + + /** + * @test + */ + public function returns_empty_array_for_non_standard_postgres_array_format(): void + { + $result1 = $this->fixture->convertToPHPValue('[test]', $this->platform); + $result2 = $this->fixture->convertToPHPValue('not-an-array', $this->platform); + + self::assertEquals([], $result1); + self::assertEquals([], $result2); + } } diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php index 57b4526a..a962166a 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php @@ -17,7 +17,7 @@ class PointTest extends TestCase /** * @var AbstractPlatform&MockObject */ - private AbstractPlatform $platform; + private MockObject $platform; private Point $fixture; @@ -131,7 +131,7 @@ public static function provideInvalidDatabaseValues(): array 'non-numeric values' => ['(a,b)'], 'too many coordinates' => ['(1.23,4.56,7.89)'], 'not a string' => [123], - 'maximum float precision' => ['(1.23456789,7.89)'], + 'float precision is too granular' => ['(1.23456789,7.89)'], ]; } } From 9bd6c74c96a418158131fb480cec7e289dc14148 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 23 Apr 2025 21:34:53 +0100 Subject: [PATCH 4/6] Update src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php --- src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php index 8ca833d0..05ab8a17 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php @@ -11,7 +11,7 @@ */ final class Point implements \Stringable { - public const POINT_REGEX = '/\((-?\d+(?:\.\d{1,6})?),\s*(-?\d+(?:\.\d{1,6})?)\)/'; + private const POINT_REGEX = '/\((-?\d+(?:\.\d{1,6})?),\s*(-?\d+(?:\.\d{1,6})?)\)/'; public function __construct( private readonly float $x, From e15db5ee9fc0b2a5f260bbf78eea8d028ea28bb0 Mon Sep 17 00:00:00 2001 From: seb-jean Date: Wed, 23 Apr 2025 22:42:14 +0200 Subject: [PATCH 5/6] Update src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php index 05ab8a17..52291466 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php @@ -47,6 +47,7 @@ public static function fromString(string $pointString): self return new self((float) $matches[1], (float) $matches[2]); } + private function validateCoordinate(float $value, string $name): void { $stringValue = (string) $value; From bef38d1f1f4920abeab89e2434d0678ccacd8d1a Mon Sep 17 00:00:00 2001 From: seb-jean Date: Wed, 23 Apr 2025 23:28:10 +0200 Subject: [PATCH 6/6] feat: add support for point type --- src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php index 52291466..05ab8a17 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php @@ -47,7 +47,6 @@ public static function fromString(string $pointString): self return new self((float) $matches[1], (float) $matches[2]); } - private function validateCoordinate(float $value, string $name): void { $stringValue = (string) $value;