Skip to content

Commit

Permalink
Cast BIGINT values to int if possible (#6177)
Browse files Browse the repository at this point in the history
|      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 <[email protected]>
  • Loading branch information
derrabus and cizordj authored Oct 9, 2023
1 parent 3112306 commit 16c850f
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 14 deletions.
5 changes: 5 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 10 additions & 10 deletions docs/en/reference/types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^^^
Expand Down
30 changes: 26 additions & 4 deletions src/Types/BigIntType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
}
118 changes: 118 additions & 0 deletions tests/Functional/Types/BigIntTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Tests\Functional\Types;

use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Tests\FunctionalTestCase;
use Doctrine\DBAL\Tests\TestUtil;
use Doctrine\DBAL\Types\Types;
use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Constraint\IsIdentical;
use PHPUnit\Framework\Constraint\LogicalOr;

use const PHP_INT_MAX;
use const PHP_INT_MIN;
use const PHP_INT_SIZE;

class BigIntTypeTest extends FunctionalTestCase
{
#[DataProvider('provideBigIntLiterals')]
public function testSelectBigInt(string $sqlLiteral, int|string|null $expectedValue): 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(<<<SQL
INSERT INTO bigint_type_test (id, my_integer)
VALUES (42, $sqlLiteral)
SQL);

self::assertSame(
$expectedValue,
$this->connection->convertToPHPValue(
$this->connection->fetchOne('SELECT my_integer from bigint_type_test WHERE id = 42'),
Types::BIGINT,
),
);
}

/** @return Generator<string, array{string, int|string|null}> */
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(<<<SQL
INSERT INTO bigint_type_test (id, my_integer)
VALUES (42, $value)
SQL);

self::assertThat(
$this->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<string, array{int}> */
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,
),
);
}
}

0 comments on commit 16c850f

Please sign in to comment.