Skip to content

Commit edaea0d

Browse files
committed
update
1 parent 5dec9cb commit edaea0d

8 files changed

Lines changed: 161 additions & 54 deletions

File tree

src/Database/Adapter/MariaDB.php

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2020,18 +2020,32 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
20202020
return "{$quotedColumn} = MOD(COALESCE({$quotedColumn}, 0), :$bindKey)";
20212021

20222022
case Operator::TYPE_POWER:
2023-
$bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
2023+
$exponent = $values[0] ?? 1;
2024+
$bindKey = $this->registerOperatorBind($binds, $exponent);
20242025
if (isset($values[1])) {
20252026
$maxKey = $this->registerOperatorBind($binds, $values[1]);
2026-
// A base of 1 or less can't exceed a positive max, so leave it unchanged —
2027-
// this also avoids POWER() on 0 or a negative base (which yields NULL for a
2028-
// negative/fractional exponent). For a base above 1 compare with logarithms
2029-
// so POWER() is computed at most once, and only when the result fits the max.
2030-
return "{$quotedColumn} = CASE
2031-
WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0)
2032-
WHEN :$bindKey * LOG(COALESCE({$quotedColumn}, 0)) > LOG(:$maxKey) THEN COALESCE({$quotedColumn}, 0)
2033-
ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey)
2034-
END";
2027+
$col = "COALESCE({$quotedColumn}, 0)";
2028+
2029+
// Leave the value unchanged only for undefined inputs, then apply the power if
2030+
// the result stays within the max. The exponent is constant, so only the
2031+
// undefined guard its value can actually trigger is emitted.
2032+
$whens = [];
2033+
if ($exponent < 0) {
2034+
// 0 to a negative power is undefined (POWER would error / return NULL).
2035+
$whens[] = "WHEN {$col} = 0 THEN {$col}";
2036+
}
2037+
if (\floor($exponent) != $exponent) {
2038+
// A negative base to a fractional exponent is not a real number.
2039+
$whens[] = "WHEN {$col} < 0 THEN {$col}";
2040+
}
2041+
// Positive base: compare with logarithms so POWER() never runs on a value that
2042+
// would overflow (base^exp > max <=> exp * LOG(base) > LOG(max)).
2043+
$whens[] = "WHEN {$col} > 0 AND :$bindKey * LOG({$col}) > LOG(:$maxKey) THEN {$col}";
2044+
// Non-positive base (no overflow risk): compare the computed value directly.
2045+
$whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}";
2046+
2047+
$whenSql = \implode(' ', $whens);
2048+
return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END";
20352049
}
20362050
return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)";
20372051

