Skip to content

Commit a172cfa

Browse files
committed
Run tests
1 parent c9db3ae commit a172cfa

6 files changed

Lines changed: 90 additions & 51 deletions

File tree

src/Database/Adapter/Memory.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Utopia\Database\Document;
99
use Utopia\Database\Exception as DatabaseException;
1010
use Utopia\Database\Exception\Duplicate as DuplicateException;
11+
use Utopia\Database\Exception\Limit as LimitException;
1112
use Utopia\Database\Exception\NotFound as NotFoundException;
1213
use Utopia\Database\Exception\Operator as OperatorException;
1314
use Utopia\Database\Operator;
@@ -3546,7 +3547,14 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
35463547
return $this->preserveNumericType($base, $result);
35473548
}
35483549

3549-
return $this->preserveNumericType($base, $base ** $by);
3550+
// 0 to a negative power, or a negative base to a fractional exponent, is not a real
3551+
// number. Fail loudly with a clear exception rather than storing INF/NaN.
3552+
$result = $base ** $by;
3553+
if (!\is_finite($result)) {
3554+
throw new LimitException('Value out of range');
3555+
}
3556+
3557+
return $this->preserveNumericType($base, $result);
35503558

35513559
case Operator::TYPE_STRING_CONCAT:
35523560
return ((string) ($current ?? '')).(string) ($values[0] ?? '');

src/Database/Adapter/Mongo.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4012,6 +4012,12 @@ protected function processException(\Throwable $e): \Throwable
40124012
return new TypeException('Invalid operation', $e->getCode(), $e);
40134013
}
40144014

4015+
// Invalid $pow argument (0 raised to a negative power) — matches the SQL adapters, which
4016+
// report an undefined power as a numeric range error.
4017+
if ($e->getCode() === 28764) {
4018+
return new LimitException('Value out of range', $e->getCode(), $e);
4019+
}
4020+
40154021
return $e;
40164022
}
40174023

src/Database/Adapter/Postgres.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2121,6 +2121,13 @@ protected function processException(PDOException $e): \Exception
21212121
return new LimitException('Numeric value out of range', $e->getCode(), $e);
21222122
}
21232123

2124+
// Invalid argument for power function (e.g. 0 to a negative power, or a negative base to a
2125+
// fractional exponent) — matches MariaDB, which reports the same as a numeric range error.
2126+
if ($e->getCode() === '2201F' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
2127+
return new LimitException('Value out of range', $e->getCode(), $e);
2128+
}
2129+
2130+
21242131
// Datetime field overflow
21252132
if ($e->getCode() === '22008' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
21262133
return new LimitException('Datetime field overflow', $e->getCode(), $e);

src/Database/Adapter/Redis.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Utopia\Database\Document;
1212
use Utopia\Database\Exception as DatabaseException;
1313
use Utopia\Database\Exception\Duplicate as DuplicateException;
14+
use Utopia\Database\Exception\Limit as LimitException;
1415
use Utopia\Database\Exception\NotFound as NotFoundException;
1516
use Utopia\Database\Exception\Operator as OperatorException;
1617
use Utopia\Database\Exception\Query as QueryException;
@@ -4119,7 +4120,15 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
41194120
return $this->preserveNumericType($base, $result);
41204121
}
41214122

4122-
return $this->preserveNumericType($base, $base ** $by);
4123+
// 0 to a negative power, or a negative base to a fractional exponent, is not a real
4124+
// number. Fail loudly with a clear exception rather than storing INF/NaN (which
4125+
// also can't be JSON-encoded, so it would otherwise surface as a raw JsonException).
4126+
$result = $base ** $by;
4127+
if (!\is_finite($result)) {
4128+
throw new LimitException('Value out of range');
4129+
}
4130+
4131+
return $this->preserveNumericType($base, $result);
41234132

41244133
case Operator::TYPE_STRING_CONCAT:
41254134
return ((string) ($current ?? '')) . (string) ($values[0] ?? '');

