Skip to content

Commit 054ca5e

Browse files
abnegateclaude
andcommitted
fix(sum): refuse a sum that overflows a double
sum() answered 0 for a double sum past the range of a double on SQLite when fetches are stringified: the engine sends `INF`, which the new sumOf() reads as no number and turns into 0. 7.4.2 handed the value back as it was. It returned INF on SQLite and failed its int|float return type on `INF`. MongoDB, Memory and Redis return INF from sum(), on 7.4.2 as on 8.x. sumOf() reads SQLite's `INF`, `-INF` and `NAN` as the doubles they name. Database::sum() refuses a non-finite sum from any adapter with the same Exception\Limit that MariaDB, MySQL and PostgreSQL raise, so sum() agrees with aggregate(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ce8eb0b commit 054ca5e

4 files changed

Lines changed: 158 additions & 3 deletions

File tree

‎src/Database/Adapter/SQL.php‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2252,6 +2252,9 @@ private function countOf(array $row): int
22522252
}
22532253

22542254
/**
2255+
* The sum in a row, with SQLite's `INF`, `-INF` and `NAN` read as the doubles they name for Database::sum() to
2256+
* refuse.
2257+
*
22552258
* @param array<string, mixed> $row
22562259
*/
22572260
private function sumOf(array $row): int|float
@@ -2262,7 +2265,12 @@ private function sumOf(array $row): int|float
22622265
return $sum + 0;
22632266
}
22642267

2265-
return 0;
2268+
return match (\is_string($sum) ? \strtoupper($sum) : null) {
2269+
'INF' => \INF,
2270+
'-INF' => -\INF,
2271+
'NAN' => \NAN,
2272+
default => 0,
2273+
};
22662274
}
22672275

22682276
/**

‎src/Database/Trait/Documents.php‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4622,6 +4622,7 @@ public function count(string $collection, array $queries = [], ?int $max = null)
46224622
* @return float|int The sum of the attribute values
46234623
*
46244624
* @throws DatabaseException
4625+
* @throws LimitException When the sum overflows the range of a double
46254626
* @throws QueryException When $max is not greater than 0
46264627
*/
46274628
public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int
@@ -4667,6 +4668,7 @@ public function sum(string $collection, string $attribute, array $queries = [],
46674668

46684669
$getSum = fn () => $this->adapter->sum($collection, $attribute, $queries, $max);
46694670
$sum = $skipAuth ? $this->authorization->skip($getSum) : $getSum();
4671+
self::assertFinite($sum);
46704672

46714673
$listeners = $this->listens(Event::DocumentSum);
46724674
if ($listeners !== []) {
@@ -4987,8 +4989,8 @@ private static function floatingAggregates(array $queries): array
49874989
}
49884990

49894991
/**
4990-
* SQLite answers a double that overflowed with infinity, which no caller can tell from a value, so it is refused
4991-
* as MariaDB, MySQL and PostgreSQL refuse it.
4992+
* SQLite, MongoDB and the adapters that sum in PHP answer a double that overflowed with infinity, which no caller
4993+
* can tell from a value, so it is refused as MariaDB, MySQL and PostgreSQL refuse it.
49924994
*
49934995
* @throws LimitException
49944996
*/

‎tests/e2e/Adapter/Scopes/DocumentTests.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2830,6 +2830,44 @@ public function testSum(): void
28302830
$this->getDatabase()->getAuthorization()->addRole('user:x');
28312831
}
28322832