src/Database/Adapter/Memory.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3533,14 +3533,14 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
35333533
$max = $values[1] ?? null;
35343534
$base = \is_numeric($current) ? $current + 0 : 0;
35353535
if ($max !== null) {
3536-
// A base of 1 or less can't exceed a positive max, so leave it unchanged.
3537-
// This also avoids INF/NAN from undefined powers (0 to a negative power,
3538-
// or a negative base to a fractional power) being stored.
3539-
if ($base <= 1) {
3536+
// Leave the value unchanged for undefined inputs (0 to a negative power, or a
3537+
// negative base to a fractional exponent) — they produce INF/NaN, not a number.
3538+
if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) {
35403539
return $this->preserveNumericType($base, $base);
35413540
}
35423541
$result = $base ** $by;
3543-
if ($result > $max) {
3542+
// A result that overflows (INF) or exceeds the max also leaves the value as-is.
3543+
if (!\is_finite($result) || $result > $max) {
35443544
return $this->preserveNumericType($base, $base);
35453545
}
35463546

src/Database/Adapter/Mongo.php

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1892,17 +1892,29 @@ private function getOperatorExpression(Operator $operator, string $field): mixed
18921892

18931893
case Operator::TYPE_POWER:
18941894
$base = ['$ifNull' => [$ref, 0]];
1895-
$expr = ['$pow' => [$base, $values[0]]];
1895+
$exponent = $values[0];
1896+
$expr = ['$pow' => [$base, $exponent]];
18961897
if (isset($values[1])) {
1897-
// A base of 1 or less can't exceed a positive max, so leave it unchanged.
1898-
// This also avoids storing NaN/Infinity from undefined powers (a negative
1899-
// base to a fractional power, or 0 to a negative power) — Mongo orders NaN
1900-
// below all numbers, so a plain `result <= max` check would wrongly apply it.
1901-
$expr = ['$cond' => [
1902-
['$lte' => [$base, 1]],
1903-
$base,
1904-
['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]],
1905-
]];
1898+
// Apply the power only if the result stays within the max; otherwise leave the
1899+
// value unchanged. Overflow yields Infinity, which is greater than the max, so
1900+
// it correctly stays put.
1901+
$expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]];
1902+
1903+
// Never compute $pow for an undefined input (0 to a negative power, or a
1904+
// negative base to a fractional exponent): it yields NaN, which Mongo orders
1905+
// below every number, so a plain `<= max` check would wrongly apply it. The
1906+
// exponent is constant, so only guard the base condition it can actually trigger.
1907+
$guards = [];
1908+
if ($exponent < 0) {
1909+
$guards[] = ['$eq' => [$base, 0]];
1910+
}
1911+
if (\floor($exponent) != $exponent) {
1912+
$guards[] = ['$lt' => [$base, 0]];
1913+
}
1914+
if (!empty($guards)) {
1915+
$undefined = \count($guards) === 1 ? $guards[0] : ['$or' => $guards];
1916+
$expr = ['$cond' => [$undefined, $base, $expr]];
1917+
}
19061918
}
19071919
return $expr;
19081920

src/Database/Adapter/Postgres.php

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2578,19 +2578,32 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
25782578
return "{$quotedColumn} = MOD(COALESCE({$columnRef}::numeric, 0), :$bindKey::numeric)";
25792579

25802580
case Operator::TYPE_POWER:
2581-
$bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
2581+
$exponent = $values[0] ?? 1;
2582+
$bindKey = $this->registerOperatorBind($binds, $exponent);
25822583
if (isset($values[1])) {
25832584
$maxKey = $this->registerOperatorBind($binds, $values[1]);
2584-
// A base of 1 or less can't exceed a positive max, so leave it unchanged.
2585-
// This also avoids POWER() domain errors: PostgreSQL throws a hard error for
2586-
// 0 to a negative power and for a negative number to a fractional power.
2587-
// For a base above 1 we compare with logarithms so POWER() is computed at
2588-
// most once, and only when the result is within the max.
2589-
return "{$quotedColumn} = CASE
2590-
WHEN COALESCE({$columnRef}, 0) <= 1 THEN COALESCE({$columnRef}, 0)
2591-
WHEN :$bindKey * LN(COALESCE({$columnRef}, 0)) > LN(:$maxKey) THEN COALESCE({$columnRef}, 0)
2592-
ELSE POWER(COALESCE({$columnRef}, 0), :$bindKey)
2593-
END";
2585+
$col = "COALESCE({$columnRef}, 0)";
2586+
2587+
// Leave the value unchanged only for undefined inputs, then apply the power if
2588+
// the result stays within the max. The exponent is constant, so only the
2589+
// undefined guard its value can actually trigger is emitted. PostgreSQL throws
2590+
// a hard error for 0 to a negative power and a negative base to a fractional
2591+
// exponent, so those must never reach POWER().
2592+
$whens = [];
2593+
if ($exponent < 0) {
2594+
$whens[] = "WHEN {$col} = 0 THEN {$col}";
2595+
}
2596+
if (\floor($exponent) != $exponent) {
2597+
$whens[] = "WHEN {$col} < 0 THEN {$col}";
2598+
}
2599+
// Positive base: compare with logarithms so POWER() never runs on a value that
2600+
// would overflow (base^exp > max <=> exp * LN(base) > LN(max)).
2601+
$whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}";
2602+
// Non-positive base (no overflow risk): compare the computed value directly.
2603+
$whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}";
2604+
2605+
$whenSql = \implode(' ', $whens);
2606+
return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END";
25942607
}
25952608
return "{$quotedColumn} = POWER(COALESCE({$columnRef}, 0), :$bindKey)";
25962609

