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 93d7efad..3edbb93e 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -28,6 +28,9 @@ Type::addType('inet', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Inet"); Type::addType('inet[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\InetArray"); Type::addType('macaddr', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Macaddr"); Type::addType('macaddr[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\MacaddrArray"); + +Type::addType('point', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Point"); +Type::addType('point[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\PointArray"); ``` @@ -204,6 +207,10 @@ $platform->registerDoctrineTypeMapping('inet[]','inet[]'); $platform->registerDoctrineTypeMapping('_inet','inet[]'); $platform->registerDoctrineTypeMapping('macaddr[]','macaddr[]'); $platform->registerDoctrineTypeMapping('_macaddr','macaddr[]'); + +$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 077acca6..ba511120 100644 --- a/docs/INTEGRATING-WITH-LARAVEL.md +++ b/docs/INTEGRATING-WITH-LARAVEL.md @@ -46,6 +46,10 @@ return [ 'macaddr' => 'macaddr', 'macaddr[]' => 'macaddr[]', '_macaddr' => 'macaddr[]', + + 'point' => 'point', + 'point[]' => 'point[]', + '_point' => 'point[]', ], ], ], @@ -80,6 +84,9 @@ return [ 'inet[]' => MartinGeorgiev\Doctrine\DBAL\Types\InetArray::class, 'macaddr' => MartinGeorgiev\Doctrine\DBAL\Types\Macaddr::class, 'macaddr[]' => MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray::class, + + 'point' => MartinGeorgiev\Doctrine\DBAL\Types\Point::class, + 'point[]' => MartinGeorgiev\Doctrine\DBAL\Types\PointArray::class, ], ]; ``` @@ -301,6 +308,12 @@ class DoctrineEventSubscriber implements Subscriber if (!Type::hasType('macaddr[]')) { Type::addType('macaddr[]', \MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray::class); } + 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 576eceda..32083e96 100644 --- a/docs/INTEGRATING-WITH-SYMFONY.md +++ b/docs/INTEGRATING-WITH-SYMFONY.md @@ -28,6 +28,9 @@ doctrine: inet[]: MartinGeorgiev\Doctrine\DBAL\Types\InetArray macaddr: MartinGeorgiev\Doctrine\DBAL\Types\Macaddr macaddr[]: MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray + + point: MartinGeorgiev\Doctrine\DBAL\Types\Point + point[]: MartinGeorgiev\Doctrine\DBAL\Types\PointArray ``` @@ -73,6 +76,10 @@ doctrine: macaddr: macaddr macaddr[]: macaddr[] _macaddr: macaddr[] + + 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 new file mode 100644 index 00000000..ee95fb67 --- /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..04b8e1e4 --- /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..4aa55a29 --- /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..8c67663a --- /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..49f6285b --- /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); + } + + try { + return PointValueObject::fromString($value); + } catch (\InvalidArgumentException) { + throw InvalidPointForDatabaseException::forInvalidFormat($value); + } + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/PointArray.php new file mode 100644 index 00000000..c6aa8f0f --- /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); + } + + try { + return PointValueObject::fromString($item); + } catch (\InvalidArgumentException) { + $this->throwInvalidFormatException($item); + } + } + + 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/Doctrine/DBAL/Types/ValueObject/Point.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php new file mode 100644 index 00000000..05ab8a17 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Point.php @@ -0,0 +1,60 @@ + + */ +final class Point implements \Stringable +{ + private 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 + { + return \sprintf('(%f, %f)', $this->x, $this->y); + } + + public function getX(): float + { + return $this->x; + } + + 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 new file mode 100644 index 00000000..4fcbe4bf --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointArrayTest.php @@ -0,0 +1,217 @@ +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 Point(1.23, 4.56)], + 'postgresValue' => '{"(1.230000, 4.560000)"}', + ], + 'multiple points' => [ + 'phpValue' => [ + 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 Point(0.0, 0.0), + new Point(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 Point(1.23, 4.56), + 'invalid', + ], + ], + ]; + } + + /** + * @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 new file mode 100644 index 00000000..a962166a --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/PointTest.php @@ -0,0 +1,137 @@ +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' => [ + 'pointValueObject' => null, + 'postgresValue' => null, + ], + 'valid point' => [ + 'pointValueObject' => new PointValueObject(1.23, 4.56), + 'postgresValue' => '(1.230000, 4.560000)', + ], + 'negative coordinates' => [ + 'pointValueObject' => new PointValueObject(-1.23, -4.56), + 'postgresValue' => '(-1.230000, -4.560000)', + ], + 'zero coordinates' => [ + '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)', + ], + ]; + } + + /** + * @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], + 'float precision is too granular' => ['(1.23456789,7.89)'], + ]; + } +}