From 16c850ff9c3e563b65628e27234f4e3dbbfc40d6 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Mon, 9 Oct 2023 16:37:23 +0200 Subject: [PATCH] Cast `BIGINT` values to int if possible (#6177) | Q | A |------------- | ----------- | Type | improvement | Fixed issues | Replaces #6143, closes #6126 #### Summary `BigIntType` casts values retrieved from the database to int if they're inside the integer range of PHP. Previously, those values were always cast to string. This PR continues the work done by @cizordj in #6143. Co-authored-by: cizordj <32869222+cizordj@users.noreply.github.com> --- UPGRADE.md | 5 + docs/en/reference/types.rst | 20 ++-- src/Types/BigIntType.php | 30 +++++- tests/Functional/Types/BigIntTypeTest.php | 118 ++++++++++++++++++++++ 4 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 tests/Functional/Types/BigIntTypeTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 213738383b1..c2d0ea98cc5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,11 @@ awareness about deprecated code. # Upgrade to 4.0 +## BC BREAK: BIGINT vales are cast to int if possible + +`BigIntType` casts values retrieved from the database to int if they're inside +the integer range of PHP. Previously, those values were always cast to string. + ## BC BREAK: Stricter `DateTime` types The following types don't accept or return `DateTimeImmutable` instances anymore: diff --git a/docs/en/reference/types.rst b/docs/en/reference/types.rst index 4fe6dc3c833..8a14613b357 100644 --- a/docs/en/reference/types.rst +++ b/docs/en/reference/types.rst @@ -83,22 +83,22 @@ bigint ++++++ Maps and converts 8-byte integer values. -Unsigned integer values have a range of **0** to **18446744073709551615** while signed +Unsigned integer values have a range of **0** to **18446744073709551615**, while signed integer values have a range of **−9223372036854775808** to **9223372036854775807**. If you know the integer data you want to store always fits into one of these ranges you should consider using this type. -Values retrieved from the database are always converted to PHP's ``string`` type -or ``null`` if no data is present. +Values retrieved from the database are always converted to PHP's ``integer`` type +if they are within PHP's integer range or ``string`` if they aren't. +Otherwise, returns ``null`` if no data is present. .. note:: - For compatibility reasons this type is not converted to an integer - as PHP can only represent big integer values as real integers on - systems with a 64-bit architecture and would fall back to approximated - float values otherwise which could lead to false assumptions in applications. - - Not all of the database vendors support unsigned integers, so such an assumption - might not be propagated to the database. + Due to architectural differences, 32-bit PHP systems have a smaller + integer range than their 64-bit counterparts. On 32-bit systems, + values exceeding this range will be represented as strings instead + of integers. Bear in mind that not all database vendors + support unsigned integers, so schema configuration cannot be + enforced. Decimal types ^^^^^^^^^^^^^ diff --git a/src/Types/BigIntType.php b/src/Types/BigIntType.php index 1d068a42c2d..0cb14c5b44c 100644 --- a/src/Types/BigIntType.php +++ b/src/Types/BigIntType.php @@ -7,8 +7,17 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\AbstractPlatform; +use function assert; +use function is_int; +use function is_string; + +use const PHP_INT_MAX; +use const PHP_INT_MIN; + /** - * Type that maps a database BIGINT to a PHP string. + * Type that attempts to map a database BIGINT to a PHP int. + * + * If the presented value is outside of PHP's integer range, the value is returned as-is (usually a string). */ class BigIntType extends Type implements PhpIntegerMappingType { @@ -28,12 +37,25 @@ public function getBindingType(): ParameterType /** * @param T $value * - * @return (T is null ? null : string) + * @return (T is null ? null : int|string) * * @template T */ - public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?string + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): int|string|null { - return $value === null ? null : (string) $value; + if ($value === null || is_int($value)) { + return $value; + } + + if ($value > PHP_INT_MIN && $value < PHP_INT_MAX) { + return (int) $value; + } + + assert( + is_string($value), + 'DBAL assumes values outside of the integer range to be returned as string by the database driver.', + ); + + return $value; } } diff --git a/tests/Functional/Types/BigIntTypeTest.php b/tests/Functional/Types/BigIntTypeTest.php new file mode 100644 index 00000000000..65dbdcfcdb9 --- /dev/null +++ b/tests/Functional/Types/BigIntTypeTest.php @@ -0,0 +1,118 @@ +addColumn('id', Types::SMALLINT, ['notnull' => true]); + $table->addColumn('my_integer', Types::BIGINT, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $this->dropAndCreateTable($table); + + $this->connection->executeStatement(<<connection->convertToPHPValue( + $this->connection->fetchOne('SELECT my_integer from bigint_type_test WHERE id = 42'), + Types::BIGINT, + ), + ); + } + + /** @return Generator */ + public static function provideBigIntLiterals(): Generator + { + yield 'zero' => ['0', 0]; + yield 'null' => ['null', null]; + yield 'positive number' => ['42', 42]; + yield 'negative number' => ['-42', -42]; + + if (PHP_INT_SIZE < 8) { + // The following tests only work on 64bit systems. + return; + } + + yield 'large positive number' => ['9223372036854775806', PHP_INT_MAX - 1]; + yield 'large negative number' => ['-9223372036854775807', PHP_INT_MIN + 1]; + } + + #[DataProvider('provideBigIntEdgeLiterals')] + public function testSelectBigIntEdge(int $value): void + { + $table = new Table('bigint_type_test'); + $table->addColumn('id', Types::SMALLINT, ['notnull' => true]); + $table->addColumn('my_integer', Types::BIGINT, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $this->dropAndCreateTable($table); + + $this->connection->executeStatement(<<connection->convertToPHPValue( + $this->connection->fetchOne('SELECT my_integer from bigint_type_test WHERE id = 42'), + Types::BIGINT, + ), + LogicalOr::fromConstraints(new IsIdentical($value), new IsIdentical((string) $value)), + ); + } + + /** @return Generator */ + public static function provideBigIntEdgeLiterals(): Generator + { + yield 'max int' => [PHP_INT_MAX]; + yield 'min int' => [PHP_INT_MIN]; + } + + public function testUnsignedBigIntOnMySQL(): void + { + if (! TestUtil::isDriverOneOf('mysqli', 'pdo_mysql')) { + self::markTestSkipped('This test only works on MySQL/MariaDB.'); + } + + $table = new Table('bigint_type_test'); + $table->addColumn('id', Types::SMALLINT, ['notnull' => true]); + $table->addColumn('my_integer', Types::BIGINT, ['notnull' => false, 'unsigned' => true]); + $table->setPrimaryKey(['id']); + $this->dropAndCreateTable($table); + + // Insert (2 ** 64) - 1 + $this->connection->executeStatement(<<<'SQL' + INSERT INTO bigint_type_test (id, my_integer) + VALUES (42, 0xFFFFFFFFFFFFFFFF) + SQL); + + self::assertSame( + '18446744073709551615', + $this->connection->convertToPHPValue( + $this->connection->fetchOne('SELECT my_integer from bigint_type_test WHERE id = 42'), + Types::BIGINT, + ), + ); + } +}