diff --git a/docs/AVAILABLE-TYPES.md b/docs/AVAILABLE-TYPES.md index b9183ec0..c2889d1f 100644 --- a/docs/AVAILABLE-TYPES.md +++ b/docs/AVAILABLE-TYPES.md @@ -1,11 +1,13 @@ # Available types -| PostgreSQL type | Implemented by | -|---|---| -| _bool | `MartinGeorgiev\Doctrine\DBAL\Types\BooleanArray` | -| _int2 | `MartinGeorgiev\Doctrine\DBAL\Types\SmallIntArray` | -| _int4 | `MartinGeorgiev\Doctrine\DBAL\Types\IntegerArray` | -| _int8 | `MartinGeorgiev\Doctrine\DBAL\Types\BigIntArray` | -| _text | `MartinGeorgiev\Doctrine\DBAL\Types\TextArray` | -| _jsonb | `MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray` | -| jsonb | `MartinGeorgiev\Doctrine\DBAL\Types\Jsonb` | \ No newline at end of file +| PostgreSQL type in practical use | PostgreSQL internal system catalogue name | Implemented by | +|---|---|---| +| bool[] | _bool | `MartinGeorgiev\Doctrine\DBAL\Types\BooleanArray` | +| smallint[] | _int2 | `MartinGeorgiev\Doctrine\DBAL\Types\SmallIntArray` | +| integer[] | _int4 | `MartinGeorgiev\Doctrine\DBAL\Types\IntegerArray` | +| 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` | diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseFloatArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseFloatArray.php new file mode 100644 index 00000000..4f82a419 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseFloatArray.php @@ -0,0 +1,128 @@ + + */ +abstract class BaseFloatArray extends BaseArray +{ + private const FLOAT_REGEX = '/^-?\d*\.?\d+(?:[eE][-+]?\d+)?$/'; + + abstract protected function getMinValue(): string; + + abstract protected function getMaxValue(): string; + + abstract protected function getMaxPrecision(): int; + + abstract protected function getMinAbsoluteValue(): string; + + public function isValidArrayItemForDatabase(mixed $item): bool + { + try { + $this->throwIfInvalidArrayItemForDatabase($item); + } catch (InvalidFloatArrayItemForDatabaseException) { + return false; + } + + return true; + } + + private function throwIfInvalidArrayItemForDatabase(mixed $item): void + { + $isNotANumber = !\is_float($item) && !\is_int($item) && !\is_string($item); + if ($isNotANumber) { + throw InvalidFloatArrayItemForDatabaseException::isNotANumber($item); + } + + $stringValue = (string) $item; + if (!\preg_match(self::FLOAT_REGEX, $stringValue)) { + throw InvalidFloatArrayItemForDatabaseException::doesNotMatchRegex($item); + } + + $floatValue = (float) $stringValue; + + // For scientific notation, convert to standard decimal form before checking precision + if (\str_contains($stringValue, 'e') || \str_contains($stringValue, 'E')) { + $standardForm = \sprintf('%.'.($this->getMaxPrecision() + 1).'f', $floatValue); + $parts = \explode('.', $standardForm); + if (isset($parts[1]) && \strlen($parts[1]) > $this->getMaxPrecision()) { + throw InvalidFloatArrayItemForDatabaseException::isAScientificNotationWithExcessPrecision($item); + } + } elseif (\str_contains($stringValue, '.')) { + $parts = \explode('.', $stringValue); + if (\strlen($parts[1]) > $this->getMaxPrecision()) { + throw InvalidFloatArrayItemForDatabaseException::isANormalNumberWithExcessPrecision($item); + } + } + + $isBelowMinValue = $floatValue < (float) $this->getMinValue(); + if ($isBelowMinValue) { + throw InvalidFloatArrayItemForDatabaseException::isBelowMinValue($item); + } + + $isAboveMaxValue = $floatValue > (float) $this->getMaxValue(); + if ($isAboveMaxValue) { + throw InvalidFloatArrayItemForDatabaseException::isAboveMaxValue($item); + } + + // Check if value is too close to zero + $absoluteValue = \abs($floatValue); + $isTooCloseToZero = $absoluteValue > 0 && $absoluteValue < (float) $this->getMinAbsoluteValue(); + if ($isTooCloseToZero) { + throw InvalidFloatArrayItemForDatabaseException::absoluteValueIsTooCloseToZero($item); + } + } + + public function transformArrayItemForPHP(mixed $item): ?float + { + if ($item === null) { + return null; + } + + $isNotANumberCandidate = !\is_float($item) && !\is_int($item) && !\is_string($item); + if ($isNotANumberCandidate) { + throw InvalidFloatArrayItemForPHPException::forValueThatIsNotAValidPHPFloat($item, static::TYPE_NAME); + } + + $stringValue = (string) $item; + if (!\preg_match(self::FLOAT_REGEX, $stringValue)) { + throw InvalidFloatArrayItemForPHPException::forValueThatIsNotAValidPHPFloat($item, static::TYPE_NAME); + } + + $floatValue = (float) $stringValue; + + // Check if value is too close to zero + $absValue = \abs($floatValue); + if ($absValue > 0 && $absValue < (float) $this->getMinAbsoluteValue()) { + throw InvalidFloatArrayItemForPHPException::forValueThatIsTooCloseToZero($item, static::TYPE_NAME); + } + + if ($floatValue < (float) $this->getMinValue() || $floatValue > (float) $this->getMaxValue()) { + throw InvalidFloatArrayItemForPHPException::forValueThatIsNotAValidPHPFloat($item, static::TYPE_NAME); + } + + // Scientific notation is valid for input as long as the resulting number + // when converted to decimal doesn't exceed precision limits + if (\str_contains($stringValue, 'e') || \str_contains($stringValue, 'E')) { + return $floatValue; + } + + // For regular decimal notation, check precision + if (\str_contains($stringValue, '.')) { + $parts = \explode('.', $stringValue); + if (\strlen($parts[1]) > $this->getMaxPrecision()) { + throw InvalidFloatArrayItemForPHPException::forValueThatExceedsMaximumPrecision($item, static::TYPE_NAME); + } + } + + return $floatValue; + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/DoublePrecisionArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/DoublePrecisionArray.php new file mode 100644 index 00000000..044c1f31 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/DoublePrecisionArray.php @@ -0,0 +1,38 @@ + + */ +class DoublePrecisionArray extends BaseFloatArray +{ + protected const TYPE_NAME = 'double precision[]'; + + protected function getMinValue(): string + { + return '-1.7976931348623157E+308'; + } + + protected function getMaxValue(): string + { + return '1.7976931348623157E+308'; + } + + protected function getMaxPrecision(): int + { + return 15; + } + + protected function getMinAbsoluteValue(): string + { + return '2.2250738585072014E-308'; + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidFloatArrayItemForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidFloatArrayItemForDatabaseException.php new file mode 100644 index 00000000..af299cf5 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidFloatArrayItemForDatabaseException.php @@ -0,0 +1,55 @@ + + */ +class InvalidFloatArrayItemForDatabaseException extends ConversionException +{ + private static function create(string $message, mixed $value): self + { + return new self(\sprintf($message, \var_export($value, true))); + } + + public static function isNotANumber(mixed $value): self + { + return self::create('Given value of %s is not a number.', $value); + } + + public static function doesNotMatchRegex(mixed $value): self + { + return self::create('Given value of %s does not match float regex.', $value); + } + + public static function isAScientificNotationWithExcessPrecision(mixed $value): self + { + return self::create('Given value of %s is a scientific notation with excess precision.', $value); + } + + public static function isANormalNumberWithExcessPrecision(mixed $value): self + { + return self::create('Given value of %s is a normal number with excess precision.', $value); + } + + public static function isBelowMinValue(mixed $value): self + { + return self::create('Given value of %s is below minimum value.', $value); + } + + public static function isAboveMaxValue(mixed $value): self + { + return self::create('Given value of %s is above maximum value.', $value); + } + + public static function absoluteValueIsTooCloseToZero(mixed $value): self + { + return self::create('Given absolute value of %s is too close to zero.', $value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidFloatArrayItemForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidFloatArrayItemForPHPException.php new file mode 100644 index 00000000..44c9c255 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidFloatArrayItemForPHPException.php @@ -0,0 +1,35 @@ + + */ +class InvalidFloatArrayItemForPHPException extends ConversionException +{ + private static function create(string $message, mixed $value, string $type): self + { + return new self(\sprintf($message, \var_export($value, true), $type)); + } + + public static function forValueThatIsNotAValidPHPFloat(mixed $value, string $type): self + { + return self::create('Given value of %s content cannot be transformed to valid PHP float from PostgreSQL %s type', $value, $type); + } + + public static function forValueThatIsTooCloseToZero(mixed $value, string $type): self + { + return self::create('Given value of %s is too close to zero for PostgreSQL %s type', $value, $type); + } + + public static function forValueThatExceedsMaximumPrecision(mixed $value, string $type): self + { + return self::create('Given value of %s exceeds maximum precision for PostgreSQL %s type', $value, $type); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/RealArray.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/RealArray.php new file mode 100644 index 00000000..fa0e62aa --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/RealArray.php @@ -0,0 +1,38 @@ + + */ +class RealArray extends BaseFloatArray +{ + protected const TYPE_NAME = 'real[]'; + + protected function getMinValue(): string + { + return '-3.4028235E+38'; + } + + protected function getMaxValue(): string + { + return '3.4028235E+38'; + } + + protected function getMaxPrecision(): int + { + return 6; + } + + protected function getMinAbsoluteValue(): string + { + return '1.17549435E-38'; + } +} diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/BaseArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/BaseArrayTest.php index f366463b..0858bf29 100644 --- a/tests/MartinGeorgiev/Doctrine/DBAL/Types/BaseArrayTest.php +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/BaseArrayTest.php @@ -94,7 +94,7 @@ public function throws_invalid_argument_exception_when_php_value_is_not_array(): /** * @test */ - public function throws_conversion_exception_when_invalid_array_item_value(): void + public function throws_domain_exception_when_invalid_array_item_value(): void { $this->expectException(ConversionException::class); $this->expectExceptionMessage("One or more of the items given doesn't look valid."); @@ -110,7 +110,7 @@ public function throws_conversion_exception_when_invalid_array_item_value(): voi /** * @test */ - public function throws_conversion_exception_when_postgres_value_is_not_valid_php_array(): void + public function throws_domain_exception_when_postgres_value_is_not_valid_php_array(): void { $this->expectException(ConversionException::class); $this->expectExceptionMessageMatches('/Given PostgreSQL value content type is not PHP string. Instead it is "\w+"./'); diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/BaseFloatArrayTestCase.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/BaseFloatArrayTestCase.php new file mode 100644 index 00000000..8061f18b --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/BaseFloatArrayTestCase.php @@ -0,0 +1,81 @@ +fixture->isValidArrayItemForDatabase($phpValue)); + } + + /** + * @return list + */ + public static function provideInvalidTransformations(): array + { + return [ + [true], + [null], + ['string'], + [[]], + [new \stdClass()], + ['1e'], // Invalid scientific notation format + ['e1'], // Invalid scientific notation format + ['1.23.45'], // Invalid number format + ['not_a_number'], + ]; + } + + /** + * @test + * + * @dataProvider provideValidTransformations + */ + public function can_transform_from_php_value(float $phpValue, string $postgresValue): void + { + self::assertTrue($this->fixture->isValidArrayItemForDatabase($phpValue)); + } + + /** + * @test + * + * @dataProvider provideValidTransformations + */ + public function can_transform_to_php_value(float $phpValue, string $postgresValue): void + { + self::assertEquals($phpValue, $this->fixture->transformArrayItemForPHP($postgresValue)); + } + + /** + * @return list + */ + abstract public static function provideValidTransformations(): array; + + /** + * @test + */ + public function throws_domain_exception_when_invalid_array_item_value(): void + { + $this->expectException(ConversionException::class); + $this->expectExceptionMessage('cannot be transformed to valid PHP float'); + + $this->fixture->transformArrayItemForPHP('1.e234'); + } +} diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/DoublePrecisionArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/DoublePrecisionArrayTest.php new file mode 100644 index 00000000..aa6579c4 --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/DoublePrecisionArrayTest.php @@ -0,0 +1,103 @@ +fixture = new DoublePrecisionArray(); + } + + /** + * @test + */ + public function has_name(): void + { + self::assertEquals('double precision[]', $this->fixture->getName()); + } + + public static function provideInvalidTransformations(): array + { + return \array_merge(parent::provideInvalidTransformations(), [ + ['1.7976931348623157E+309'], // Too large + ['-1.7976931348623157E+309'], // Too small + ['1.123456789012345678'], // Too many decimal places (>15) + ['2.2250738585072014E-309'], // Too close to zero + ['-2.2250738585072014E-309'], // Too close to zero (negative) + ['not_a_number'], + ['1.23.45'], + ['1e'], // Invalid scientific notation + ['e1'], // Invalid scientific notation + ]); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + ['phpValue' => 1.23e4, 'postgresValue' => '1.23e4'], + ['phpValue' => 1.23e-4, 'postgresValue' => '1.23e-4'], + ['phpValue' => 1.234567890123456, 'postgresValue' => '1.234567890123456'], + ['phpValue' => 1., 'postgresValue' => '1.0'], + ['phpValue' => 1.0, 'postgresValue' => '1.0'], + ['phpValue' => -1.0, 'postgresValue' => '-1.0'], + ]; + } + + /** + * @test + */ + public function throws_domain_exception_when_value_is_too_close_to_zero(): void + { + $this->expectException(InvalidFloatArrayItemForPHPException::class); + $this->expectExceptionMessage('is too close to zero for PostgreSQL double precision[] type'); + + $this->fixture->transformArrayItemForPHP('1.18E-308'); + } + + /** + * @test + */ + public function throws_domain_exception_when_value_exceeds_precision_limit(): void + { + $this->expectException(InvalidFloatArrayItemForPHPException::class); + $this->expectExceptionMessage('exceeds maximum precision for PostgreSQL double precision[] type'); + + $this->fixture->transformArrayItemForPHP('1.123456789012345678'); + } + + /** + * @test + * + * @dataProvider providePrecisionExceedingValues + */ + public function throws_domain_exception_for_various_precision_violations(string $value): void + { + $this->expectException(InvalidFloatArrayItemForPHPException::class); + $this->expectExceptionMessage('exceeds maximum precision for PostgreSQL double precision[] type'); + + $this->fixture->transformArrayItemForPHP($value); + } + + /** + * @return array + */ + public static function providePrecisionExceedingValues(): array + { + return [ + 'sixteen decimals' => ['1.1234567890123456789'], + 'many trailing zeros' => ['1.123456789012345000000'], + 'large number with excess precision' => ['123456.1234567890123456789'], + 'negative with excess precision' => ['-1.1234567890123456789'], + ]; + } +} diff --git a/tests/MartinGeorgiev/Doctrine/DBAL/Types/RealArrayTest.php b/tests/MartinGeorgiev/Doctrine/DBAL/Types/RealArrayTest.php new file mode 100644 index 00000000..c9f5d13a --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/DBAL/Types/RealArrayTest.php @@ -0,0 +1,117 @@ +fixture = new RealArray(); + } + + /** + * @test + */ + public function has_name(): void + { + self::assertEquals('real[]', $this->fixture->getName()); + } + + public static function provideInvalidTransformations(): array + { + return \array_merge(parent::provideInvalidTransformations(), [ + ['3.402823467E+38'], // Too large + ['-3.402823467E+38'], // Too small + ['1.1234567'], // Too many decimal places (>6) + ['1e38'], // Scientific notation not allowed + ['1.17E-38'], // Too close to zero + ['-1.17E-38'], // Too close to zero (negative) + ]); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + [ + 'phpValue' => -3.402823466E+8, + 'postgresValue' => '-3.402823466E+8', + ], + [ + 'phpValue' => 3.402823466E+8, + 'postgresValue' => '3.402823466E+8', + ], + [ + 'phpValue' => 1.123456, + 'postgresValue' => '1.123456', + ], + [ + 'phpValue' => -1.123456, + 'postgresValue' => '-1.123456', + ], + [ + 'phpValue' => 1., + 'postgresValue' => '1.0', + ], + [ + 'phpValue' => 0.0, + 'postgresValue' => '0', + ], + ]; + } + + /** + * @test + */ + public function throws_domain_exception_when_value_too_close_to_zero(): void + { + $this->expectException(InvalidFloatArrayItemForPHPException::class); + $this->expectExceptionMessage('is too close to zero for PostgreSQL real[] type'); + + $this->fixture->transformArrayItemForPHP('1.17E-38'); + } + + /** + * @test + */ + public function throws_domain_exception_when_value_exceeds_precision_limit(): void + { + $this->expectException(InvalidFloatArrayItemForPHPException::class); + $this->expectExceptionMessage('exceeds maximum precision for PostgreSQL real[] type'); + + $this->fixture->transformArrayItemForPHP('1.1234567'); + } + + /** + * @test + * + * @dataProvider providePrecisionExceedingValues + */ + public function throws_domain_exception_for_various_precision_violations(string $value): void + { + $this->expectException(InvalidFloatArrayItemForPHPException::class); + $this->expectExceptionMessage('exceeds maximum precision for PostgreSQL real[] type'); + + $this->fixture->transformArrayItemForPHP($value); + } + + /** + * @return array + */ + public static function providePrecisionExceedingValues(): array + { + return [ + 'seven decimals' => ['1.1234567'], + 'many trailing zeros' => ['1.123000000'], + 'large number with excess precision' => ['123456.1234567'], + 'negative with excess precision' => ['-1.1234567'], + ]; + } +}