Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 46 additions & 35 deletions src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -1966,9 +1966,11 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
$bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
if (isset($values[1])) {
$maxKey = $this->registerOperatorBind($binds, $values[1]);
// Compare with the operand moved across (`col > max - val`) instead of
// `col + val > max`, so the guard never overflows BIGINT when col is near the
// integer range limit. Inclusive: a result landing exactly on max still applies.
return "{$quotedColumn} = CASE
WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey
WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN :$maxKey
WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) + :$bindKey
END";
}
Expand All @@ -1978,9 +1980,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
$bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
if (isset($values[1])) {
$minKey = $this->registerOperatorBind($binds, $values[1]);
// `col < min + val` rather than `col - val < min`: overflow-safe near the
// integer range limit. Inclusive: a result landing exactly on min still applies.
return "{$quotedColumn} = CASE
WHEN COALESCE({$quotedColumn}, 0) <= :$minKey THEN :$minKey
WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN :$minKey
WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) - :$bindKey
END";
}
Expand All @@ -1990,10 +1993,12 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
$bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
if (isset($values[1])) {
$maxKey = $this->registerOperatorBind($binds, $values[1]);
// Compare via division (`col > max/val`, sign-aware) instead of computing
// `col * val`, which would overflow BIGINT for large operands. The factor's
// sign flips the inequality. Inclusive: a result exactly on max still applies.
return "{$quotedColumn} = CASE
WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey
WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN :$maxKey
WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN :$maxKey
WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0)
WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) * :$bindKey
END";
}
Expand All @@ -2004,7 +2009,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
if (isset($values[1])) {
$minKey = $this->registerOperatorBind($binds, $values[1]);
return "{$quotedColumn} = CASE
WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey <= :$minKey THEN :$minKey
WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) / :$bindKey
END";
}
Expand All @@ -2015,15 +2020,41 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
return "{$quotedColumn} = MOD(COALESCE({$quotedColumn}, 0), :$bindKey)";

case Operator::TYPE_POWER:
$bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
$exponent = $values[0] ?? 1;
$bindKey = $this->registerOperatorBind($binds, $exponent);
if (isset($values[1])) {
$maxKey = $this->registerOperatorBind($binds, $values[1]);
return "{$quotedColumn} = CASE
WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey
WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0)
WHEN :$bindKey * LOG(COALESCE({$quotedColumn}, 1)) > LOG(:$maxKey) THEN :$maxKey
ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey)
END";
$col = "COALESCE({$quotedColumn}, 0)";

// Leave the value unchanged only for undefined inputs, then apply the power if
// the result stays within the max. The exponent is constant, so only the
// undefined guard its value can actually trigger is emitted.
$oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0;

$whens = [];
if ($exponent < 0) {
// 0 to a negative power is undefined (POWER would error / return NULL).
$whens[] = "WHEN {$col} = 0 THEN {$col}";
}
if (\floor($exponent) != $exponent) {
// A negative base to a fractional exponent is not a real number.
$whens[] = "WHEN {$col} < 0 THEN {$col}";
}
// Cap by magnitude via logarithms so POWER() never runs on a value that would
// overflow (base^exp > max <=> exp * LOG(base) > LOG(max)).
if ($oddInteger) {
// An odd exponent keeps a negative base negative, and a negative result is
// always within a positive max, so only cap positive bases; negative bases
// fall through to POWER() and their (negative) result is applied.
$whens[] = "WHEN {$col} > 0 AND :$bindKey * LOG({$col}) > LOG(:$maxKey) THEN {$col}";
} else {
// Otherwise the result is non-negative, so its magnitude equals its value —
// cap either sign. ABS() keeps LOG() defined for a negative even-power base.
$whens[] = "WHEN {$col} <> 0 AND :$bindKey * LOG(ABS({$col})) > LOG(:$maxKey) THEN {$col}";
}

$whenSql = \implode(' ', $whens);
return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END";
Comment on lines +2039 to +2057

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether tests cover bounded zero-to-zero power with max < 1.
rg -n "Operator::power\(0|bounded_power|0\.5" tests src

Repository: utopia-php/database

Length of output: 7762


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the MariaDB power implementation around the cited lines.
file="src/Database/Adapter/MariaDB.php"
wc -l "$file"
sed -n '2000,2085p' "$file"

# Inspect operator tests around power/bounded power cases.
testfile="tests/e2e/Adapter/Scopes/OperatorTests.php"
wc -l "$testfile"
sed -n '1720,1775p' "$testfile"
sed -n '4035,4075p' "$testfile"
sed -n '1415,1475p' "$testfile"

Repository: utopia-php/database