src/Database/Adapter/Redis.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4106,14 +4106,14 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
41064106
$max = $values[1] ?? null;
41074107
$base = \is_numeric($current) ? $current + 0 : 0;
41084108
if ($max !== null) {
4109-
// A base of 1 or less can't exceed a positive max, so leave it unchanged.
4110-
// This also avoids INF/NAN from undefined powers (0 to a negative power,
4111-
// or a negative base to a fractional power) being stored.
4112-
if ($base <= 1) {
4109+
// Leave the value unchanged for undefined inputs (0 to a negative power, or a
4110+
// negative base to a fractional exponent) — they produce INF/NaN, not a number.
4111+
if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) {
41134112
return $this->preserveNumericType($base, $base);
41144113
}
41154114
$result = $base ** $by;
4116-
if ($result > $max) {
4115+
// A result that overflows (INF) or exceeds the max also leaves the value as-is.
4116+
if (!\is_finite($result) || $result > $max) {
41174117
return $this->preserveNumericType($base, $base);
41184118
}
41194119

src/Database/Adapter/SQLite.php

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2091,19 +2091,33 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
20912091
}
20922092

20932093
$values = $operator->getValues();
2094-
$bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
2094+
$exponent = $values[0] ?? 1;
2095+
$bindKey = $this->registerOperatorBind($binds, $exponent);
20952096

20962097
if (isset($values[1])) {
20972098
$maxKey = $this->registerOperatorBind($binds, $values[1]);
2098-
// A base of 1 or less can't exceed a positive max, so leave it unchanged —
2099-
// this also avoids POWER() on 0 or a negative base (undefined for a
2100-
// negative/fractional exponent). For a base above 1 compare with logarithms
2101-
// so POWER() is computed at most once, and only when the result fits the max.
2102-
return "{$quotedColumn} = CASE
2103-
WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0)
2104-
WHEN :$bindKey * LN(COALESCE({$quotedColumn}, 0)) > LN(:$maxKey) THEN COALESCE({$quotedColumn}, 0)
2105-
ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey)
2106-
END";
2099+
$col = "COALESCE({$quotedColumn}, 0)";
2100+
2101+
// Leave the value unchanged only for undefined inputs, then apply the power if
2102+
// the result stays within the max. The exponent is constant, so only the
2103+
// undefined guard its value can actually trigger is emitted.
2104+
$whens = [];
2105+
if ($exponent < 0) {
2106+
// 0 to a negative power is undefined.
2107+
$whens[] = "WHEN {$col} = 0 THEN {$col}";
2108+
}
2109+
if (\floor($exponent) != $exponent) {
2110+
// A negative base to a fractional exponent is not a real number.
2111+
$whens[] = "WHEN {$col} < 0 THEN {$col}";
2112+
}
2113+
// Positive base: compare with logarithms so POWER() never runs on a value that
2114+
// would overflow (base^exp > max <=> exp * LN(base) > LN(max)).
2115+
$whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}";
2116+
// Non-positive base (no overflow risk): compare the computed value directly.
2117+
$whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}";
2118+
2119+
$whenSql = \implode(' ', $whens);
2120+
return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END";
21072121
}
21082122
return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)";
21092123