src/Database/Validator/Operator.php

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ private function validateOperatorForAttribute(
148148

149149
// Handle both Document objects and arrays
150150
$type = $attribute instanceof Document ? $attribute->getAttribute('type') : $attribute['type'];
151-
$isArray = $attribute instanceof Document ? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false);
151+
$isArray = $attribute instanceof Document? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false);
152152

153153
// Array operators that carry a caller-supplied value list are capped to guard against
154154
// memory exhaustion. Enforced here so every adapter rejects an oversized list the same way.
@@ -221,25 +221,6 @@ private function validateOperatorForAttribute(
221221
}
222222
}
223223

224-
// An unbounded power is computed directly by the database. Some engines hard-error on
225-
// results that are not real numbers, so reject those up-front with a clear message.
226-
if ($method === DatabaseOperator::TYPE_POWER && $this->currentDocument !== null && !isset($values[1])) {
227-
$base = (float)($this->currentDocument->getAttribute($operator->getAttribute()) ?? 0);
228-
$exponent = (float)$values[0];
229-
230-
// Zero raised to a negative power is undefined (a division by zero).
231-
if ($base === 0.0 && $exponent < 0) {
232-
$this->message = "Cannot apply power operator: zero raised to a negative power is undefined";
233-
return false;
234-
}
235-
236-
// A negative base raised to a fractional exponent is not a real number.
237-
if ($base < 0.0 && \floor($exponent) !== $exponent) {
238-
$this->message = "Cannot apply power operator: a negative base raised to a fractional exponent is undefined";
239-
return false;
240-
}
241-
}
242-
243224
break;
244225
case DatabaseOperator::TYPE_ARRAY_APPEND:
245226
case DatabaseOperator::TYPE_ARRAY_PREPEND:

tests/e2e/Adapter/Scopes/OperatorTests.php

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Utopia\Database\Database;
66
use Utopia\Database\DateTime;
77
use Utopia\Database\Document;
8+
use Utopia\Database\Exception\Limit as LimitException;
89
use Utopia\Database\Exception\Operator as OperatorException;
910
use Utopia\Database\Exception\Structure as StructureException;
1011
use Utopia\Database\Exception\Type as TypeException;
@@ -1652,10 +1653,17 @@ public function testOperatorPowerOnZeroOrNegativeBase(): void
16521653
}
16531654

16541655
/**
1655-
* A power with no maximum is computed directly by the database. When the result is not a real
1656-
* number (zero raised to a negative power, or a negative base with a fractional exponent) some
1657-
* databases throw a hard error. Validation rejects these before they reach the database, so the
1658-
* update fails the same way on every adapter and the stored value is left untouched.
1656+
* A power with no maximum is computed directly by the engine on the live row value, so each
1657+
* adapter behaves as its engine does when the result is not a real number (zero raised to a
1658+
* negative power, or a negative base with a fractional exponent):
1659+
* - Most engines (MariaDB/MySQL/Postgres) and the in-memory adapters (Memory/Redis) reject it
1660+
* with a LimitException and leave the stored value untouched.
1661+
* - MongoDB rejects 0-to-a-negative-power the same way, but has no error for a negative base
1662+
* with a fractional exponent, so it stores NaN.
1663+
* - SQLite never raises on undefined math; it stores NULL (or leaves the value as-is).
1664+
*
1665+
* The one behaviour that must never happen on any adapter is silently storing a plausible but
1666+
* wrong real number, so the assertions verify the stored value via a fresh read.
16591667
*/
16601668
public function testOperatorUnboundedPowerOnUndefinedBase(): void
16611669
{
@@ -1670,37 +1678,57 @@ public function testOperatorUnboundedPowerOnUndefinedBase(): void
16701678
$database->createCollection($collectionId);
16711679
$database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0);
16721680