Length of output: 12896


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for any bounded power test using exponent 0, and inspect the createAttribute max constraint semantics.
rg -n "Operator::power\(0,|power\(0, [^)]*\)|Operator::power\(0\)" tests/e2e/Adapter/Scopes/OperatorTests.php tests/unit/OperatorTest.php src/Database/Adapter/MariaDB.php

# Inspect the operator constructor/shape if needed to understand default arguments.
rg -n "function power|TYPE_POWER|power\(" src tests/unit tests/e2e -g '!vendor'

Repository: utopia-php/database

Length of output: 6377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/Database/Validator/Operator.php"
wc -l "$file"
sed -n '160,240p' "$file"

Repository: utopia-php/database

Length of output: 4822


Handle 0^0 in the bounded power path (src/Database/Adapter/MariaDB.php:2039-2057).
Operator::power(0, $max) falls through to POWER(0, 0) for a base of 0, so when $max < 1 it writes 1 instead of keeping the existing value. Add a zero-exponent guard before the log-based check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Adapter/MariaDB.php` around lines 2039 - 2057, The bounded power
handling in MariaDBAdapter’s power update path does not special-case a zero
exponent, so `Operator::power(0, $max)` can still fall through to `POWER(0, 0)`
and write `1` when `$max < 1`. Update the CASE-building logic in the MariaDB
`power`/bounded exponent branch to add an explicit zero-exponent guard before
the log-based overflow checks, ensuring the existing column value is preserved
for `0^0` instead of letting `POWER()` decide.

}
return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)";

Expand All @@ -2043,16 +2074,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi

// Array operators
case Operator::TYPE_ARRAY_APPEND:
if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) {
throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations");
}
$bindKey = $this->registerOperatorBind($binds, json_encode($values));
return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)";

case Operator::TYPE_ARRAY_PREPEND:
if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) {
throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations");
}
$bindKey = $this->registerOperatorBind($binds, json_encode($values));
return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))";

Expand Down Expand Up @@ -2086,9 +2111,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
), JSON_ARRAY())";

case Operator::TYPE_ARRAY_INTERSECT:
if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) {
throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations");
}
$bindKey = $this->registerOperatorBind($binds, json_encode($values));
return "{$quotedColumn} = IFNULL((
SELECT JSON_ARRAYAGG(jt1.value)
Expand All @@ -2100,9 +2122,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
), JSON_ARRAY())";

case Operator::TYPE_ARRAY_DIFF:
if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) {
throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations");
}
$bindKey = $this->registerOperatorBind($binds, json_encode($values));
return "{$quotedColumn} = IFNULL((
SELECT JSON_ARRAYAGG(jt1.value)
Expand All @@ -2115,14 +2134,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi

case Operator::TYPE_ARRAY_FILTER:
$condition = $values[0] ?? 'equal';
$validConditions = [
'equal', 'notEqual', // Comparison
'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', // Numeric
'isNull', 'isNotNull' // Null checks
];
if (!in_array($condition, $validConditions, true)) {
throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', $validConditions));
}
$filterValue = $values[1] ?? null;
$conditionKey = $this->registerOperatorBind($binds, $condition);
$valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue));
Expand Down
55 changes: 44 additions & 11 deletions src/Database/Adapter/Memory.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Utopia\Database\Document;
use Utopia\Database\Exception as DatabaseException;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Exception\Limit as LimitException;
use Utopia\Database\Exception\NotFound as NotFoundException;
use Utopia\Database\Exception\Operator as OperatorException;
use Utopia\Database\Operator;
Expand Down Expand Up @@ -3467,12 +3468,13 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
$max = $values[1] ?? null;
$base = \is_numeric($current) ? $current + 0 : 0;
if ($max !== null) {
// Compare *remaining headroom* against $by so we never
// overflow PHP's int range (which would silently demote
// the result to float and corrupt downstream Range
// validators).
if ($base >= $max || ($max - $base) <= $by) {
return $this->preserveNumericType($base, $max);
// Compare *remaining headroom* against $by so we never overflow PHP's int
// range. Guard: if the RESULT would exceed the max, leave it unchanged.
// Note: we must NOT short-circuit on `$base >= $max` — a negative $by moves
// the value down, so an already-over-max base can still land within bound
// (e.g. 52 + (-5) = 47 <= 50 must apply).
if (($max - $base) < $by) {
return $this->preserveNumericType($base, $base);
}
}

Expand All @@ -3483,8 +3485,10 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
$min = $values[1] ?? null;
$base = \is_numeric($current) ? $current + 0 : 0;
if ($min !== null) {
if ($base <= $min || ($base - $min) <= $by) {
return $this->preserveNumericType($base, $min);
// Guard: leave unchanged only if the RESULT would go below min. Don't
// short-circuit on `$base <= $min` — a negative $by moves the value up.
if (($base - $min) < $by) {
return $this->preserveNumericType($base, $base);
}
}

Expand All @@ -3494,8 +3498,12 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
$by = $values[0] ?? 1;
$max = $values[1] ?? null;
$base = \is_numeric($current) ? $current + 0 : 0;
$result = $base * $by;
if ($max !== null && $result > $max) {
return $this->preserveNumericType($base, $base);
}

return $this->applyNumericLimit($base * $by, $max, true);
return $this->preserveNumericType($base, $result);

case Operator::TYPE_DIVIDE:
$by = $values[0] ?? 1;
Expand All @@ -3504,8 +3512,12 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
return $current;
}
$base = \is_numeric($current) ? $current + 0 : 0;
$result = $base / $by;
if ($min !== null && $result < $min) {
return $this->preserveNumericType($base, $base);
}

return $this->applyNumericLimit($base / $by, $min, false);
return $this->preserveNumericType($base, $result);

case Operator::TYPE_MODULO:
$by = $values[0] ?? 1;
Expand All @@ -3520,8 +3532,29 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
$by = $values[0] ?? 1;
$max = $values[1] ?? null;
$base = \is_numeric($current) ? $current + 0 : 0;
if ($max !== null) {
// Leave the value unchanged for undefined inputs (0 to a negative power, or a
// negative base to a fractional exponent) — they produce INF/NaN, not a number.
if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) {
return $this->preserveNumericType($base, $base);
}
$result = $base ** $by;
// A result that overflows (INF) or exceeds the max also leaves the value as-is.
if (!\is_finite($result) || $result > $max) {
return $this->preserveNumericType($base, $base);
}

return $this->preserveNumericType($base, $result);
}

// 0 to a negative power, or a negative base to a fractional exponent, is not a real
// number. Fail loudly with a clear exception rather than storing INF/NaN.
$result = $base ** $by;
if (!\is_finite($result)) {
throw new LimitException('Value out of range');
}

return $this->applyNumericLimit($base ** $by, $max, true);
return $this->preserveNumericType($base, $result);

case Operator::TYPE_STRING_CONCAT:
return ((string) ($current ?? '')).(string) ($values[0] ?? '');
Expand Down
39 changes: 33 additions & 6 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -1862,38 +1862,59 @@ private function getOperatorExpression(Operator $operator, string $field): mixed
case Operator::TYPE_INCREMENT:
$expr = ['$add' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]];
if (isset($values[1])) {
$expr = ['$min' => [$expr, $values[1]]];
$expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
}
return $expr;

case Operator::TYPE_DECREMENT:
$expr = ['$subtract' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]];
if (isset($values[1])) {
$expr = ['$max' => [$expr, $values[1]]];
$expr = ['$cond' => [['$gte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
}
return $expr;

case Operator::TYPE_MULTIPLY:
$expr = ['$multiply' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]];
if (isset($values[1])) {
$expr = ['$min' => [$expr, $values[1]]];
$expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
}
return $expr;

case Operator::TYPE_DIVIDE:
$expr = ['$divide' => [['$ifNull' => [$ref, 0]], $values[0]]];
if (isset($values[1])) {
$expr = ['$max' => [$expr, $values[1]]];
$expr = ['$cond' => [['$gte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
}
return $expr;

case Operator::TYPE_MODULO:
return ['$mod' => [['$ifNull' => [$ref, 0]], $values[0]]];

case Operator::TYPE_POWER:
$expr = ['$pow' => [['$ifNull' => [$ref, 0]], $values[0]]];
$base = ['$ifNull' => [$ref, 0]];
$exponent = $values[0];
$expr = ['$pow' => [$base, $exponent]];
if (isset($values[1])) {
$expr = ['$min' => [$expr, $values[1]]];
// Apply the power only if the result stays within the max; otherwise leave the
// value unchanged. Overflow yields Infinity, which is greater than the max, so
// it correctly stays put.
$expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]];

// Never compute $pow for an undefined input (0 to a negative power, or a
// negative base to a fractional exponent): it yields NaN, which Mongo orders
// below every number, so a plain `<= max` check would wrongly apply it. The
// exponent is constant, so only guard the base condition it can actually trigger.
$guards = [];
if ($exponent < 0) {
$guards[] = ['$eq' => [$base, 0]];
}
if (\floor($exponent) != $exponent) {
$guards[] = ['$lt' => [$base, 0]];
}
if (!empty($guards)) {
$undefined = \count($guards) === 1 ? $guards[0] : ['$or' => $guards];
$expr = ['$cond' => [$undefined, $base, $expr]];
}
}
return $expr;

Expand Down Expand Up @@ -4003,6 +4024,12 @@ protected function processException(\Throwable $e): \Throwable
return new TypeException('Invalid operation', $e->getCode(), $e);
}

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

return $e;
}

Expand Down
Loading
Loading