From 0ffcaa62d27628752a7509575ad6aca7ee693064 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 08:52:00 +0300 Subject: [PATCH 01/18] run ci --- src/Database/Adapter/MariaDB.php | 28 ++- src/Database/Adapter/Memory.php | 37 +++- src/Database/Adapter/Mongo.php | 10 +- src/Database/Adapter/Postgres.php | 16 +- src/Database/Adapter/Redis.php | 32 ++- src/Database/Adapter/SQLite.php | 16 +- tests/e2e/Adapter/Scopes/OperatorTests.php | 233 +++++++++++++++++++-- 7 files changed, 293 insertions(+), 79 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 64e9681a27..331d91106f 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1968,9 +1968,11 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind if (isset($values[1])) { $maxKey = "op_{$bindIndex}"; $bindIndex++; + // 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"; } @@ -1982,9 +1984,10 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind if (isset($values[1])) { $minKey = "op_{$bindIndex}"; $bindIndex++; + // `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"; } @@ -1996,10 +1999,12 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind if (isset($values[1])) { $maxKey = "op_{$bindIndex}"; $bindIndex++; + // 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"; } @@ -2012,7 +2017,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $minKey = "op_{$bindIndex}"; $bindIndex++; 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"; } @@ -2029,10 +2034,11 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind if (isset($values[1])) { $maxKey = "op_{$bindIndex}"; $bindIndex++; + // Compare via logarithm (val*LN(col) > LN(max)) for col > 1, so POWER() is + // never computed when it would overflow. col <= 1 can't exceed a positive max, + // so it falls through and applies. Inclusive: a result exactly on max applies. 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 + WHEN COALESCE({$quotedColumn}, 0) > 1 AND :$bindKey * LOG(COALESCE({$quotedColumn}, 0)) > LOG(:$maxKey) THEN COALESCE({$quotedColumn}, 0) ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) END"; } diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 7760baf4d8..dda034bec6 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -3467,12 +3467,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); } } @@ -3483,8 +3484,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); } } @@ -3494,8 +3497,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; @@ -3504,8 +3511,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; @@ -3520,8 +3531,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_STRING_CONCAT: return ((string) ($current ?? '')).(string) ($values[0] ?? ''); diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index a7799abc12..11f3ef1537 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1862,28 +1862,28 @@ 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; @@ -1893,7 +1893,7 @@ private function getOperatorExpression(Operator $operator, string $field): mixed case Operator::TYPE_POWER: $expr = ['$pow' => [['$ifNull' => [$ref, 0]], $values[0]]]; if (isset($values[1])) { - $expr = ['$min' => [$expr, $values[1]]]; + $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]]; } return $expr; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index b1d33d40e2..5b6ec5f42d 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2532,8 +2532,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $maxKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN COALESCE({$columnRef}, 0) >= CAST(:$maxKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) - WHEN COALESCE({$columnRef}, 0) > CAST(:$maxKey AS NUMERIC) - CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) + WHEN COALESCE({$columnRef}, 0) + CAST(:$bindKey AS NUMERIC) > CAST(:$maxKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) + CAST(:$bindKey AS NUMERIC) END"; } @@ -2546,8 +2545,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $minKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN COALESCE({$columnRef}, 0) <= CAST(:$minKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC) - WHEN COALESCE({$columnRef}, 0) < CAST(:$minKey AS NUMERIC) + CAST(:$bindKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC) + WHEN COALESCE({$columnRef}, 0) - CAST(:$bindKey AS NUMERIC) < CAST(:$minKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) - CAST(:$bindKey AS NUMERIC) END"; } @@ -2560,9 +2558,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $maxKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN COALESCE({$columnRef}, 0) >= CAST(:$maxKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) - WHEN CAST(:$bindKey AS NUMERIC) > 0 AND COALESCE({$columnRef}, 0) > CAST(:$maxKey AS NUMERIC) / CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) - WHEN CAST(:$bindKey AS NUMERIC) < 0 AND COALESCE({$columnRef}, 0) < CAST(:$maxKey AS NUMERIC) / CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) + WHEN COALESCE({$columnRef}, 0) * CAST(:$bindKey AS NUMERIC) > CAST(:$maxKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) * CAST(:$bindKey AS NUMERIC) END"; } @@ -2575,7 +2571,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $minKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN CAST(:$bindKey AS NUMERIC) != 0 AND COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC) <= CAST(:$minKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC) + WHEN CAST(:$bindKey AS NUMERIC) != 0 AND COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC) < CAST(:$minKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC) END"; } @@ -2593,9 +2589,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $maxKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN COALESCE({$columnRef}, 0) >= :$maxKey THEN :$maxKey - WHEN COALESCE({$columnRef}, 0) <= 1 THEN COALESCE({$columnRef}, 0) - WHEN :$bindKey * LN(COALESCE({$columnRef}, 1)) > LN(:$maxKey) THEN :$maxKey + WHEN POWER(COALESCE({$columnRef}, 0), :$bindKey) > :$maxKey THEN COALESCE({$columnRef}, 0) ELSE POWER(COALESCE({$columnRef}, 0), :$bindKey) END"; } diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 87e0fe89b6..b3f1005aab 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -4041,8 +4041,12 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $max = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; if ($max !== null) { - if ($base >= $max || ($max - $base) <= $by) { - return $this->preserveNumericType($base, $max); + // Guard: if the RESULT would exceed the max, leave it unchanged. Comparing + // remaining headroom keeps us inside PHP's int range. Must NOT short-circuit + // on `$base >= $max` — a negative $by moves the value down, so an 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); } } @@ -4053,8 +4057,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); } } @@ -4064,8 +4070,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; @@ -4074,8 +4084,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; @@ -4090,8 +4104,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_STRING_CONCAT: return ((string) ($current ?? '')) . (string) ($values[0] ?? ''); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 0f30d5ab45..44db52a14f 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2077,8 +2077,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $maxKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey - WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN :$maxKey + WHEN COALESCE({$quotedColumn}, 0) + :$bindKey > :$maxKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) + :$bindKey END"; } @@ -2093,8 +2092,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $minKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) <= :$minKey THEN :$minKey - WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN :$minKey + WHEN COALESCE({$quotedColumn}, 0) - :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) - :$bindKey END"; } @@ -2109,9 +2107,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $maxKey = "op_{$bindIndex}"; $bindIndex++; 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 COALESCE({$quotedColumn}, 0) * :$bindKey > :$maxKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) * :$bindKey END"; } @@ -2126,7 +2122,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $minKey = "op_{$bindIndex}"; $bindIndex++; 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"; } @@ -2153,9 +2149,7 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind $maxKey = "op_{$bindIndex}"; $bindIndex++; return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey - WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) - WHEN :$bindKey * LN(COALESCE({$quotedColumn}, 1)) > LN(:$maxKey) THEN :$maxKey + WHEN POWER(COALESCE({$quotedColumn}, 0), :$bindKey) > :$maxKey THEN COALESCE({$quotedColumn}, 0) ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) END"; } diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 3f365ed37c..686fce0c8f 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -762,13 +762,13 @@ public function testOperatorValueLimits(): void $updated = $database->updateDocument($collectionId, 'limits_test_doc', new Document([ 'counter' => Operator::increment(100, 50) // Increment by 100 but max is 50 ])); - $this->assertEquals(50, $updated->getAttribute('counter')); // Should be capped at 50 + $this->assertEquals(10, $updated->getAttribute('counter')); // Unchanged — would exceed 50 // Test: Decrement with min limit $updated = $database->updateDocument($collectionId, 'limits_test_doc', new Document([ 'score' => Operator::decrement(10, 0) // Decrement score by 10 but min is 0 ])); - $this->assertEquals(0, $updated->getAttribute('score')); // Should be capped at 0 + $this->assertEquals(5.0, $updated->getAttribute('score')); // Unchanged — would go below 0 // Test: Multiply with max limit $doc = $database->createDocument($collectionId, new Document([ @@ -781,13 +781,13 @@ public function testOperatorValueLimits(): void $updated = $database->updateDocument($collectionId, 'limits_test_doc2', new Document([ 'counter' => Operator::multiply(10, 75) // 10 * 10 = 100, but max is 75 ])); - $this->assertEquals(75, $updated->getAttribute('counter')); // Should be capped at 75 + $this->assertEquals(10, $updated->getAttribute('counter')); // Unchanged — would exceed 75 // Test: Power with max limit $updated = $database->updateDocument($collectionId, 'limits_test_doc2', new Document([ 'score' => Operator::power(3, 100) // 5^3 = 125, but max is 100 ])); - $this->assertEquals(100, $updated->getAttribute('score')); // Should be capped at 100 + $this->assertEquals(5.0, $updated->getAttribute('score')); // Unchanged — would exceed 100 $database->deleteCollection($collectionId); } @@ -1216,7 +1216,7 @@ public function testOperatorIncrementComprehensive(): void $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ 'count' => Operator::increment(5, 10) ])); - $this->assertEquals(10, $updated->getAttribute('count')); // Should cap at 10 + $this->assertEquals(8, $updated->getAttribute('count')); // Unchanged — would exceed 10 // Success case - float $doc2 = $database->createDocument($collectionId, new Document([ @@ -1272,7 +1272,7 @@ public function testOperatorDecrementComprehensive(): void $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ 'count' => Operator::decrement(10, 5) ])); - $this->assertEquals(5, $updated->getAttribute('count')); // Should stop at min 5 + $this->assertEquals(7, $updated->getAttribute('count')); // Unchanged — would go below 5 // Edge case: null value $doc2 = $database->createDocument($collectionId, new Document([ @@ -1317,7 +1317,7 @@ public function testOperatorMultiplyComprehensive(): void $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ 'value' => Operator::multiply(3, 20) ])); - $this->assertEquals(20.0, $updated->getAttribute('value')); // Should cap at 20 + $this->assertEquals(10.0, $updated->getAttribute('value')); // Unchanged — would exceed 20 $database->deleteCollection($collectionId); } @@ -1352,7 +1352,7 @@ public function testOperatorDivideComprehensive(): void $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ 'value' => Operator::divide(10, 2) ])); - $this->assertEquals(2.0, $updated->getAttribute('value')); // Should stop at min 2 + $this->assertEquals(5.0, $updated->getAttribute('value')); // Unchanged — would go below 2 $database->deleteCollection($collectionId); } @@ -1416,7 +1416,194 @@ public function testOperatorPowerComprehensive(): void $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ 'number' => Operator::power(4, 50) ])); - $this->assertEquals(50, $updated->getAttribute('number')); // Should cap at 50 + $this->assertEquals(8, $updated->getAttribute('number')); // Unchanged — would exceed 50 + + $database->deleteCollection($collectionId); + } + + /** + * When an operation makes the value SMALLER, the max limit should not get in the way: + * the new (smaller) value is below the max, so it must be saved. Older code had a bug + * where, if the current value was already at or above the max, it refused to change it — + * so multiply(0.5, 50) on 80 wrongly stayed 50 instead of becoming 40, and a square root + * (power(0.5, 50)) on 100 stayed 50 instead of 10. + */ + public function testOperatorBoundedShrinkApplies(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_bounded_shrink'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + + $database->createDocument($collectionId, new Document([ + '$id' => 'shrink_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 80.0, + ])); + + // 80 * 0.5 = 40. 40 is below the max of 50, so 40 is saved. (Old code wrongly kept 50.) + $updated = $database->updateDocument($collectionId, 'shrink_doc', new Document([ + 'value' => Operator::multiply(0.5, 50), + ])); + $this->assertEquals(40.0, $updated->getAttribute('value')); + + // Square root of 100 is 10. 10 is below the max of 50, so 10 is saved. (Old code kept 50.) + $database->updateDocument($collectionId, 'shrink_doc', new Document([ + 'value' => 100.0, + ])); + $updated = $database->updateDocument($collectionId, 'shrink_doc', new Document([ + 'value' => Operator::power(0.5, 50), + ])); + $this->assertEquals(10.0, $updated->getAttribute('value')); + + // Adding a negative number lowers the value: 52 + (-5) = 47. 47 is below the max of 50, + // so 47 is saved even though the starting value 52 was already above the max. + // (Older code failed to save 47 here.) + $database->updateDocument($collectionId, 'shrink_doc', new Document([ + 'value' => 52.0, + ])); + $updated = $database->updateDocument($collectionId, 'shrink_doc', new Document([ + 'value' => Operator::increment(-5, 50), + ])); + $this->assertEquals(47.0, $updated->getAttribute('value')); + + $database->deleteCollection($collectionId); + } + + /** + * A limit on one field only affects that one field. In a single update, if one operator + * hits its limit and is skipped, the other operators and the normal fields in the same + * update still get saved. + */ + public function testOperatorGuardIsPerColumn(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_guard_per_column'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 100, false, ''); + + $database->createDocument($collectionId, new Document([ + '$id' => 'doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 48, + 'score' => 10.0, + 'name' => 'before', + ])); + + $updated = $database->updateDocument($collectionId, 'doc', new Document([ + 'count' => Operator::increment(5, 50), // 48 + 5 = 53, over the max 50, so count is left as 48 + 'score' => Operator::multiply(2, 100), // 10 * 2 = 20, under the max 100, so score becomes 20 + 'name' => 'after', // a normal field, always saved + ])); + + $this->assertEquals(48, $updated->getAttribute('count')); // left unchanged (would have gone over the max) + $this->assertEquals(20.0, $updated->getAttribute('score')); // saved + $this->assertEquals('after', $updated->getAttribute('name')); // saved + + $database->deleteCollection($collectionId); + } + + /** + * When updating many documents at once, each document is checked against the limit using + * its own value. Here d2's count is over the limit so d2's count is left alone, but d2's + * score is still under its limit and is saved. So the limit on one field of one document + * does not stop other fields, or other documents, from being updated. + */ + public function testOperatorGuardIsPerRowInBatch(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_guard_per_row'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + + foreach ([['d1', 10], ['d2', 48]] as [$id, $count]) { + $database->createDocument($collectionId, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => $count, + 'score' => 10.0, + ])); + } + + $database->updateDocuments($collectionId, new Document([ + 'count' => Operator::increment(5, 50), // d1: 10+5=15 saved; d2: 48+5=53 is over 50, so d2.count stays 48 + 'score' => Operator::increment(5, 100), // both: 10+5=15, under 100, so saved + ])); + + $d1 = $database->getDocument($collectionId, 'd1'); + $d2 = $database->getDocument($collectionId, 'd2'); + + $this->assertEquals(15, $d1->getAttribute('count')); // under the max, saved + $this->assertEquals(15.0, $d1->getAttribute('score')); + $this->assertEquals(48, $d2->getAttribute('count')); // over the max, left as 48 + $this->assertEquals(15.0, $d2->getAttribute('score')); // still saved, even though d2.count was left unchanged + + $database->deleteCollection($collectionId); + } + + /** + * The max value itself is allowed. Reaching exactly the max is saved; only going ABOVE + * the max leaves the value unchanged. + */ + public function testOperatorBoundIsInclusive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_bound_inclusive'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); + + $database->createDocument($collectionId, new Document([ + '$id' => 'doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 48, + ])); + + // 48 + 1 = 49, below the max of 50, so 49 is saved + $updated = $database->updateDocument($collectionId, 'doc', new Document([ + 'counter' => Operator::increment(1, 50), + ])); + $this->assertEquals(49, $updated->getAttribute('counter')); + + // Reset. 48 + 2 = 50, exactly the max, so 50 is saved + $database->updateDocument($collectionId, 'doc', new Document(['counter' => 48])); + $updated = $database->updateDocument($collectionId, 'doc', new Document([ + 'counter' => Operator::increment(2, 50), + ])); + $this->assertEquals(50, $updated->getAttribute('counter')); + + // Reset. 48 + 3 = 51, above the max of 50, so the value is left as 48 + $database->updateDocument($collectionId, 'doc', new Document(['counter' => 48])); + $updated = $database->updateDocument($collectionId, 'doc', new Document([ + 'counter' => Operator::increment(3, 50), + ])); + $this->assertEquals(48, $updated->getAttribute('counter')); $database->deleteCollection($collectionId); } @@ -2630,9 +2817,9 @@ public function testOperatorMultiplyWithNegativeMultiplier(): void ])); $updated4 = $database->updateDocument($collectionId, 'negative_overflow', new Document([ - 'value' => Operator::multiply(-3, 100) // -60 * -3 = 180, should be capped at 100 + 'value' => Operator::multiply(-3, 100) // -60 * -3 = 180, would exceed max 100, so unchanged ])); - $this->assertEquals(100.0, $updated4->getAttribute('value'), 'Negative * negative should cap at max when result would exceed it'); + $this->assertEquals(-60.0, $updated4->getAttribute('value'), 'Negative * negative should leave value unchanged when result would exceed max'); // Test zero multiplier with max $doc5 = $database->createDocument($collectionId, new Document([ @@ -2700,9 +2887,9 @@ public function testOperatorDivideWithNegativeDivisor(): void ])); $updated3 = $database->updateDocument($collectionId, 'pos_div_neg', new Document([ - 'value' => Operator::divide(-4, -10) // 100 / -4 = -25, which is below min -10, so floor at -10 + 'value' => Operator::divide(-4, -10) // 100 / -4 = -25, which is below min -10, so unchanged ])); - $this->assertEquals(-10.0, $updated3->getAttribute('value'), 'Positive / negative should floor at min when result would be below it'); + $this->assertEquals(100.0, $updated3->getAttribute('value'), 'Positive / negative should leave value unchanged when result would be below min'); // Test negative value / negative divisor that would go below min $doc4 = $database->createDocument($collectionId, new Document([ @@ -2712,9 +2899,9 @@ public function testOperatorDivideWithNegativeDivisor(): void ])); $updated4 = $database->updateDocument($collectionId, 'negative_underflow', new Document([ - 'value' => Operator::divide(-2, -10) // 40 / -2 = -20, which is below min -10, so floor at -10 + 'value' => Operator::divide(-2, -10) // 40 / -2 = -20, which is below min -10, so unchanged ])); - $this->assertEquals(-10.0, $updated4->getAttribute('value'), 'Positive / negative should floor at min when result would be below it'); + $this->assertEquals(40.0, $updated4->getAttribute('value'), 'Positive / negative should leave value unchanged when result would be below min'); $database->deleteCollection($collectionId); } @@ -2862,17 +3049,17 @@ public function testOperatorWithExtremeIntegerValues(): void $updated = $database->updateDocument($collectionId, 'extreme_int_doc', new Document([ 'bigint_max' => Operator::increment(2000, PHP_INT_MAX - 500) ])); - // Should be capped at max + // Unchanged — base + 2000 would exceed max $this->assertLessThanOrEqual(PHP_INT_MAX - 500, $updated->getAttribute('bigint_max')); - $this->assertEquals(PHP_INT_MAX - 500, $updated->getAttribute('bigint_max')); + $this->assertEquals($maxValue, $updated->getAttribute('bigint_max')); // Test decrement near min with limit $updated = $database->updateDocument($collectionId, 'extreme_int_doc', new Document([ 'bigint_min' => Operator::decrement(2000, PHP_INT_MIN + 500) ])); - // Should be capped at min + // Unchanged — base - 2000 would go below min $this->assertGreaterThanOrEqual(PHP_INT_MIN + 500, $updated->getAttribute('bigint_min')); - $this->assertEquals(PHP_INT_MIN + 500, $updated->getAttribute('bigint_min')); + $this->assertEquals($minValue, $updated->getAttribute('bigint_min')); $database->deleteCollection($collectionId); } @@ -3842,13 +4029,13 @@ public function testOperatorWithAttributeConstraints(): void 'small_int' => 100 ])); - // Test increment with max that's within bounds + // Test increment with max — 100 + 50 = 150 exceeds max 120, so unchanged $updated = $database->updateDocument($collectionId, 'constraint_doc', new Document([ 'small_int' => Operator::increment(50, 120) ])); - $this->assertEquals(120, $updated->getAttribute('small_int')); + $this->assertEquals(100, $updated->getAttribute('small_int')); - // Test multiply that would exceed without limit + // Test multiply that would exceed the max limit — unchanged $database->updateDocument($collectionId, 'constraint_doc', new Document([ 'small_int' => 1000 ])); @@ -3856,7 +4043,7 @@ public function testOperatorWithAttributeConstraints(): void $updated = $database->updateDocument($collectionId, 'constraint_doc', new Document([ 'small_int' => Operator::multiply(1000, 5000) ])); - $this->assertEquals(5000, $updated->getAttribute('small_int')); + $this->assertEquals(1000, $updated->getAttribute('small_int')); $database->deleteCollection($collectionId); } From cc442134808b6703130e963fa27549c869ae41ce Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 09:02:20 +0300 Subject: [PATCH 02/18] Update main --- src/Database/Adapter/MariaDB.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index adc90f49ae..71805b2d48 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1974,7 +1974,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ELSE COALESCE({$quotedColumn}, 0) + :$bindKey END"; } - } return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) + :$bindKey"; case Operator::TYPE_DECREMENT: From 4d60463debee5a56b8bec38e39d9fad3106b2c01 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 09:33:47 +0300 Subject: [PATCH 03/18] fix edge cases --- src/Database/Adapter/MariaDB.php | 10 +++-- src/Database/Adapter/Memory.php | 18 +++++++-- src/Database/Adapter/Mongo.php | 13 ++++++- src/Database/Adapter/Postgres.php | 8 +++- src/Database/Adapter/Redis.php | 18 +++++++-- src/Database/Adapter/SQLite.php | 7 +++- tests/e2e/Adapter/Scopes/OperatorTests.php | 43 ++++++++++++++++++++++ 7 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 71805b2d48..3f56761cca 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2023,11 +2023,13 @@ 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 logarithm (val*LN(col) > LN(max)) for col > 1, so POWER() is - // never computed when it would overflow. col <= 1 can't exceed a positive max, - // so it falls through and applies. Inclusive: a result exactly on max applies. + // A base of 1 or less can't exceed a positive max, so leave it unchanged — + // this also avoids POWER() on 0 or a negative base (which yields NULL for a + // negative/fractional exponent). For a base above 1 compare with logarithms + // so POWER() is computed at most once, and only when the result fits the max. return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) > 1 AND :$bindKey * LOG(COALESCE({$quotedColumn}, 0)) > LOG(:$maxKey) THEN COALESCE({$quotedColumn}, 0) + WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) + WHEN :$bindKey * LOG(COALESCE({$quotedColumn}, 0)) > LOG(:$maxKey) THEN COALESCE({$quotedColumn}, 0) ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) END"; } diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index dda034bec6..6e3362b39b 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -3531,12 +3531,22 @@ 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); + if ($max !== null) { + // A base of 1 or less can't exceed a positive max, so leave it unchanged. + // This also avoids INF/NAN from undefined powers (0 to a negative power, + // or a negative base to a fractional power) being stored. + if ($base <= 1) { + return $this->preserveNumericType($base, $base); + } + $result = $base ** $by; + if ($result > $max) { + return $this->preserveNumericType($base, $base); + } + + return $this->preserveNumericType($base, $result); } - return $this->preserveNumericType($base, $result); + return $this->preserveNumericType($base, $base ** $by); case Operator::TYPE_STRING_CONCAT: return ((string) ($current ?? '')).(string) ($values[0] ?? ''); diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 11f3ef1537..30c940ca65 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1891,9 +1891,18 @@ private function getOperatorExpression(Operator $operator, string $field): mixed return ['$mod' => [['$ifNull' => [$ref, 0]], $values[0]]]; case Operator::TYPE_POWER: - $expr = ['$pow' => [['$ifNull' => [$ref, 0]], $values[0]]]; + $base = ['$ifNull' => [$ref, 0]]; + $expr = ['$pow' => [$base, $values[0]]]; if (isset($values[1])) { - $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]]; + // A base of 1 or less can't exceed a positive max, so leave it unchanged. + // This also avoids storing NaN/Infinity from undefined powers (a negative + // base to a fractional power, or 0 to a negative power) — Mongo orders NaN + // below all numbers, so a plain `result <= max` check would wrongly apply it. + $expr = ['$cond' => [ + ['$lte' => [$base, 1]], + $base, + ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]], + ]]; } return $expr; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 085946d679..f3030292ca 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2575,8 +2575,14 @@ 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]); + // A base of 1 or less can't exceed a positive max, so leave it unchanged. + // This also avoids POWER() domain errors: PostgreSQL throws a hard error for + // 0 to a negative power and for a negative number to a fractional power. + // For a base above 1 we compare with logarithms so POWER() is computed at + // most once, and only when the result is within the max. return "{$quotedColumn} = CASE - WHEN POWER(COALESCE({$columnRef}, 0), :$bindKey) > :$maxKey THEN COALESCE({$columnRef}, 0) + WHEN COALESCE({$columnRef}, 0) <= 1 THEN COALESCE({$columnRef}, 0) + WHEN :$bindKey * LN(COALESCE({$columnRef}, 0)) > LN(:$maxKey) THEN COALESCE({$columnRef}, 0) ELSE POWER(COALESCE({$columnRef}, 0), :$bindKey) END"; } diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index b3f1005aab..106be87a30 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -4104,12 +4104,22 @@ 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); + if ($max !== null) { + // A base of 1 or less can't exceed a positive max, so leave it unchanged. + // This also avoids INF/NAN from undefined powers (0 to a negative power, + // or a negative base to a fractional power) being stored. + if ($base <= 1) { + return $this->preserveNumericType($base, $base); + } + $result = $base ** $by; + if ($result > $max) { + return $this->preserveNumericType($base, $base); + } + + return $this->preserveNumericType($base, $result); } - return $this->preserveNumericType($base, $result); + return $this->preserveNumericType($base, $base ** $by); case Operator::TYPE_STRING_CONCAT: return ((string) ($current ?? '')) . (string) ($values[0] ?? ''); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 035cef2e96..4a357bab07 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2095,8 +2095,13 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); + // A base of 1 or less can't exceed a positive max, so leave it unchanged — + // this also avoids POWER() on 0 or a negative base (undefined for a + // negative/fractional exponent). For a base above 1 compare with logarithms + // so POWER() is computed at most once, and only when the result fits the max. return "{$quotedColumn} = CASE - WHEN POWER(COALESCE({$quotedColumn}, 0), :$bindKey) > :$maxKey THEN COALESCE({$quotedColumn}, 0) + WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) + WHEN :$bindKey * LN(COALESCE({$quotedColumn}, 0)) > LN(:$maxKey) THEN COALESCE({$quotedColumn}, 0) ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) END"; } diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 686fce0c8f..6c9367b25e 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1608,6 +1608,49 @@ public function testOperatorBoundIsInclusive(): void $database->deleteCollection($collectionId); } + /** + * A square root (power 0.5) of a negative number, and zero raised to a negative power, are + * not real numbers. With a max set, the operator must not crash the update — it leaves the + * value unchanged. (Some databases throw a hard error if asked to compute these directly.) + */ + public function testOperatorPowerOnZeroOrNegativeBase(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_power_edge'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + + // The square root of a negative number is not a real number, so -4 is left as -4. + $database->createDocument($collectionId, new Document([ + '$id' => 'neg', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => -4.0, + ])); + $updated = $database->updateDocument($collectionId, 'neg', new Document([ + 'value' => Operator::power(0.5, 100), + ])); + $this->assertEquals(-4.0, $updated->getAttribute('value')); + + // 0 raised to a negative power is undefined, so 0 is left as 0. + $database->createDocument($collectionId, new Document([ + '$id' => 'zero', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 0.0, + ])); + $updated = $database->updateDocument($collectionId, 'zero', new Document([ + 'value' => Operator::power(-1, 100), + ])); + $this->assertEquals(0.0, $updated->getAttribute('value')); + + $database->deleteCollection($collectionId); + } + public function testOperatorStringConcatComprehensive(): void { $database = static::getDatabase(); From 3fd8f8499201edafd37f0d3b39e74b97beeb4994 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 11:42:50 +0300 Subject: [PATCH 04/18] MAX_ARRAY_OPERATOR_SIZE --- src/Database/Adapter/MariaDB.php | 16 +++++----- src/Database/Adapter/MySQL.php | 8 ++--- src/Database/Adapter/SQL.php | 6 ---- src/Database/Adapter/SQLite.php | 16 +++++----- src/Database/Operator.php | 6 ++++ tests/e2e/Adapter/Scopes/OperatorTests.php | 35 ++++++++++++++++++++++ 6 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 3f56761cca..0af88eb78f 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2051,15 +2051,15 @@ 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; @@ -2094,8 +2094,8 @@ 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = IFNULL(( @@ -2108,8 +2108,8 @@ 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = IFNULL(( diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index bc7631f0c2..1f6c2ccdb1 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -298,15 +298,15 @@ protected function getOperatorSQL(string $column, \Utopia\Database\Operator $ope switch ($method) { 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 88b739ca93..c1c0a264ec 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -22,12 +22,6 @@ abstract class SQL extends Adapter { protected mixed $pdo; - /** - * Maximum array size for array operations to prevent memory exhaustion. - * Large arrays in JSON_TABLE operations can cause significant memory usage. - */ - protected const MAX_ARRAY_OPERATOR_SIZE = 10000; - /** * Controls how many fractional digits are used when binding float parameters. */ diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 4a357bab07..a33e6fcc71 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2127,8 +2127,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // Array operators case Operator::TYPE_ARRAY_APPEND: $values = $operator->getValues(); - 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: merge arrays by using json_group_array on extracted elements @@ -2144,8 +2144,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_PREPEND: $values = $operator->getValues(); - 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: prepend by extracting and recombining with new elements first @@ -2215,8 +2215,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_INTERSECT: $values = $operator->getValues(); - 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: keep only values that exist in both arrays @@ -2228,8 +2228,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_DIFF: $values = $operator->getValues(); - 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"); + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: remove values that exist in the comparison array diff --git a/src/Database/Operator.php b/src/Database/Operator.php index b60b49fb62..0983ba10b6 100644 --- a/src/Database/Operator.php +++ b/src/Database/Operator.php @@ -66,6 +66,12 @@ class Operator self::TYPE_DATE_SET_NOW, ]; + /** + * Maximum number of values a single array operator (append/prepend/intersect/diff) may carry, + * to guard against memory exhaustion. Enforced consistently across all adapters. + */ + public const MAX_ARRAY_OPERATOR_SIZE = 10000; + protected const NUMERIC_TYPES = [ self::TYPE_INCREMENT, self::TYPE_DECREMENT, diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 6c9367b25e..cfff305d1d 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1651,6 +1651,41 @@ public function testOperatorPowerOnZeroOrNegativeBase(): void $database->deleteCollection($collectionId); } + /** + * Appending more elements than the allowed maximum (10000) must be rejected the same way on + * every adapter. (Currently only the MySQL-family adapters enforce this; the others accept it.) + */ + public function testOperatorArraySizeLimit(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_array_size_limit'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + + $database->createDocument($collectionId, new Document([ + '$id' => 'doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'tags' => ['a'], + ])); + + try { + $database->updateDocument($collectionId, 'doc', new Document([ + 'tags' => Operator::arrayAppend(array_fill(0, Operator::MAX_ARRAY_OPERATOR_SIZE + 1, 'x')), // one over the limit + ])); + $this->fail('Expected an exception for exceeding the array operator size limit'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('exceeds maximum allowed size', $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + public function testOperatorStringConcatComprehensive(): void { $database = static::getDatabase(); From cb070cb3445c06a334765a64c84e47976996ab55 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 11:58:30 +0300 Subject: [PATCH 05/18] Remove check --- src/Database/Adapter/MariaDB.php | 12 --- src/Database/Adapter/MySQL.php | 95 ++++++++-------------- src/Database/Adapter/SQLite.php | 12 --- src/Database/Validator/Operator.php | 15 ++++ tests/e2e/Adapter/Scopes/OperatorTests.php | 27 ++++-- 5 files changed, 69 insertions(+), 92 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 0af88eb78f..7a47c566d9 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2051,16 +2051,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // Array operators case Operator::TYPE_ARRAY_APPEND: - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::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) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; @@ -2094,9 +2088,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ), JSON_ARRAY())"; case Operator::TYPE_ARRAY_INTERSECT: - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(jt1.value) @@ -2108,9 +2099,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ), JSON_ARRAY())"; case Operator::TYPE_ARRAY_DIFF: - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(jt1.value) diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 1f6c2ccdb1..34953442da 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -16,14 +16,12 @@ class MySQL extends MariaDB { /** * Set max execution time - * @param int $milliseconds - * @param string $event - * @return void + * * @throws DatabaseException */ public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void { - if (!$this->getSupportForTimeouts()) { + if (! $this->getSupportForTimeouts()) { return; } if ($milliseconds <= 0) { @@ -44,29 +42,28 @@ public function setTimeout(int $milliseconds, string $event = Database::EVENT_AL /** * Get size of collection on disk - * @param string $collection - * @return int + * * @throws DatabaseException */ public function getSizeOfCollectionOnDisk(string $collection): int { $collection = $this->filter($collection); - $collection = $this->getNamespace() . '_' . $collection; + $collection = $this->getNamespace().'_'.$collection; $database = $this->getDatabase(); - $name = $database . '/' . $collection; - $permissions = $database . '/' . $collection . '_perms'; + $name = $database.'/'.$collection; + $permissions = $database.'/'.$collection.'_perms'; - $collectionSize = $this->getPDO()->prepare(" + $collectionSize = $this->getPDO()->prepare(' SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME = :name - "); + '); - $permissionsSize = $this->getPDO()->prepare(" + $permissionsSize = $this->getPDO()->prepare(' SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME = :permissions - "); + '); $collectionSize->bindParam(':name', $name); $permissionsSize->bindParam(':permissions', $permissions); @@ -76,7 +73,7 @@ public function getSizeOfCollectionOnDisk(string $collection): int $permissionsSize->execute(); $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn(); } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } return $size; @@ -85,14 +82,8 @@ public function getSizeOfCollectionOnDisk(string $collection): int /** * Handle distance spatial queries * - * @param Query $query - * @param array $binds - * @param string $attribute - * @param string $type - * @param string $alias - * @param string $placeholder - * @return string - */ + * @param array $binds + */ protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string { $distanceParams = $query->getValues()[0]; @@ -115,17 +106,19 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str $operator = '<'; break; default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + throw new DatabaseException('Unknown spatial query method: '.$query->getMethod()); } if ($useMeters) { - $attr = "ST_SRID({$alias}.{$attribute}, " . Database::DEFAULT_SRID . ")"; + $attr = "ST_SRID({$alias}.{$attribute}, ".Database::DEFAULT_SRID.')'; $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", null); + return "ST_Distance({$attr}, {$geom}, 'metre') {$operator} :{$placeholder}_1"; } // need to use srid 0 because of geometric distance - $attr = "ST_SRID({$alias}.{$attribute}, " . 0 . ")"; + $attr = "ST_SRID({$alias}.{$attribute}, ". 0 .')'; $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", 0); + return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; } @@ -139,7 +132,7 @@ public function getSupportForIndexArray(): bool public function getSupportForCastIndexArray(): bool { - if (!$this->getSupportForIndexArray()) { + if (! $this->getSupportForIndexArray()) { return false; } @@ -173,20 +166,18 @@ protected function processException(PDOException $e): \Exception return parent::processException($e); } + /** * Does the adapter includes boundary during spatial contains? - * - * @return bool */ public function getSupportForBoundaryInclusiveContains(): bool { return false; } + /** * Does the adapter support order attribute in spatial indexes? - * - * @return bool - */ + */ public function getSupportForSpatialIndexOrder(): bool { return false; @@ -194,8 +185,6 @@ public function getSupportForSpatialIndexOrder(): bool /** * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool */ public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool { @@ -204,51 +193,52 @@ public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bo /** * Spatial type attribute - */ + */ public function getSpatialSQLType(string $type, bool $required): string { switch ($type) { case Database::VAR_POINT: $type = 'POINT SRID 4326'; - if (!$this->getSupportForSpatialIndexNull()) { + if (! $this->getSupportForSpatialIndexNull()) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } + return $type; case Database::VAR_LINESTRING: $type = 'LINESTRING SRID 4326'; - if (!$this->getSupportForSpatialIndexNull()) { + if (! $this->getSupportForSpatialIndexNull()) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } - return $type; + return $type; case Database::VAR_POLYGON: $type = 'POLYGON SRID 4326'; - if (!$this->getSupportForSpatialIndexNull()) { + if (! $this->getSupportForSpatialIndexNull()) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } + return $type; } + return ''; } /** * Does the adapter support spatial axis order specification? - * - * @return bool */ public function getSupportForSpatialAxisOrder(): bool { @@ -263,8 +253,6 @@ public function getSupportForObjectIndexes(): bool /** * Get the spatial axis order specification string for MySQL * MySQL with SRID 4326 expects lat-long by default, but our data is in long-lat format - * - * @return string */ protected function getSpatialAxisOrderSpec(): string { @@ -273,8 +261,6 @@ protected function getSpatialAxisOrderSpec(): string /** * Adapter supports optional spatial attributes with existing rows. - * - * @return bool */ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool { @@ -285,30 +271,21 @@ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool * Get SQL expression for operator * Override for MySQL-specific operator implementations * - * @param string $column - * @param \Utopia\Database\Operator $operator - * @param array $binds - * @return ?string + * @param array $binds */ - protected function getOperatorSQL(string $column, \Utopia\Database\Operator $operator, array &$binds): ?string + protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string { $quotedColumn = $this->quote($column); $method = $operator->getMethod(); $values = $operator->getValues(); switch ($method) { - case Operator::TYPE_ARRAY_APPEND: - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case Operator::TYPE_ARRAY_APPEND: $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) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case Operator::TYPE_ARRAY_PREPEND: $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; case Operator::TYPE_ARRAY_UNIQUE: diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a33e6fcc71..3137c8deb0 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2127,9 +2127,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // Array operators case Operator::TYPE_ARRAY_APPEND: $values = $operator->getValues(); - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: merge arrays by using json_group_array on extracted elements // We use json_each to extract elements from both arrays and combine them @@ -2144,9 +2141,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_PREPEND: $values = $operator->getValues(); - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: prepend by extracting and recombining with new elements first return "{$quotedColumn} = ( @@ -2215,9 +2209,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_INTERSECT: $values = $operator->getValues(); - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: keep only values that exist in both arrays return "{$quotedColumn} = ( @@ -2228,9 +2219,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_DIFF: $values = $operator->getValues(); - if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . Operator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: remove values that exist in the comparison array return "{$quotedColumn} = ( diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 46f1b8db37..3832702319 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -150,6 +150,21 @@ private function validateOperatorForAttribute( $type = $attribute instanceof Document ? $attribute->getAttribute('type') : $attribute['type']; $isArray = $attribute instanceof Document ? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); + // Array operators that carry a caller-supplied value list are capped to guard against + // memory exhaustion. Enforced here so every adapter rejects an oversized list the same way. + if ( + \in_array($method, [ + DatabaseOperator::TYPE_ARRAY_APPEND, + DatabaseOperator::TYPE_ARRAY_PREPEND, + DatabaseOperator::TYPE_ARRAY_INTERSECT, + DatabaseOperator::TYPE_ARRAY_DIFF, + ], true) + && \count($values) > DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE + ) { + $this->message = "Array size " . \count($values) . " exceeds maximum allowed size of " . DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"; + return false; + } + switch ($method) { case DatabaseOperator::TYPE_INCREMENT: case DatabaseOperator::TYPE_DECREMENT: diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index cfff305d1d..c93bc3a3dc 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1652,8 +1652,8 @@ public function testOperatorPowerOnZeroOrNegativeBase(): void } /** - * Appending more elements than the allowed maximum (10000) must be rejected the same way on - * every adapter. (Currently only the MySQL-family adapters enforce this; the others accept it.) + * Passing more values than the allowed maximum (10000) to an array operator must be rejected + * the same way on every adapter. Covers each operator that takes a caller-supplied value list. */ public function testOperatorArraySizeLimit(): void { @@ -1674,13 +1674,22 @@ public function testOperatorArraySizeLimit(): void 'tags' => ['a'], ])); - try { - $database->updateDocument($collectionId, 'doc', new Document([ - 'tags' => Operator::arrayAppend(array_fill(0, Operator::MAX_ARRAY_OPERATOR_SIZE + 1, 'x')), // one over the limit - ])); - $this->fail('Expected an exception for exceeding the array operator size limit'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('exceeds maximum allowed size', $e->getMessage()); + $tooMany = array_fill(0, Operator::MAX_ARRAY_OPERATOR_SIZE + 1, 'x'); // one over the limit + + $operators = [ + 'arrayAppend' => Operator::arrayAppend($tooMany), + 'arrayPrepend' => Operator::arrayPrepend($tooMany), + 'arrayIntersect' => Operator::arrayIntersect($tooMany), + 'arrayDiff' => Operator::arrayDiff($tooMany), + ]; + + foreach ($operators as $name => $operator) { + try { + $database->updateDocument($collectionId, 'doc', new Document(['tags' => $operator])); + $this->fail("Expected an exception for {$name} exceeding the array operator size limit"); + } catch (DatabaseException $e) { + $this->assertStringContainsString('exceeds maximum allowed size', $e->getMessage()); + } } $database->deleteCollection($collectionId); From 5523af208e686b4681bd96b9d24ec9a6330b3107 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 12:17:59 +0300 Subject: [PATCH 06/18] Revert pint changes --- src/Database/Adapter/MySQL.php | 89 ++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 34953442da..6a43047957 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -16,12 +16,14 @@ class MySQL extends MariaDB { /** * Set max execution time - * + * @param int $milliseconds + * @param string $event + * @return void * @throws DatabaseException */ public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void { - if (! $this->getSupportForTimeouts()) { + if (!$this->getSupportForTimeouts()) { return; } if ($milliseconds <= 0) { @@ -42,28 +44,29 @@ public function setTimeout(int $milliseconds, string $event = Database::EVENT_AL /** * Get size of collection on disk - * + * @param string $collection + * @return int * @throws DatabaseException */ public function getSizeOfCollectionOnDisk(string $collection): int { $collection = $this->filter($collection); - $collection = $this->getNamespace().'_'.$collection; + $collection = $this->getNamespace() . '_' . $collection; $database = $this->getDatabase(); - $name = $database.'/'.$collection; - $permissions = $database.'/'.$collection.'_perms'; + $name = $database . '/' . $collection; + $permissions = $database . '/' . $collection . '_perms'; - $collectionSize = $this->getPDO()->prepare(' + $collectionSize = $this->getPDO()->prepare(" SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME = :name - '); + "); - $permissionsSize = $this->getPDO()->prepare(' + $permissionsSize = $this->getPDO()->prepare(" SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME = :permissions - '); + "); $collectionSize->bindParam(':name', $name); $permissionsSize->bindParam(':permissions', $permissions); @@ -73,7 +76,7 @@ public function getSizeOfCollectionOnDisk(string $collection): int $permissionsSize->execute(); $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn(); } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); + throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); } return $size; @@ -82,8 +85,14 @@ public function getSizeOfCollectionOnDisk(string $collection): int /** * Handle distance spatial queries * - * @param array $binds - */ + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $type + * @param string $alias + * @param string $placeholder + * @return string + */ protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string { $distanceParams = $query->getValues()[0]; @@ -106,19 +115,17 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str $operator = '<'; break; default: - throw new DatabaseException('Unknown spatial query method: '.$query->getMethod()); + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); } if ($useMeters) { - $attr = "ST_SRID({$alias}.{$attribute}, ".Database::DEFAULT_SRID.')'; + $attr = "ST_SRID({$alias}.{$attribute}, " . Database::DEFAULT_SRID . ")"; $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", null); - return "ST_Distance({$attr}, {$geom}, 'metre') {$operator} :{$placeholder}_1"; } // need to use srid 0 because of geometric distance - $attr = "ST_SRID({$alias}.{$attribute}, ". 0 .')'; + $attr = "ST_SRID({$alias}.{$attribute}, " . 0 . ")"; $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", 0); - return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; } @@ -132,7 +139,7 @@ public function getSupportForIndexArray(): bool public function getSupportForCastIndexArray(): bool { - if (! $this->getSupportForIndexArray()) { + if (!$this->getSupportForIndexArray()) { return false; } @@ -166,18 +173,20 @@ protected function processException(PDOException $e): \Exception return parent::processException($e); } - /** * Does the adapter includes boundary during spatial contains? + * + * @return bool */ public function getSupportForBoundaryInclusiveContains(): bool { return false; } - /** * Does the adapter support order attribute in spatial indexes? - */ + * + * @return bool + */ public function getSupportForSpatialIndexOrder(): bool { return false; @@ -185,6 +194,8 @@ public function getSupportForSpatialIndexOrder(): bool /** * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * + * @return bool */ public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool { @@ -193,52 +204,51 @@ public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bo /** * Spatial type attribute - */ + */ public function getSpatialSQLType(string $type, bool $required): string { switch ($type) { case Database::VAR_POINT: $type = 'POINT SRID 4326'; - if (! $this->getSupportForSpatialIndexNull()) { + if (!$this->getSupportForSpatialIndexNull()) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } - return $type; case Database::VAR_LINESTRING: $type = 'LINESTRING SRID 4326'; - if (! $this->getSupportForSpatialIndexNull()) { + if (!$this->getSupportForSpatialIndexNull()) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } - return $type; + case Database::VAR_POLYGON: $type = 'POLYGON SRID 4326'; - if (! $this->getSupportForSpatialIndexNull()) { + if (!$this->getSupportForSpatialIndexNull()) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } - return $type; } - return ''; } /** * Does the adapter support spatial axis order specification? + * + * @return bool */ public function getSupportForSpatialAxisOrder(): bool { @@ -253,6 +263,8 @@ public function getSupportForObjectIndexes(): bool /** * Get the spatial axis order specification string for MySQL * MySQL with SRID 4326 expects lat-long by default, but our data is in long-lat format + * + * @return string */ protected function getSpatialAxisOrderSpec(): string { @@ -261,6 +273,8 @@ protected function getSpatialAxisOrderSpec(): string /** * Adapter supports optional spatial attributes with existing rows. + * + * @return bool */ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool { @@ -271,21 +285,24 @@ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool * Get SQL expression for operator * Override for MySQL-specific operator implementations * - * @param array $binds + * @param string $column + * @param \Utopia\Database\Operator $operator + * @param array $binds + * @return ?string */ - protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string + protected function getOperatorSQL(string $column, \Utopia\Database\Operator $operator, array &$binds): ?string { $quotedColumn = $this->quote($column); $method = $operator->getMethod(); $values = $operator->getValues(); switch ($method) { - case Operator::TYPE_ARRAY_APPEND: $bindKey = $this->registerOperatorBind($binds, json_encode($values)); - + case Operator::TYPE_ARRAY_APPEND: + $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)"; - case Operator::TYPE_ARRAY_PREPEND: $bindKey = $this->registerOperatorBind($binds, json_encode($values)); - + case Operator::TYPE_ARRAY_PREPEND: + $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; case Operator::TYPE_ARRAY_UNIQUE: From 4262ff8eb9491ed9441686d18344b01b6bfdf88f Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 12:56:25 +0300 Subject: [PATCH 07/18] On source of trues --- src/Database/Adapter/MariaDB.php | 9 +-- src/Database/Adapter/Postgres.php | 9 +-- src/Database/Operator.php | 9 +++ src/Database/Validator/Operator.php | 9 +-- tests/e2e/Adapter/Scopes/OperatorTests.php | 80 ++++++++++++++++++++++ 5 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 7a47c566d9..a28eb25ae3 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2111,13 +2111,8 @@ 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)); + if (!in_array($condition, Operator::ARRAY_FILTER_CONDITIONS, true)) { + throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', Operator::ARRAY_FILTER_CONDITIONS)); } $filterValue = $values[1] ?? null; $conditionKey = $this->registerOperatorBind($binds, $condition); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index f3030292ca..7b4cffaeab 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2661,13 +2661,8 @@ 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)); + if (!in_array($condition, Operator::ARRAY_FILTER_CONDITIONS, true)) { + throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', Operator::ARRAY_FILTER_CONDITIONS)); } $filterValue = $values[1] ?? null; $conditionKey = $this->registerOperatorBind($binds, $condition); diff --git a/src/Database/Operator.php b/src/Database/Operator.php index 0983ba10b6..c43d767097 100644 --- a/src/Database/Operator.php +++ b/src/Database/Operator.php @@ -72,6 +72,15 @@ class Operator */ public const MAX_ARRAY_OPERATOR_SIZE = 10000; + /** + * Conditions accepted by the arrayFilter operator. + */ + public const ARRAY_FILTER_CONDITIONS = [ + 'equal', 'notEqual', // comparison + 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', // numeric + 'isNull', 'isNotNull', // null checks + ]; + protected const NUMERIC_TYPES = [ self::TYPE_INCREMENT, self::TYPE_DECREMENT, diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 3832702319..ded8cc8e03 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -405,13 +405,8 @@ private function validateOperatorForAttribute( return false; } - $validConditions = [ - 'equal', 'notEqual', // Comparison - 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', // Numeric - 'isNull', 'isNotNull' // Null checks - ]; - if (!\in_array($values[0], $validConditions, true)) { - $this->message = "Invalid array filter condition '{$values[0]}'. Must be one of: " . \implode(', ', $validConditions); + if (!\in_array($values[0], DatabaseOperator::ARRAY_FILTER_CONDITIONS, true)) { + $this->message = "Invalid array filter condition '{$values[0]}'. Must be one of: " . \implode(', ', DatabaseOperator::ARRAY_FILTER_CONDITIONS); return false; } diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index c93bc3a3dc..b4a8c84fcf 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1695,6 +1695,86 @@ public function testOperatorArraySizeLimit(): void $database->deleteCollection($collectionId); } + /** + * An invalid array filter condition is rejected the same way on every adapter, because the + * operator validator checks it before any adapter runs. + */ + public function testOperatorArrayFilterRejectsUnknownCondition(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_filter_unknown_cond'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + + $database->createDocument($collectionId, new Document([ + '$id' => 'doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'tags' => ['a', 'b', 'c'], + ])); + + try { + $database->updateDocument($collectionId, 'doc', new Document([ + 'tags' => Operator::arrayFilter('bogusCondition', 'x'), + ])); + $this->fail('Expected an exception for an invalid array filter condition'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('filter condition', $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + + /** + * Every filter condition the validator accepts must actually work the same on all adapters. + * Each condition is applied to [1,2,3,4,5] and must keep exactly the matching elements — + * an adapter that doesn't handle a condition would keep the whole array instead. + */ + public function testOperatorArrayFilterAllConditions(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_filter_all_conditions'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createDocument($collectionId, new Document([ + '$id' => 'doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [1, 2, 3, 4, 5], + ])); + + $cases = [ + ['equal', 3, [3]], + ['notEqual', 3, [1, 2, 4, 5]], + ['greaterThan', 3, [4, 5]], + ['greaterThanEqual', 3, [3, 4, 5]], + ['lessThan', 3, [1, 2]], + ['lessThanEqual', 3, [1, 2, 3]], + ['isNotNull', null, [1, 2, 3, 4, 5]], + ['isNull', null, []], + ]; + + foreach ($cases as [$condition, $compare, $expected]) { + $database->updateDocument($collectionId, 'doc', new Document(['numbers' => [1, 2, 3, 4, 5]])); // reset + $updated = $database->updateDocument($collectionId, 'doc', new Document([ + 'numbers' => Operator::arrayFilter($condition, $compare), + ])); + $this->assertEquals($expected, \array_values($updated->getAttribute('numbers')), "arrayFilter('{$condition}') gave the wrong result"); + } + + $database->deleteCollection($collectionId); + } + public function testOperatorStringConcatComprehensive(): void { $database = static::getDatabase(); From 3b2216628805fb596c907c1ebd92a6d62ff4815e Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 13:01:07 +0300 Subject: [PATCH 08/18] Remove throw --- src/Database/Adapter/MariaDB.php | 3 --- src/Database/Adapter/Postgres.php | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index a28eb25ae3..c4855ff259 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2111,9 +2111,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_FILTER: $condition = $values[0] ?? 'equal'; - if (!in_array($condition, Operator::ARRAY_FILTER_CONDITIONS, true)) { - throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', Operator::ARRAY_FILTER_CONDITIONS)); - } $filterValue = $values[1] ?? null; $conditionKey = $this->registerOperatorBind($binds, $condition); $valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue)); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 7b4cffaeab..88075faa69 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2661,9 +2661,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_FILTER: $condition = $values[0] ?? 'equal'; - if (!in_array($condition, Operator::ARRAY_FILTER_CONDITIONS, true)) { - throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', Operator::ARRAY_FILTER_CONDITIONS)); - } $filterValue = $values[1] ?? null; $conditionKey = $this->registerOperatorBind($binds, $condition); $valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue)); From e84f4504d3438c05ce1405cd5e010ffc6211d893 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 13:10:51 +0300 Subject: [PATCH 09/18] Catch throws --- tests/e2e/Adapter/Scopes/OperatorTests.php | 32 +++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index b4a8c84fcf..5917e15341 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -5,7 +5,7 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Helpers\Permission; @@ -459,7 +459,7 @@ public function testOperatorErrorHandling(): void ])); // Test increment on non-numeric field - $this->expectException(DatabaseException::class); + $this->expectException(StructureException::class); $this->expectExceptionMessage("Cannot apply increment operator to non-numeric field 'text_field'"); $database->updateDocument($collectionId, 'error_test_doc', new Document([ @@ -496,7 +496,7 @@ public function testOperatorArrayErrorHandling(): void ])); // Test append on non-array field - $this->expectException(DatabaseException::class); + $this->expectException(StructureException::class); $this->expectExceptionMessage("Cannot apply arrayAppend operator to non-array field 'text_field'"); $database->updateDocument($collectionId, 'array_error_test_doc', new Document([ @@ -531,7 +531,7 @@ public function testOperatorInsertErrorHandling(): void ])); // Test insert with negative index - $this->expectException(DatabaseException::class); + $this->expectException(StructureException::class); $this->expectExceptionMessage("Cannot apply arrayInsert operator: index must be a non-negative integer"); $database->updateDocument($collectionId, 'insert_error_test_doc', new Document([ @@ -585,7 +585,7 @@ public function testOperatorValidationEdgeCases(): void 'string_field' => Operator::increment(5) ])); $this->fail('Expected exception for increment on string field'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString("Cannot apply increment operator to non-numeric field 'string_field'", $e->getMessage()); } @@ -595,7 +595,7 @@ public function testOperatorValidationEdgeCases(): void 'int_field' => Operator::stringConcat(' suffix') ])); $this->fail('Expected exception for concat on integer field'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString("Cannot apply stringConcat operator", $e->getMessage()); } @@ -605,7 +605,7 @@ public function testOperatorValidationEdgeCases(): void 'string_field' => Operator::arrayAppend(['new']) ])); $this->fail('Expected exception for arrayAppend on string field'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString("Cannot apply arrayAppend operator to non-array field 'string_field'", $e->getMessage()); } @@ -615,7 +615,7 @@ public function testOperatorValidationEdgeCases(): void 'int_field' => Operator::toggle() ])); $this->fail('Expected exception for toggle on integer field'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString("Cannot apply toggle operator to non-boolean field 'int_field'", $e->getMessage()); } @@ -625,7 +625,7 @@ public function testOperatorValidationEdgeCases(): void 'string_field' => Operator::dateAddDays(5) ])); $this->fail('Expected exception for dateAddDays on string field'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { // Date operators check if string can be parsed as date $this->assertStringContainsString("Cannot apply dateAddDays operator to non-datetime field 'string_field'", $e->getMessage()); } @@ -660,7 +660,7 @@ public function testOperatorDivisionModuloByZero(): void 'number' => Operator::divide(0) ])); $this->fail('Expected exception for division by zero'); - } catch (DatabaseException $e) { + } catch (OperatorException $e) { $this->assertStringContainsString("Division by zero is not allowed", $e->getMessage()); } @@ -670,7 +670,7 @@ public function testOperatorDivisionModuloByZero(): void 'number' => Operator::modulo(0) ])); $this->fail('Expected exception for modulo by zero'); - } catch (DatabaseException $e) { + } catch (OperatorException $e) { $this->assertStringContainsString("Modulo by zero is not allowed", $e->getMessage()); } @@ -716,7 +716,7 @@ public function testOperatorArrayInsertOutOfBounds(): void 'items' => Operator::arrayInsert(10, 'new') // Index 10 > length 3 ])); $this->fail('Expected exception for out of bounds insert'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString("Cannot apply arrayInsert operator: index 10 is out of bounds for array of length 3", $e->getMessage()); } @@ -865,7 +865,7 @@ public function testOperatorReplaceValidation(): void 'number' => Operator::stringReplace('4', '5') ])); $this->fail('Expected exception for replace on integer field'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString("Cannot apply stringReplace operator to non-string field 'number'", $e->getMessage()); } @@ -1687,7 +1687,7 @@ public function testOperatorArraySizeLimit(): void try { $database->updateDocument($collectionId, 'doc', new Document(['tags' => $operator])); $this->fail("Expected an exception for {$name} exceeding the array operator size limit"); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString('exceeds maximum allowed size', $e->getMessage()); } } @@ -1723,7 +1723,7 @@ public function testOperatorArrayFilterRejectsUnknownCondition(): void 'tags' => Operator::arrayFilter('bogusCondition', 'x'), ])); $this->fail('Expected an exception for an invalid array filter condition'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString('filter condition', $e->getMessage()); } @@ -3799,7 +3799,7 @@ public function testOperatorArrayInsertAtExactBoundaries(): void 'items' => Operator::arrayInsert(10, 'z') ])); $this->fail('Expected exception for out of bounds insert'); - } catch (DatabaseException $e) { + } catch (StructureException $e) { $this->assertStringContainsString('out of bounds', $e->getMessage()); } From c9db3ae2462106e41a71934677fd45975cc65226 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 15:21:57 +0300 Subject: [PATCH 10/18] Adress comments --- src/Database/Validator/Operator.php | 19 ++++++++ tests/e2e/Adapter/Scopes/OperatorTests.php | 54 ++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index ded8cc8e03..648ef5c174 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -221,6 +221,25 @@ private function validateOperatorForAttribute( } } + // An unbounded power is computed directly by the database. Some engines hard-error on + // results that are not real numbers, so reject those up-front with a clear message. + if ($method === DatabaseOperator::TYPE_POWER && $this->currentDocument !== null && !isset($values[1])) { + $base = (float)($this->currentDocument->getAttribute($operator->getAttribute()) ?? 0); + $exponent = (float)$values[0]; + + // Zero raised to a negative power is undefined (a division by zero). + if ($base === 0.0 && $exponent < 0) { + $this->message = "Cannot apply power operator: zero raised to a negative power is undefined"; + return false; + } + + // A negative base raised to a fractional exponent is not a real number. + if ($base < 0.0 && \floor($exponent) !== $exponent) { + $this->message = "Cannot apply power operator: a negative base raised to a fractional exponent is undefined"; + return false; + } + } + break; case DatabaseOperator::TYPE_ARRAY_APPEND: case DatabaseOperator::TYPE_ARRAY_PREPEND: diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 5917e15341..89cb80bf00 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1651,6 +1651,60 @@ public function testOperatorPowerOnZeroOrNegativeBase(): void $database->deleteCollection($collectionId); } + /** + * A power with no maximum is computed directly by the database. When the result is not a real + * number (zero raised to a negative power, or a negative base with a fractional exponent) some + * databases throw a hard error. Validation rejects these before they reach the database, so the + * update fails the same way on every adapter and the stored value is left untouched. + */ + public function testOperatorUnboundedPowerOnUndefinedBase(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_power_undefined'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + + // 0 raised to a negative power is undefined, so the update is rejected and 0 stays 0. + $database->createDocument($collectionId, new Document([ + '$id' => 'zero', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 0.0, + ])); + try { + $database->updateDocument($collectionId, 'zero', new Document([ + 'value' => Operator::power(-1), + ])); + $this->fail('Expected the update to be rejected for zero raised to a negative power'); + } catch (StructureException) { + // expected + } + $this->assertEquals(0.0, $database->getDocument($collectionId, 'zero')->getAttribute('value')); + + // The square root of a negative number is not a real number, so the update is rejected and -4 stays -4. + $database->createDocument($collectionId, new Document([ + '$id' => 'neg', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => -4.0, + ])); + try { + $database->updateDocument($collectionId, 'neg', new Document([ + 'value' => Operator::power(0.5), + ])); + $this->fail('Expected the update to be rejected for a negative base with a fractional exponent'); + } catch (StructureException) { + // expected + } + $this->assertEquals(-4.0, $database->getDocument($collectionId, 'neg')->getAttribute('value')); + + $database->deleteCollection($collectionId); + } + /** * Passing more values than the allowed maximum (10000) to an array operator must be rejected * the same way on every adapter. Covers each operator that takes a caller-supplied value list. From a172cfa3c77d019fe95bbf5eec7d1f088db2acea Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 17:44:54 +0300 Subject: [PATCH 11/18] Run tests --- src/Database/Adapter/Memory.php | 10 ++- src/Database/Adapter/Mongo.php | 6 ++ src/Database/Adapter/Postgres.php | 7 ++ src/Database/Adapter/Redis.php | 11 ++- src/Database/Validator/Operator.php | 21 +----- tests/e2e/Adapter/Scopes/OperatorTests.php | 86 ++++++++++++++-------- 6 files changed, 90 insertions(+), 51 deletions(-) diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 6e3362b39b..5c33585421 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -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; @@ -3546,7 +3547,14 @@ protected function applyOperator(mixed $current, Operator $operator): mixed return $this->preserveNumericType($base, $result); } - return $this->preserveNumericType($base, $base ** $by); + // 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->preserveNumericType($base, $result); case Operator::TYPE_STRING_CONCAT: return ((string) ($current ?? '')).(string) ($values[0] ?? ''); diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 30c940ca65..e8380789f6 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4012,6 +4012,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; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 88075faa69..28b738ed61 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2121,6 +2121,13 @@ protected function processException(PDOException $e): \Exception return new LimitException('Numeric value out of range', $e->getCode(), $e); } + // Invalid argument for power function (e.g. 0 to a negative power, or a negative base to a + // fractional exponent) — matches MariaDB, which reports the same as a numeric range error. + if ($e->getCode() === '2201F' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Value out of range', $e->getCode(), $e); + } + + // Datetime field overflow if ($e->getCode() === '22008' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return new LimitException('Datetime field overflow', $e->getCode(), $e); diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 106be87a30..187b7b2fc4 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -11,6 +11,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\Exception\Query as QueryException; @@ -4119,7 +4120,15 @@ protected function applyOperator(mixed $current, Operator $operator): mixed return $this->preserveNumericType($base, $result); } - return $this->preserveNumericType($base, $base ** $by); + // 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 (which + // also can't be JSON-encoded, so it would otherwise surface as a raw JsonException). + $result = $base ** $by; + if (!\is_finite($result)) { + throw new LimitException('Value out of range'); + } + + return $this->preserveNumericType($base, $result); case Operator::TYPE_STRING_CONCAT: return ((string) ($current ?? '')) . (string) ($values[0] ?? ''); diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 648ef5c174..4943b23793 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -148,7 +148,7 @@ private function validateOperatorForAttribute( // Handle both Document objects and arrays $type = $attribute instanceof Document ? $attribute->getAttribute('type') : $attribute['type']; - $isArray = $attribute instanceof Document ? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); + $isArray = $attribute instanceof Document? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); // Array operators that carry a caller-supplied value list are capped to guard against // memory exhaustion. Enforced here so every adapter rejects an oversized list the same way. @@ -221,25 +221,6 @@ private function validateOperatorForAttribute( } } - // An unbounded power is computed directly by the database. Some engines hard-error on - // results that are not real numbers, so reject those up-front with a clear message. - if ($method === DatabaseOperator::TYPE_POWER && $this->currentDocument !== null && !isset($values[1])) { - $base = (float)($this->currentDocument->getAttribute($operator->getAttribute()) ?? 0); - $exponent = (float)$values[0]; - - // Zero raised to a negative power is undefined (a division by zero). - if ($base === 0.0 && $exponent < 0) { - $this->message = "Cannot apply power operator: zero raised to a negative power is undefined"; - return false; - } - - // A negative base raised to a fractional exponent is not a real number. - if ($base < 0.0 && \floor($exponent) !== $exponent) { - $this->message = "Cannot apply power operator: a negative base raised to a fractional exponent is undefined"; - return false; - } - } - break; case DatabaseOperator::TYPE_ARRAY_APPEND: case DatabaseOperator::TYPE_ARRAY_PREPEND: diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 89cb80bf00..43b876ee64 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -5,6 +5,7 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Type as TypeException; @@ -1652,10 +1653,17 @@ public function testOperatorPowerOnZeroOrNegativeBase(): void } /** - * A power with no maximum is computed directly by the database. When the result is not a real - * number (zero raised to a negative power, or a negative base with a fractional exponent) some - * databases throw a hard error. Validation rejects these before they reach the database, so the - * update fails the same way on every adapter and the stored value is left untouched. + * A power with no maximum is computed directly by the engine on the live row value, so each + * adapter behaves as its engine does when the result is not a real number (zero raised to a + * negative power, or a negative base with a fractional exponent): + * - Most engines (MariaDB/MySQL/Postgres) and the in-memory adapters (Memory/Redis) reject it + * with a LimitException and leave the stored value untouched. + * - MongoDB rejects 0-to-a-negative-power the same way, but has no error for a negative base + * with a fractional exponent, so it stores NaN. + * - SQLite never raises on undefined math; it stores NULL (or leaves the value as-is). + * + * The one behaviour that must never happen on any adapter is silently storing a plausible but + * wrong real number, so the assertions verify the stored value via a fresh read. */ public function testOperatorUnboundedPowerOnUndefinedBase(): void { @@ -1670,37 +1678,57 @@ public function testOperatorUnboundedPowerOnUndefinedBase(): void $database->createCollection($collectionId); $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); - // 0 raised to a negative power is undefined, so the update is rejected and 0 stays 0. - $database->createDocument($collectionId, new Document([ - '$id' => 'zero', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'value' => 0.0, - ])); - try { - $database->updateDocument($collectionId, 'zero', new Document([ - 'value' => Operator::power(-1), + // [id, starting value, operator]. Each result is mathematically undefined. + $undefined = [ + ['zero', 0.0, Operator::power(-1)], // 0 to a negative power + ['neg', -4.0, Operator::power(0.5)], // square root of a negative number + ]; + + foreach ($undefined as [$id, $start, $operator]) { + $database->createDocument($collectionId, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => $start, ])); - $this->fail('Expected the update to be rejected for zero raised to a negative power'); - } catch (StructureException) { - // expected + + + try { + $caught = false; + $database->updateDocument($collectionId, $id, new Document(['value' => $operator])); + } catch (\Throwable $e) { + $caught = true; + $this->assertInstanceOf(LimitException::class, $e); + + // Verify the actual stored value with a fresh read, not the returned document. + $stored = $database->getDocument($collectionId, $id)->getAttribute('value'); + + // Whatever the engine raised must surface as a LimitException — not a raw + // PDO/Mongo/Json error. The row must also be left exactly as it was (the failed + // update is rolled back, no partial write). + $this->assertInstanceOf(LimitException::class, $caught); + $this->assertEquals($start, $stored, "{$id}: value changed even though the update raised a LimitException"); + } + + if ($caught === false) { + // Verify the actual stored value with a fresh read, not the returned document. + $stored = $database->getDocument($collectionId, $id)->getAttribute('value'); + + // Engines that never raise on undefined math (SQLite, and MongoDB for a negative + // base) must still not store a wrong real number: the value is either untouched or + // an explicit "not a number" marker (NULL / NaN). + $safe = $stored === null || $stored == $start || !\is_finite((float) $stored); + $this->assertTrue($safe, "{$id}: undefined power neither raised a LimitException nor left a safe value; stored " . \var_export($stored, true)); + } } - $this->assertEquals(0.0, $database->getDocument($collectionId, 'zero')->getAttribute('value')); - // The square root of a negative number is not a real number, so the update is rejected and -4 stays -4. + // A valid unbounded power still computes normally on every adapter: 2^3 = 8. $database->createDocument($collectionId, new Document([ - '$id' => 'neg', + '$id' => 'valid', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'value' => -4.0, + 'value' => 2.0, ])); - try { - $database->updateDocument($collectionId, 'neg', new Document([ - 'value' => Operator::power(0.5), - ])); - $this->fail('Expected the update to be rejected for a negative base with a fractional exponent'); - } catch (StructureException) { - // expected - } - $this->assertEquals(-4.0, $database->getDocument($collectionId, 'neg')->getAttribute('value')); + $database->updateDocument($collectionId, 'valid', new Document(['value' => Operator::power(3)])); + $this->assertEquals(8.0, $database->getDocument($collectionId, 'valid')->getAttribute('value')); $database->deleteCollection($collectionId); } From 7874ad174efb0a943bae4e662d8690bf7423eede Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 17:46:54 +0300 Subject: [PATCH 12/18] lint --- src/Database/Validator/Operator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 4943b23793..ded8cc8e03 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -148,7 +148,7 @@ private function validateOperatorForAttribute( // Handle both Document objects and arrays $type = $attribute instanceof Document ? $attribute->getAttribute('type') : $attribute['type']; - $isArray = $attribute instanceof Document? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); + $isArray = $attribute instanceof Document ? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); // Array operators that carry a caller-supplied value list are capped to guard against // memory exhaustion. Enforced here so every adapter rejects an oversized list the same way. From 5dec9cb63a9a8ffc09affa9fbb4c3002c3983d3a Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 17:56:49 +0300 Subject: [PATCH 13/18] update --- src/Database/Adapter/Postgres.php | 1 - tests/e2e/Adapter/Scopes/OperatorTests.php | 20 ++++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 28b738ed61..e5748c3b0d 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2127,7 +2127,6 @@ protected function processException(PDOException $e): \Exception return new LimitException('Value out of range', $e->getCode(), $e); } - // Datetime field overflow if ($e->getCode() === '22008' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return new LimitException('Datetime field overflow', $e->getCode(), $e); diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 43b876ee64..7ce8c7fd1b 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1691,31 +1691,27 @@ public function testOperatorUnboundedPowerOnUndefinedBase(): void 'value' => $start, ])); - + $caught = false; try { - $caught = false; $database->updateDocument($collectionId, $id, new Document(['value' => $operator])); } catch (\Throwable $e) { $caught = true; + + // Whatever the engine raised must surface as a LimitException — not a raw + // PDO/Mongo/Json error. $this->assertInstanceOf(LimitException::class, $e); - // Verify the actual stored value with a fresh read, not the returned document. + // The row must be left exactly as it was — the failed update is rolled back, no + // partial write. Verify with a fresh read, not the returned document. $stored = $database->getDocument($collectionId, $id)->getAttribute('value'); - - // Whatever the engine raised must surface as a LimitException — not a raw - // PDO/Mongo/Json error. The row must also be left exactly as it was (the failed - // update is rolled back, no partial write). - $this->assertInstanceOf(LimitException::class, $caught); $this->assertEquals($start, $stored, "{$id}: value changed even though the update raised a LimitException"); } if ($caught === false) { - // Verify the actual stored value with a fresh read, not the returned document. - $stored = $database->getDocument($collectionId, $id)->getAttribute('value'); - // Engines that never raise on undefined math (SQLite, and MongoDB for a negative // base) must still not store a wrong real number: the value is either untouched or - // an explicit "not a number" marker (NULL / NaN). + // an explicit "not a number" marker (NULL / NaN). Verify with a fresh read. + $stored = $database->getDocument($collectionId, $id)->getAttribute('value'); $safe = $stored === null || $stored == $start || !\is_finite((float) $stored); $this->assertTrue($safe, "{$id}: undefined power neither raised a LimitException nor left a safe value; stored " . \var_export($stored, true)); } From edaea0d34e4099568dc9aa3b536082cf75ed3d0a Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 18:20:00 +0300 Subject: [PATCH 14/18] update --- src/Database/Adapter/MariaDB.php | 34 ++++++++++----- src/Database/Adapter/Memory.php | 10 ++--- src/Database/Adapter/Mongo.php | 32 +++++++++----- src/Database/Adapter/Postgres.php | 35 +++++++++++----- src/Database/Adapter/Redis.php | 10 ++--- src/Database/Adapter/SQLite.php | 34 ++++++++++----- src/Database/Validator/Operator.php | 11 +++-- tests/e2e/Adapter/Scopes/OperatorTests.php | 49 ++++++++++++++++++++++ 8 files changed, 161 insertions(+), 54 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index c4855ff259..d581bb4f13 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2020,18 +2020,32 @@ 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]); - // A base of 1 or less can't exceed a positive max, so leave it unchanged — - // this also avoids POWER() on 0 or a negative base (which yields NULL for a - // negative/fractional exponent). For a base above 1 compare with logarithms - // so POWER() is computed at most once, and only when the result fits the max. - return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) - WHEN :$bindKey * LOG(COALESCE({$quotedColumn}, 0)) > LOG(:$maxKey) THEN COALESCE({$quotedColumn}, 0) - 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. + $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}"; + } + // Positive base: compare with logarithms so POWER() never runs on a value that + // would overflow (base^exp > max <=> exp * LOG(base) > LOG(max)). + $whens[] = "WHEN {$col} > 0 AND :$bindKey * LOG({$col}) > LOG(:$maxKey) THEN {$col}"; + // Non-positive base (no overflow risk): compare the computed value directly. + $whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}"; + + $whenSql = \implode(' ', $whens); + return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; } return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 5c33585421..7f9e5fe59e 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -3533,14 +3533,14 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $max = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; if ($max !== null) { - // A base of 1 or less can't exceed a positive max, so leave it unchanged. - // This also avoids INF/NAN from undefined powers (0 to a negative power, - // or a negative base to a fractional power) being stored. - if ($base <= 1) { + // 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; - if ($result > $max) { + // 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); } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index e8380789f6..88f0180a9f 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1892,17 +1892,29 @@ private function getOperatorExpression(Operator $operator, string $field): mixed case Operator::TYPE_POWER: $base = ['$ifNull' => [$ref, 0]]; - $expr = ['$pow' => [$base, $values[0]]]; + $exponent = $values[0]; + $expr = ['$pow' => [$base, $exponent]]; if (isset($values[1])) { - // A base of 1 or less can't exceed a positive max, so leave it unchanged. - // This also avoids storing NaN/Infinity from undefined powers (a negative - // base to a fractional power, or 0 to a negative power) — Mongo orders NaN - // below all numbers, so a plain `result <= max` check would wrongly apply it. - $expr = ['$cond' => [ - ['$lte' => [$base, 1]], - $base, - ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]], - ]]; + // 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; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index e5748c3b0d..b965f4e73c 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2578,19 +2578,32 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi return "{$quotedColumn} = MOD(COALESCE({$columnRef}::numeric, 0), :$bindKey::numeric)"; 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]); - // A base of 1 or less can't exceed a positive max, so leave it unchanged. - // This also avoids POWER() domain errors: PostgreSQL throws a hard error for - // 0 to a negative power and for a negative number to a fractional power. - // For a base above 1 we compare with logarithms so POWER() is computed at - // most once, and only when the result is within the max. - return "{$quotedColumn} = CASE - WHEN COALESCE({$columnRef}, 0) <= 1 THEN COALESCE({$columnRef}, 0) - WHEN :$bindKey * LN(COALESCE({$columnRef}, 0)) > LN(:$maxKey) THEN COALESCE({$columnRef}, 0) - ELSE POWER(COALESCE({$columnRef}, 0), :$bindKey) - END"; + $col = "COALESCE({$columnRef}, 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. PostgreSQL throws + // a hard error for 0 to a negative power and a negative base to a fractional + // exponent, so those must never reach POWER(). + $whens = []; + if ($exponent < 0) { + $whens[] = "WHEN {$col} = 0 THEN {$col}"; + } + if (\floor($exponent) != $exponent) { + $whens[] = "WHEN {$col} < 0 THEN {$col}"; + } + // Positive base: compare with logarithms so POWER() never runs on a value that + // would overflow (base^exp > max <=> exp * LN(base) > LN(max)). + $whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}"; + // Non-positive base (no overflow risk): compare the computed value directly. + $whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}"; + + $whenSql = \implode(' ', $whens); + return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; } return "{$quotedColumn} = POWER(COALESCE({$columnRef}, 0), :$bindKey)"; diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 187b7b2fc4..fa60c3a935 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -4106,14 +4106,14 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $max = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; if ($max !== null) { - // A base of 1 or less can't exceed a positive max, so leave it unchanged. - // This also avoids INF/NAN from undefined powers (0 to a negative power, - // or a negative base to a fractional power) being stored. - if ($base <= 1) { + // 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; - if ($result > $max) { + // 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); } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 3137c8deb0..1d4d1c89db 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2091,19 +2091,33 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi } $values = $operator->getValues(); - $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]); - // A base of 1 or less can't exceed a positive max, so leave it unchanged — - // this also avoids POWER() on 0 or a negative base (undefined for a - // negative/fractional exponent). For a base above 1 compare with logarithms - // so POWER() is computed at most once, and only when the result fits the max. - return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) - WHEN :$bindKey * LN(COALESCE({$quotedColumn}, 0)) > LN(:$maxKey) THEN COALESCE({$quotedColumn}, 0) - 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. + $whens = []; + if ($exponent < 0) { + // 0 to a negative power is undefined. + $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}"; + } + // Positive base: compare with logarithms so POWER() never runs on a value that + // would overflow (base^exp > max <=> exp * LN(base) > LN(max)). + $whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}"; + // Non-positive base (no overflow risk): compare the computed value directly. + $whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}"; + + $whenSql = \implode(' ', $whens); + return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; } return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index ded8cc8e03..655d5d511d 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -152,17 +152,22 @@ private function validateOperatorForAttribute( // Array operators that carry a caller-supplied value list are capped to guard against // memory exhaustion. Enforced here so every adapter rejects an oversized list the same way. + // The payload may be spread across $values or wrapped in $values[0] (the same shape the + // operators normalize with), so measure whichever the operator will actually process. if ( \in_array($method, [ DatabaseOperator::TYPE_ARRAY_APPEND, DatabaseOperator::TYPE_ARRAY_PREPEND, DatabaseOperator::TYPE_ARRAY_INTERSECT, DatabaseOperator::TYPE_ARRAY_DIFF, + DatabaseOperator::TYPE_ARRAY_REMOVE, ], true) - && \count($values) > DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE ) { - $this->message = "Array size " . \count($values) . " exceeds maximum allowed size of " . DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"; - return false; + $payload = (isset($values[0]) && \is_array($values[0])) ? $values[0] : $values; + if (\count($payload) > DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE) { + $this->message = "Array size " . \count($payload) . " exceeds maximum allowed size of " . DatabaseOperator::MAX_ARRAY_OPERATOR_SIZE . " for array operations"; + return false; + } } switch ($method) { diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 7ce8c7fd1b..9b7159a065 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1729,6 +1729,50 @@ public function testOperatorUnboundedPowerOnUndefinedBase(): void $database->deleteCollection($collectionId); } + /** + * A bounded power (with a max) must still compute the result whenever it fits within the max — + * it only leaves the value unchanged when the result would exceed the max, or when the input is + * mathematically undefined. A base of 1 or less is NOT a reason to skip: 0.5^2 = 0.25 and + * (-4)^2 = 16 are perfectly valid and within their bounds. Verified via a fresh read. + */ + public function testOperatorBoundedPowerComputesWithinMax(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'operator_bounded_power'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + + // [id, starting value, operator, expected stored value]. + $cases = [ + ['fraction', 0.5, Operator::power(2, 1), 0.25], // 0.5^2 = 0.25, within max 1 → applied + ['negeven', -4.0, Operator::power(2, 20), 16.0], // (-4)^2 = 16, within max 20 → applied + ['within', 2.0, Operator::power(3, 100), 8.0], // 2^3 = 8, within max 100 → applied + ['exceeds', 5.0, Operator::power(3, 100), 5.0], // 5^3 = 125 > 100 → left unchanged + ['negfrac', -4.0, Operator::power(0.5, 100), -4.0], // sqrt(-4) undefined → left unchanged + ['zeroneg', 0.0, Operator::power(-1, 100), 0.0], // 0^-1 undefined → left unchanged + ]; + + foreach ($cases as [$id, $start, $operator, $expected]) { + $database->createDocument($collectionId, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => $start, + ])); + $database->updateDocument($collectionId, $id, new Document(['value' => $operator])); + + $stored = $database->getDocument($collectionId, $id)->getAttribute('value'); + $this->assertEqualsWithDelta($expected, $stored, 0.000001, "bounded power case '{$id}' stored the wrong value"); + } + + $database->deleteCollection($collectionId); + } + /** * Passing more values than the allowed maximum (10000) to an array operator must be rejected * the same way on every adapter. Covers each operator that takes a caller-supplied value list. @@ -1759,6 +1803,11 @@ public function testOperatorArraySizeLimit(): void 'arrayPrepend' => Operator::arrayPrepend($tooMany), 'arrayIntersect' => Operator::arrayIntersect($tooMany), 'arrayDiff' => Operator::arrayDiff($tooMany), + // arrayRemove wraps its argument, so the oversized list lands in values[0]. + 'arrayRemove' => Operator::arrayRemove($tooMany), + // A wrapped payload (the list nested in values[0]) must be capped too, not just the + // spread form — otherwise count($values) would see 1 and slip past the limit. + 'arrayAppend (wrapped)' => Operator::arrayAppend([$tooMany]), ]; foreach ($operators as $name => $operator) { From adb2bbb2dbaaff5e5a884ddcb559b65277575677 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 18:31:42 +0300 Subject: [PATCH 15/18] edge cases --- src/Database/Adapter/MariaDB.php | 9 ++++----- src/Database/Adapter/Postgres.php | 9 ++++----- src/Database/Adapter/SQLite.php | 9 ++++----- tests/e2e/Adapter/Scopes/OperatorTests.php | 14 ++++++++------ 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index d581bb4f13..aabf679bca 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2038,11 +2038,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // A negative base to a fractional exponent is not a real number. $whens[] = "WHEN {$col} < 0 THEN {$col}"; } - // Positive base: compare with logarithms so POWER() never runs on a value that - // would overflow (base^exp > max <=> exp * LOG(base) > LOG(max)). - $whens[] = "WHEN {$col} > 0 AND :$bindKey * LOG({$col}) > LOG(:$maxKey) THEN {$col}"; - // Non-positive base (no overflow risk): compare the computed value directly. - $whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}"; + // Compare magnitudes with logarithms so POWER() never runs on a value that would + // overflow (|base|^exp > max <=> exp * LOG(|base|) > LOG(max)). Works for both + // signs; ABS() keeps LOG() defined for negative bases with an integer exponent. + $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"; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index b965f4e73c..1f8fe63725 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2596,11 +2596,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (\floor($exponent) != $exponent) { $whens[] = "WHEN {$col} < 0 THEN {$col}"; } - // Positive base: compare with logarithms so POWER() never runs on a value that - // would overflow (base^exp > max <=> exp * LN(base) > LN(max)). - $whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}"; - // Non-positive base (no overflow risk): compare the computed value directly. - $whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}"; + // Compare magnitudes with logarithms so POWER() never runs on a value that would + // overflow (|base|^exp > max <=> exp * LN(|base|) > LN(max)). Works for both + // signs; ABS() keeps LN() defined for negative bases with an integer exponent. + $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; $whenSql = \implode(' ', $whens); return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 1d4d1c89db..a8f5c5b9bd 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2110,11 +2110,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // A negative base to a fractional exponent is not a real number. $whens[] = "WHEN {$col} < 0 THEN {$col}"; } - // Positive base: compare with logarithms so POWER() never runs on a value that - // would overflow (base^exp > max <=> exp * LN(base) > LN(max)). - $whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}"; - // Non-positive base (no overflow risk): compare the computed value directly. - $whens[] = "WHEN {$col} <= 0 AND POWER({$col}, :$bindKey) > :$maxKey THEN {$col}"; + // Compare magnitudes with logarithms so POWER() never runs on a value that would + // overflow (|base|^exp > max <=> exp * LN(|base|) > LN(max)). Works for both + // signs; ABS() keeps LN() defined for negative bases with an integer exponent. + $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; $whenSql = \implode(' ', $whens); return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 9b7159a065..e7618bc2ad 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1750,12 +1750,14 @@ public function testOperatorBoundedPowerComputesWithinMax(): void // [id, starting value, operator, expected stored value]. $cases = [ - ['fraction', 0.5, Operator::power(2, 1), 0.25], // 0.5^2 = 0.25, within max 1 → applied - ['negeven', -4.0, Operator::power(2, 20), 16.0], // (-4)^2 = 16, within max 20 → applied - ['within', 2.0, Operator::power(3, 100), 8.0], // 2^3 = 8, within max 100 → applied - ['exceeds', 5.0, Operator::power(3, 100), 5.0], // 5^3 = 125 > 100 → left unchanged - ['negfrac', -4.0, Operator::power(0.5, 100), -4.0], // sqrt(-4) undefined → left unchanged - ['zeroneg', 0.0, Operator::power(-1, 100), 0.0], // 0^-1 undefined → left unchanged + ['fraction', 0.5, Operator::power(2, 1), 0.25], // 0.5^2 = 0.25, within max 1 → applied + ['negeven', -4.0, Operator::power(2, 20), 16.0], // (-4)^2 = 16, within max 20 → applied + ['negodd', -2.0, Operator::power(3, 100), -8.0], // (-2)^3 = -8, within max 100 → applied + ['within', 2.0, Operator::power(3, 100), 8.0], // 2^3 = 8, within max 100 → applied + ['exceeds', 5.0, Operator::power(3, 100), 5.0], // 5^3 = 125 > 100 → left unchanged + ['negexceeds', -10.0, Operator::power(100, 100), -10.0], // (-10)^100 far exceeds max → left unchanged (no overflow error) + ['negfrac', -4.0, Operator::power(0.5, 100), -4.0], // sqrt(-4) undefined → left unchanged + ['zeroneg', 0.0, Operator::power(-1, 100), 0.0], // 0^-1 undefined → left unchanged ]; foreach ($cases as [$id, $start, $operator, $expected]) { From db28249e8eb305a37c9672c21910c219d406ac04 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 1 Jul 2026 18:45:27 +0300 Subject: [PATCH 16/18] Address greptile review --- src/Database/Adapter/MariaDB.php | 18 ++++++++++++++---- src/Database/Adapter/Postgres.php | 18 ++++++++++++++---- src/Database/Adapter/SQLite.php | 18 ++++++++++++++---- tests/e2e/Adapter/Scopes/OperatorTests.php | 1 + 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index aabf679bca..7be1a65f66 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2029,6 +2029,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // 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). @@ -2038,10 +2040,18 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // A negative base to a fractional exponent is not a real number. $whens[] = "WHEN {$col} < 0 THEN {$col}"; } - // Compare magnitudes with logarithms so POWER() never runs on a value that would - // overflow (|base|^exp > max <=> exp * LOG(|base|) > LOG(max)). Works for both - // signs; ABS() keeps LOG() defined for negative bases with an integer exponent. - $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LOG(ABS({$col})) > LOG(:$maxKey) 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"; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 1f8fe63725..ed9e2c66f2 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2589,6 +2589,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // undefined guard its value can actually trigger is emitted. PostgreSQL throws // a hard error for 0 to a negative power and a negative base to a fractional // exponent, so those must never reach POWER(). + $oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0; + $whens = []; if ($exponent < 0) { $whens[] = "WHEN {$col} = 0 THEN {$col}"; @@ -2596,10 +2598,18 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (\floor($exponent) != $exponent) { $whens[] = "WHEN {$col} < 0 THEN {$col}"; } - // Compare magnitudes with logarithms so POWER() never runs on a value that would - // overflow (|base|^exp > max <=> exp * LN(|base|) > LN(max)). Works for both - // signs; ABS() keeps LN() defined for negative bases with an integer exponent. - $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; + // Cap by magnitude via logarithms so POWER() never runs on a value that would + // overflow (base^exp > max <=> exp * LN(base) > LN(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 * LN({$col}) > LN(:$maxKey) THEN {$col}"; + } else { + // Otherwise the result is non-negative, so its magnitude equals its value — + // cap either sign. ABS() keeps LN() defined for a negative even-power base. + $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; + } $whenSql = \implode(' ', $whens); return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a8f5c5b9bd..4f0fa8cf48 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2101,6 +2101,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // 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. @@ -2110,10 +2112,18 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // A negative base to a fractional exponent is not a real number. $whens[] = "WHEN {$col} < 0 THEN {$col}"; } - // Compare magnitudes with logarithms so POWER() never runs on a value that would - // overflow (|base|^exp > max <=> exp * LN(|base|) > LN(max)). Works for both - // signs; ABS() keeps LN() defined for negative bases with an integer exponent. - $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; + // Cap by magnitude via logarithms so POWER() never runs on a value that would + // overflow (base^exp > max <=> exp * LN(base) > LN(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 * LN({$col}) > LN(:$maxKey) THEN {$col}"; + } else { + // Otherwise the result is non-negative, so its magnitude equals its value — + // cap either sign. ABS() keeps LN() defined for a negative even-power base. + $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; + } $whenSql = \implode(' ', $whens); return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index e7618bc2ad..8cdcaf1e08 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1753,6 +1753,7 @@ public function testOperatorBoundedPowerComputesWithinMax(): void ['fraction', 0.5, Operator::power(2, 1), 0.25], // 0.5^2 = 0.25, within max 1 → applied ['negeven', -4.0, Operator::power(2, 20), 16.0], // (-4)^2 = 16, within max 20 → applied ['negodd', -2.0, Operator::power(3, 100), -8.0], // (-2)^3 = -8, within max 100 → applied + ['negoddbig', -5.0, Operator::power(3, 100), -125.0], // (-5)^3 = -125, negative so within max 100 → applied ['within', 2.0, Operator::power(3, 100), 8.0], // 2^3 = 8, within max 100 → applied ['exceeds', 5.0, Operator::power(3, 100), 5.0], // 5^3 = 125 > 100 → left unchanged ['negexceeds', -10.0, Operator::power(100, 100), -10.0], // (-10)^100 far exceeds max → left unchanged (no overflow error) From 991564b8f9f724a740a98bb41021800bf7007669 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 5 Jul 2026 09:38:54 +0300 Subject: [PATCH 17/18] zero power --- src/Database/Adapter/MariaDB.php | 7 ++++++- src/Database/Adapter/Postgres.php | 7 ++++++- src/Database/Adapter/SQLite.php | 7 ++++++- tests/e2e/Adapter/Scopes/OperatorTests.php | 2 ++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 7be1a65f66..b83fc67bb8 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2042,7 +2042,12 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi } // 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) { + if ($exponent == 0) { + // Every base to the zeroth power is 1 (including 0^0), which the magnitude + // check below can't see for a base of 0. The result 1 exceeds the max when + // max < 1, i.e. LOG(max) < 0 (LOG also coerces the bound value numerically). + $whens[] = "WHEN LOG(:$maxKey) < 0 THEN {$col}"; + } elseif ($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. diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index ed9e2c66f2..38301c53c2 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2600,7 +2600,12 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi } // Cap by magnitude via logarithms so POWER() never runs on a value that would // overflow (base^exp > max <=> exp * LN(base) > LN(max)). - if ($oddInteger) { + if ($exponent == 0) { + // Every base to the zeroth power is 1 (including 0^0), which the magnitude + // check below can't see for a base of 0. The result 1 exceeds the max when + // max < 1, i.e. LN(max) < 0 (LN also coerces the bound value numerically). + $whens[] = "WHEN LN(:$maxKey) < 0 THEN {$col}"; + } elseif ($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. diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 4f0fa8cf48..73c08515d7 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -2114,7 +2114,12 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi } // Cap by magnitude via logarithms so POWER() never runs on a value that would // overflow (base^exp > max <=> exp * LN(base) > LN(max)). - if ($oddInteger) { + if ($exponent == 0) { + // Every base to the zeroth power is 1 (including 0^0), which the magnitude + // check below can't see for a base of 0. The result 1 exceeds the max when + // max < 1, i.e. LN(max) < 0 (LN also coerces the bound value numerically). + $whens[] = "WHEN LN(:$maxKey) < 0 THEN {$col}"; + } elseif ($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. diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 8cdcaf1e08..89c64bee5b 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -1759,6 +1759,8 @@ public function testOperatorBoundedPowerComputesWithinMax(): void ['negexceeds', -10.0, Operator::power(100, 100), -10.0], // (-10)^100 far exceeds max → left unchanged (no overflow error) ['negfrac', -4.0, Operator::power(0.5, 100), -4.0], // sqrt(-4) undefined → left unchanged ['zeroneg', 0.0, Operator::power(-1, 100), 0.0], // 0^-1 undefined → left unchanged + ['zeroexp', 0.0, Operator::power(0, 0.5), 0.0], // 0^0 = 1 > max 0.5 → left unchanged (not 1) + ['zeroexpok', 0.0, Operator::power(0, 5), 1.0], // 0^0 = 1, within max 5 → applied ]; foreach ($cases as [$id, $start, $operator, $expected]) { From dd16c2586763bbc6f27505a8d0b0ab6b170b3b04 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 5 Jul 2026 10:35:15 +0300 Subject: [PATCH 18/18] Check is operator --- src/Database/Adapter/MariaDB.php | 14 +++----------- src/Database/Adapter/Postgres.php | 14 +++----------- src/Database/Adapter/SQL.php | 14 +++----------- src/Database/Adapter/SQLite.php | 14 +++----------- 4 files changed, 12 insertions(+), 44 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index b83fc67bb8..4aa4793f43 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1038,21 +1038,13 @@ public function updateDocument(Document $collection, string $id, Document $docum */ $keyIndex = 0; $operatorBinds = []; - $operators = []; - - // Separate regular attributes from operators - foreach ($attributes as $attribute => $value) { - if (Operator::isOperator($value)) { - $operators[$attribute] = $value; - } - } foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); // Check if this is an operator or regular attribute - if (isset($operators[$attribute])) { - $operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $operatorBinds); + if (Operator::isOperator($value)) { + $operatorSQL = $this->getOperatorSQL($column, $value, $operatorBinds); $columns .= $operatorSQL . ','; } else { $bindKey = 'key_' . $keyIndex; @@ -1086,7 +1078,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $keyIndex = 0; foreach ($attributes as $attribute => $value) { // Handle operators separately - if (isset($operators[$attribute])) { + if (Operator::isOperator($value)) { continue; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 38301c53c2..8c680a072d 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1166,21 +1166,13 @@ public function updateDocument(Document $collection, string $id, Document $docum $keyIndex = 0; $operatorBinds = []; - $operators = []; - - // Separate regular attributes from operators - foreach ($attributes as $attribute => $value) { - if (Operator::isOperator($value)) { - $operators[$attribute] = $value; - } - } foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); // Check if this is an operator, spatial attribute, or regular attribute - if (isset($operators[$attribute])) { - $operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $operatorBinds); + if (Operator::isOperator($value)) { + $operatorSQL = $this->getOperatorSQL($column, $value, $operatorBinds); $columns .= $operatorSQL . ','; } elseif (\in_array($attribute, $spatialAttributes, true)) { $bindKey = 'key_' . $keyIndex; @@ -1213,7 +1205,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $keyIndex = 0; foreach ($attributes as $attribute => $value) { // Handle operators separately - if (isset($operators[$attribute])) { + if (Operator::isOperator($value)) { continue; } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index c1c0a264ec..03aefd5bc7 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -510,21 +510,13 @@ public function updateDocuments(Document $collection, Document $updates, array $ $keyIndex = 0; $operatorBinds = []; $columns = ''; - $operators = []; - - // Separate regular attributes from operators - foreach ($attributes as $attribute => $value) { - if (Operator::isOperator($value)) { - $operators[$attribute] = $value; - } - } foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); // Check if this is an operator, spatial attribute, or regular attribute - if (isset($operators[$attribute])) { - $columns .= $this->getOperatorSQL($column, $operators[$attribute], $operatorBinds); + if (Operator::isOperator($value)) { + $columns .= $this->getOperatorSQL($column, $value, $operatorBinds); } elseif (\in_array($attribute, $spatialAttributes)) { $columns .= "{$this->quote($column)} = " . $this->getSpatialGeomFromText(":key_{$keyIndex}"); $keyIndex++; @@ -569,7 +561,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ $keyIndex = 0; foreach ($attributes as $attributeName => $value) { // Skip operators as they don't need value binding - if (isset($operators[$attributeName])) { + if (Operator::isOperator($value)) { continue; } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 73c08515d7..015cce3767 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1336,21 +1336,13 @@ public function updateDocument(Document $collection, string $id, Document $docum */ $keyIndex = 0; $operatorBinds = []; - $operators = []; - - // Separate regular attributes from operators - foreach ($attributes as $attribute => $value) { - if (Operator::isOperator($value)) { - $operators[$attribute] = $value; - } - } foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); // Check if this is an operator, spatial attribute, or regular attribute - if (isset($operators[$attribute])) { - $operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $operatorBinds); + if (Operator::isOperator($value)) { + $operatorSQL = $this->getOperatorSQL($column, $value, $operatorBinds); $columns .= $operatorSQL; } elseif ($this->getSupportForSpatialAttributes() && \in_array($attribute, $spatialAttributes, true)) { $bindKey = 'key_' . $keyIndex; @@ -1389,7 +1381,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $keyIndex = 0; foreach ($attributes as $attribute => $value) { // Handle operators separately - if (isset($operators[$attribute])) { + if (Operator::isOperator($value)) { continue; }