1673-
// 0 raised to a negative power is undefined, so the update is rejected and 0 stays 0.
1674-
$database->createDocument($collectionId, new Document([
1675-
'$id' => 'zero',
1676-
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
1677-
'value' => 0.0,
1678-
]));
1679-
try {
1680-
$database->updateDocument($collectionId, 'zero', new Document([
1681-
'value' => Operator::power(-1),
1681+
// [id, starting value, operator]. Each result is mathematically undefined.
1682+
$undefined = [
1683+
['zero', 0.0, Operator::power(-1)], // 0 to a negative power
1684+
['neg', -4.0, Operator::power(0.5)], // square root of a negative number
1685+
];
1686+
1687+
foreach ($undefined as [$id, $start, $operator]) {
1688+
$database->createDocument($collectionId, new Document([
1689+
'$id' => $id,
1690+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
1691+
'value' => $start,
16821692
]));
1683-
$this->fail('Expected the update to be rejected for zero raised to a negative power');
1684-
} catch (StructureException) {
1685-
// expected
1693+
1694+
1695+
try {
1696+
$caught = false;
1697+
$database->updateDocument($collectionId, $id, new Document(['value' => $operator]));
1698+
} catch (\Throwable $e) {
1699+
$caught = true;
1700+
$this->assertInstanceOf(LimitException::class, $e);
1701+
1702+
// Verify the actual stored value with a fresh read, not the returned document.
1703+
$stored = $database->getDocument($collectionId, $id)->getAttribute('value');
1704+
1705+
// Whatever the engine raised must surface as a LimitException — not a raw
1706+
// PDO/Mongo/Json error. The row must also be left exactly as it was (the failed
1707+
// update is rolled back, no partial write).
1708+
$this->assertInstanceOf(LimitException::class, $caught);
1709+
$this->assertEquals($start, $stored, "{$id}: value changed even though the update raised a LimitException");
1710+
}
1711+
1712+
if ($caught === false) {
1713+
// Verify the actual stored value with a fresh read, not the returned document.
1714+
$stored = $database->getDocument($collectionId, $id)->getAttribute('value');
1715+
1716+
// Engines that never raise on undefined math (SQLite, and MongoDB for a negative
1717+
// base) must still not store a wrong real number: the value is either untouched or
1718+
// an explicit "not a number" marker (NULL / NaN).
1719+
$safe = $stored === null || $stored == $start || !\is_finite((float) $stored);
1720+
$this->assertTrue($safe, "{$id}: undefined power neither raised a LimitException nor left a safe value; stored " . \var_export($stored, true));
1721+
}
16861722
}
1687-
$this->assertEquals(0.0, $database->getDocument($collectionId, 'zero')->getAttribute('value'));
16881723

1689-
// The square root of a negative number is not a real number, so the update is rejected and -4 stays -4.
1724+
// A valid unbounded power still computes normally on every adapter: 2^3 = 8.
16901725
$database->createDocument($collectionId, new Document([
1691-
'$id' => 'neg',
1726+
'$id' => 'valid',
16921727
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
1693-
'value' => -4.0,
1728+
'value' => 2.0,
16941729
]));
1695-
try {
1696-
$database->updateDocument($collectionId, 'neg', new Document([
1697-
'value' => Operator::power(0.5),
1698-
]));
1699-
$this->fail('Expected the update to be rejected for a negative base with a fractional exponent');
1700-
} catch (StructureException) {
1701-
// expected
1702-
}
1703-
$this->assertEquals(-4.0, $database->getDocument($collectionId, 'neg')->getAttribute('value'));
1730+
$database->updateDocument($collectionId, 'valid', new Document(['value' => Operator::power(3)]));
1731+
$this->assertEquals(8.0, $database->getDocument($collectionId, 'valid')->getAttribute('value'));
17041732

17051733
$database->deleteCollection($collectionId);
17061734
}

0 commit comments

Comments
 (0)