src/Database/Validator/Operator.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,22 @@ private function validateOperatorForAttribute(
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.
155+
// The payload may be spread across $values or wrapped in $values[0] (the same shape the
156+
// operators normalize with), so measure whichever the operator will actually process.
155157
if (
156158
\in_array($method, [
157159
DatabaseOperator::TYPE_ARRAY_APPEND,
158160
DatabaseOperator::TYPE_ARRAY_PREPEND,
159161
DatabaseOperator::TYPE_ARRAY_INTERSECT,
160162
DatabaseOperator::TYPE_ARRAY_DIFF,
163+
DatabaseOperator::TYPE_ARRAY_REMOVE,
161164
], true)
162-
&& \count($values) > DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE
163165
) {
164-
$this->message = "Array size " . \count($values) . " exceeds maximum allowed size of " . DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE . " for array operations";
165-
return false;
166+
$payload = (isset($values[0]) && \is_array($values[0])) ? $values[0] : $values;
167+
if (\count($payload) > DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE) {
168+
$this->message = "Array size " . \count($payload) . " exceeds maximum allowed size of " . DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE . " for array operations";
169+
return false;
170+
}
166171
}
167172

168173
switch ($method) {

tests/e2e/Adapter/Scopes/OperatorTests.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,6 +1729,50 @@ public function testOperatorUnboundedPowerOnUndefinedBase(): void
17291729
$database->deleteCollection($collectionId);
17301730
}
17311731

1732+
/**
1733+
* A bounded power (with a max) must still compute the result whenever it fits within the max —
1734+
* it only leaves the value unchanged when the result would exceed the max, or when the input is
1735+
* mathematically undefined. A base of 1 or less is NOT a reason to skip: 0.5^2 = 0.25 and
1736+
* (-4)^2 = 16 are perfectly valid and within their bounds. Verified via a fresh read.
1737+
*/
1738+
public function testOperatorBoundedPowerComputesWithinMax(): void
1739+
{
1740+
$database = static::getDatabase();
1741+
1742+
if (!$database->getAdapter()->getSupportForOperators()) {
1743+
$this->expectNotToPerformAssertions();
1744+
return;
1745+
}
1746+
1747+
$collectionId = 'operator_bounded_power';
1748+
$database->createCollection($collectionId);
1749+
$database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0);
1750+
1751+
// [id, starting value, operator, expected stored value].
1752+
$cases = [
1753+
['fraction', 0.5, Operator::power(2, 1), 0.25], // 0.5^2 = 0.25, within max 1 → applied
1754+
['negeven', -4.0, Operator::power(2, 20), 16.0], // (-4)^2 = 16, within max 20 → applied
1755+
['within', 2.0, Operator::power(3, 100), 8.0], // 2^3 = 8, within max 100 → applied
1756+
['exceeds', 5.0, Operator::power(3, 100), 5.0], // 5^3 = 125 > 100 → left unchanged
1757+
['negfrac', -4.0, Operator::power(0.5, 100), -4.0], // sqrt(-4) undefined → left unchanged
1758+
['zeroneg', 0.0, Operator::power(-1, 100), 0.0], // 0^-1 undefined → left unchanged
1759+
];
1760+
1761+
foreach ($cases as [$id, $start, $operator, $expected]) {
1762+
$database->createDocument($collectionId, new Document([
1763+
'$id' => $id,
1764+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
1765+
'value' => $start,
1766+
]));
1767+
$database->updateDocument($collectionId, $id, new Document(['value' => $operator]));
1768+
1769+
$stored = $database->getDocument($collectionId, $id)->getAttribute('value');
1770+
$this->assertEqualsWithDelta($expected, $stored, 0.000001, "bounded power case '{$id}' stored the wrong value");
1771+
}
1772+
1773+
$database->deleteCollection($collectionId);
1774+
}
1775+
17321776
/**
17331777
* Passing more values than the allowed maximum (10000) to an array operator must be rejected
17341778
* the same way on every adapter. Covers each operator that takes a caller-supplied value list.
@@ -1759,6 +1803,11 @@ public function testOperatorArraySizeLimit(): void
17591803
'arrayPrepend' => Operator::arrayPrepend($tooMany),
17601804
'arrayIntersect' => Operator::arrayIntersect($tooMany),
17611805
'arrayDiff' => Operator::arrayDiff($tooMany),
1806+
// arrayRemove wraps its argument, so the oversized list lands in values[0].
1807+
'arrayRemove' => Operator::arrayRemove($tooMany),
1808+
// A wrapped payload (the list nested in values[0]) must be capped too, not just the
1809+
// spread form — otherwise count($values) would see 1 and slip past the limit.
1810+
'arrayAppend (wrapped)' => Operator::arrayAppend([$tooMany]),
17621811
];
17631812

17641813
foreach ($operators as $name => $operator) {

0 commit comments

Comments
 (0)