2833+
/**
2834+
* MariaDB and MySQL sent a double sum that overflowed as `0`, and SQLite, MongoDB and the adapters that sum in
2835+
* PHP answered infinity. A sum past the range of a double is refused on every adapter instead.
2836+
*/
2837+
public function testSumOverflowIsRefused(): void
2838+
{
2839+
$database = $this->getDatabase();
2840+
$collection = 'sum_overflow_'.\substr(\uniqid(), -6);
2841+
$database->createCollection(Collection::create(
2842+
id: $collection,
2843+
attributes: [
2844+
Attribute::string(key: 'series', size: 20, required: true),
2845+
Attribute::double(key: 'reading', required: true),
2846+
],
2847+
permissions: [Permission::create(Role::any()), Permission::read(Role::any())],
2848+
));
2849+
foreach ([['huge', 1e308], ['huge', 1e308], ['single', 1e308]] as [$series, $reading]) {
2850+
$database->createDocument($collection, new Document([
2851+
'series' => $series,
2852+
'reading' => $reading,
2853+
'$permissions' => [Permission::read(Role::any())],
2854+
]));
2855+
}
2856+
2857+
foreach ([[[], null], [[Query::equal('series', ['huge'])], null], [[Query::equal('series', ['huge'])], 5], [[Query::greaterThan('reading', 0)], null]] as [$queries, $max]) {
2858+
try {
2859+
$database->sum($collection, 'reading', $queries, $max);
2860+
$this->fail('A sum that overflows a double is refused');
2861+
} catch (LimitException) {
2862+
$this->addToAssertionCount(1);
2863+
}
2864+
}
2865+
2866+
$this->assertEqualsWithDelta(1e308, $database->sum($collection, 'reading', [Query::equal('series', ['single'])]), 1e293);
2867+
2868+
$database->deleteCollection($collection);
2869+
}
2870+
28332871
public function testUpdateDocument(): void
28342872
{
28352873
$document = $this->initDocumentsFixture();

‎tests/unit/SumOverflowTest.php‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace Tests\Unit;
4+
5+
use Closure;
6+
use PDO;
7+
use PHPUnit\Framework\Attributes\DataProvider;
8+
use PHPUnit\Framework\TestCase;
9+
use Utopia\Cache\Adapter\None as NoCache;
10+
use Utopia\Cache\Cache;
11+
use Utopia\Database\Adapter;
12+
use Utopia\Database\Adapter\Memory;
13+
use Utopia\Database\Adapter\SQLite;
14+
use Utopia\Database\Attribute;
15+
use Utopia\Database\Collection;
16+
use Utopia\Database\Database;
17+
use Utopia\Database\Document;
18+
use Utopia\Database\Exception\Limit as LimitException;
19+
use Utopia\Database\Hook\Permissions;
20+
use Utopia\Database\Permission;
21+
use Utopia\Database\Query;
22+
use Utopia\Database\Role;
23+
use Utopia\Database\Validator\Authorization;
24+
25+
/**
26+
* SQLite and the adapters that sum in PHP answer a double sum that overflowed with infinity, which SQLite sends as
27+
* `INF` when it stringifies. sum() refuses it with the Limit error MariaDB, MySQL and PostgreSQL raise for it.
28+
*/
29+
final class SumOverflowTest extends TestCase
30+
{
31+
private const string COLLECTION = 'readings';
32+
33+
/**
34+
* @return iterable<string, array{Closure(): Adapter}>
35+
*/
36+
public static function adapters(): iterable
37+
{
38+
yield 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:', null, null, [PDO::ATTR_STRINGIFY_FETCHES => false]))];
39+
yield 'sqlite stringified' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:', null, null, [PDO::ATTR_STRINGIFY_FETCHES => true]))];
40+
yield 'memory' => [static fn (): Adapter => new Memory()];
41+
}
42+
43+
/**
44+
* @param Closure(): Adapter $adapter
45+
* @param list<float> $readings
46+
*/
47+
private function database(Closure $adapter, array $readings): Database
48+
{
49+
$database = new Database($adapter(), new Cache(new NoCache()));
50+
$database
51+
->setDatabase('sum_overflow')
52+
->setNamespace('sum_overflow_'.\uniqid())
53+
->setAuthorization(new Authorization());
54+
$database->addHook(new Permissions());
55+
$database->create();
56+
57+
$database->createCollection(Collection::create(
58+
id: self::COLLECTION,
59+
attributes: [Attribute::double(key: 'reading', required: true)],
60+
permissions: [Permission::create(Role::any()), Permission::read(Role::any())],
61+
));
62+
63+
foreach ($readings as $reading) {
64+
$database->createDocument(self::COLLECTION, new Document([
65+
'reading' => $reading,
66+
'$permissions' => [Permission::read(Role::any())],
67+
]));
68+
}
69+
70+
return $database;
71+
}
72+
73+
/**
74+
* @param Closure(): Adapter $adapter
75+
*/
76+
#[DataProvider('adapters')]
77+
public function testASumThatOverflowsIsRefused(Closure $adapter): void
78+
{
79+
$database = $this->database($adapter, [1e308, 1e308]);
80+
81+
$this->expectException(LimitException::class);
82+
$database->sum(self::COLLECTION, 'reading');
83+
}
84+
85+
/**
86+
* @param Closure(): Adapter $adapter
87+
*/
88+
#[DataProvider('adapters')]
89+
public function testASumThatOverflowsUnderFiltersIsRefused(Closure $adapter): void
90+
{
91+
$database = $this->database($adapter, [1e308, 1e308, -1.0]);
92+
93+
$this->expectException(LimitException::class);
94+
$database->sum(self::COLLECTION, 'reading', [Query::greaterThan('reading', 0)], 10);
95+
}
96+
97+
/**
98+
* @param Closure(): Adapter $adapter
99+
*/
100+
#[DataProvider('adapters')]
101+
public function testTheLargestFiniteSumIsStillAnswered(Closure $adapter): void
102+
{
103+
$database = $this->database($adapter, [1e308]);
104+
105+
$this->assertSame(1e308, $database->sum(self::COLLECTION, 'reading'));
106+
}
107+
}

0 commit comments

Comments
 (0)